# Eine Buchseite auf dem Grundlinienraster

> Eine gespiegelte zweispaltige Seite, deren Satzspiegel genau 44 Zeilen hoch ist, sodass der Grundtext über Spaltenabstand und Bund auf einem Raster steht.

- HTML-Fassung: https://postext.dev/de/cookbook/baseline-grid-book-page
- Rezept Nº 013 · Seite und Raster · Stufe 2 (Mittel) · Ausgaben: Canvas
- Gattungen: Jedes Genre
- Benötigt postext ≥ 1.25.0 · getestet mit 1.33.7 am 2026-10-15
- Seiten: [9](https://postext.dev/cookbook/baseline-grid-book-page/de/p01.webp?v=e523ddf9), [10](https://postext.dev/cookbook/baseline-grid-book-page/de/p02.webp?v=e523ddf9), [11](https://postext.dev/cookbook/baseline-grid-book-page/de/p03.webp?v=e523ddf9)
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=baseline-grid-book-page&lang=de (.postext: https://postext.dev/cookbook/baseline-grid-book-page/de/baseline-grid-book-page.postext)
- Zuletzt aktualisiert: 2026-10-10
- Andere Sprachen: [en](https://postext.dev/en/cookbook/baseline-grid-book-page.md), [es](https://postext.dev/es/cookbook/baseline-grid-book-page.md), [ca](https://postext.dev/ca/cookbook/baseline-grid-book-page.md), [fr](https://postext.dev/fr/cookbook/baseline-grid-book-page.md), [it](https://postext.dev/it/cookbook/baseline-grid-book-page.md), [pt](https://postext.dev/pt/cookbook/baseline-grid-book-page.md), [zh](https://postext.dev/zh/cookbook/baseline-grid-book-page.md), [ja](https://postext.dev/ja/cookbook/baseline-grid-book-page.md), [ar](https://postext.dev/ar/cookbook/baseline-grid-book-page.md)

## Kurz gesagt

Drei Seiten eines Essays darüber, wie alte Bücher gestaltet wurden. Sie zeigen, wie jede Textzeile über beide Spalten und beide gegenüberliegenden Seiten auf derselben Höhe steht.

## Was Sie setzen

Die Seiten 9 bis 11 von *Die linierte Seite*, einem Sammelband über das Büchermachen, enthalten „Geheimer Kanon“, einen Essay über die Proportionen, die mittelalterliche Schreiber ihren Seiten gaben. Seine Ränder folgen dem Kanon, den er beschreibt, 2:3:4:6 und gespiegelt, auf einem Endformat von 210 × 280 mm um zwei Spalten in Vollkorn. Ein Grundlinienraster ist in Blassrot über die Seiten gezeichnet, und der Grundtext beider Spalten und beider gegenüberliegender Seiten steht darauf. Der Titel in Playfair Display füllt die erste Spalte des Auftakts, die Zeichnung des Kanons steht über der zweiten. Abschnittsüberschriften stehen eine halbe Zeile neben dem Raster, und der Text darunter kehrt darauf zurück. Eine Werkstattnotiz in kleinerer Schrift läuft auf einem eigenen Zeilenabstand von 10 pt. Auf Seite 11 endet der Essay unter einer seitenbreiten Abbildung in zwei gleich lang geschnittenen Spalten, und das Impressum schließt die zweite.

**Dieses Rezept beantwortet:**

- Wie halte ich auf einer zweispaltigen Buchseite jede Zeile über Spalten und Doppelseite auf einem Raster?
- Wie binde ich Text an ein Grundlinienraster, damit die Zeilen über Spalten und gegenüberliegende Seiten hinweg fluchten?
- Wie erreiche ich bündige Spaltenenden und eine ausgeglichene letzte Seite (vertikaler Ausgleich)?
- Wie vermeide ich Witwen, Waisen und Ausgangszeilen mit nur einem Wort, und wie halte ich eine Überschrift bei ihrem Text?

## Die kurze Antwort

```js
// script.js, Zeilen 32–53
const LEAD = 13.4; // pt: the body leading is the pitch of the baseline grid
const UNIT = 8; // mm: the margins step 2:3:4:6 in units of 8 mm, like the canon's
const LINES = 44; // lines per column: the text block is a whole number of leads tall
const TRIM = { width: 210, height: 280 }; // mm
const MM_PER_PT = 25.4 / 72;
const page = {
  sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { mirror: true, // left is the inner margin; mirror swaps it on every verso
    left: mm(2 * UNIT), top: mm(3 * UNIT), right: mm(4 * UNIT), // 16, 24 and 32 mm
    bottom: mm(TRIM.height - 3 * UNIT - LINES * LEAD * MM_PER_PT) }, // 48 mm: six units
  // The layout keeps to the grid whether or not the overlay draws it.
  baselineGrid: { enabled: true, color: col('grid') },
};
const GUTTER = 6; // mm: 162 mm of text block make two columns of 78 mm
const layout = { layoutType: 'double', gutterWidth: mm(GUTTER),
  columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) } };
const body = { fontFamily: 'Vollkorn', fontSize: pt(9.8), lineHeight: pt(LEAD) };
// Lists add no space of their own (any whole number of leads would do). The grid never
// absorbs a list's top margin: the default 1.5 em (14.7 pt here) would set the items
// 1.3 pt off the lines of the column beside them, until the list reaches a new column.
const onGrid = { marginTop: pt(0), marginBottom: pt(0) };
```

## Zutaten

**Zeigt**

- [Gespiegelte Ränder](https://postext.dev/de/docs/configuration-page-layout.md#gespiegelte-ränder): Vier unabhängige Ränder; bei gespiegelten Rändern wird der linke zum Bundsteg und wechselt auf jeder linken Seite die Seite, sodass die Seiten Doppelseiten bilden.
- [Grundlinienraster](https://postext.dev/de/docs/configuration-page-layout.md#grundlinienraster): Der Zeilenabstand des Fließtexts ist das Raster, auf dem jede Zeile steht, sodass die Zeilen über Spalten und gegenüberliegende Seiten hinweg fluchten; das über die Seiten gezeichnete Raster macht es nur sichtbar.

**Verwendet außerdem**

- [Spaltenausgleich](https://postext.dev/de/docs/configuration-text.md#spaltenausgleich)
- [Das Raster bewusst verlassen](https://postext.dev/de/docs/architecture.md#rasterbrechende-elemente)
- [Platzierung von Abbildungen](https://postext.dev/de/docs/document-format.md#platzierung)
- [Witwen, Waisen und kurze Ausgangszeilen](https://postext.dev/de/docs/configuration-text.md#waisen-witwen-zu-kurze-ausgangszeilen-und-regeln-zum-zusammenhalten)
- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)
- [Endformat](https://postext.dev/de/docs/configuration-page-layout.md#vorgegebene-seitenformate)
- [Eine oder zwei Spalten](https://postext.dev/de/docs/configuration-page-layout.md#layout-typen)
- [Spaltenlinie](https://postext.dev/de/docs/configuration-page-layout.md#spaltenlinie)
- [Einheiten und Maße](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#maße)
- [Grundschrift](https://postext.dev/de/docs/configuration-text.md#fließtext)
- [Aufzählungen und Checklisten](https://postext.dev/de/docs/configuration-text.md#aufzählungen)
- [Nummerierte Listen](https://postext.dev/de/docs/configuration-text.md#nummerierte-listen)
- [Überschriftenebenen](https://postext.dev/de/docs/configuration-text.md#überschreibungen-pro-ebene)
- [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)
- [Abbildungen und Tabellen als Ressourcen](https://postext.dev/de/docs/document-format.md#ressourcen)
- [Seiten auf einem Canvas](https://postext.dev/de/docs/configuration-programmatic-usage.md#eine-seite-als-bitmap-rendern)
- [Verweise, die Abbildungen platzieren](https://postext.dev/de/docs/document-format.md#verweis-im-text-die-hauptform)
- [Papierfarbe](https://postext.dev/de/docs/configuration-page-layout.md#seite)

**Die Konfiguration auf einen Blick**

- [`bodyText`](https://postext.dev/de/docs/configuration-text.md#fließtext), [`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), [`orderedLists`](https://postext.dev/de/docs/configuration-text.md#nummerierte-listen), [`page`](https://postext.dev/de/docs/configuration-page-layout.md#seite), [`paragraphStyles`](https://postext.dev/de/docs/configuration-styles.md#absatzformate), [`unorderedLists`](https://postext.dev/de/docs/configuration-text.md#aufzählungen)

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

- Vollkorn (OFL-1.1), Playfair Display (OFL-1.1), Vollkorn SC (OFL-1.1)

## Zubereitung

### 1 · Überschriften dürfen das Raster verlassen, Listen nie

```js
// script.js, Zeilen 99–112
  headings: { fontFamily: 'Playfair Display', color: col('ink'), balancing,
    levels: [
      // parity 'odd': the essay opens on a recto.
      // Two lines of 46 pt, each four leads tall, then one lead: the text starts on line 10.
      { level: 1, breakBefore: { enabled: true, parity: 'odd' },
        fontSize: pt(46), lineHeight: pt(4 * LEAD), italic: true, color: col('rubric'),
        marginBottom: pt(LEAD) },
      // A lead and a half above puts the head between two grid lines. The margin below
      // needs no fitting: the engine snaps the text under a head to the next grid line.
      { level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), marginTop: pt(1.5 * LEAD) },
    ] },
  // Lists add no space of their own (onGrid, in the short answer): items stay on the grid.
  unorderedLists: { ...onGrid, bulletChar: '–', color: col('rubric'), fontWeight: 400 },
  orderedLists: { ...onGrid, color: col('rubric') }, // numbers in the rubric, text in ink
```

Der Abstand über einer Überschrift bleibt genau wie eingestellt, sodass 1,5 Zeilen die Überschrift zwischen zwei Rasterlinien setzen, und der Text darunter rastet auf der nächsten Linie ein ([Grundlinienraster](https://postext.dev/de/docs/configuration-page-layout.md#grundlinienraster)). Eine Liste rastet vor ihrem ersten Punkt nicht ein; die kurze Antwort setzt die Listenabstände daher auf null (jede ganze Zahl von Zeilen ginge ebenso). Mit den voreingestellten 1,5 em stünden die drei Punkte auf Seite 10 um 1,3 pt unter den Linien der Nachbarspalte. Die zwei Titelzeilen zu 46 pt nehmen je vier Zeilen ein und sein unterer Abstand eine weitere, sodass der Essay auf Zeile 10 beginnt.

![Seite 10: Linien für die Schrift. Eine linke Seite mit dem Außensteg links, unter dem Kolumnentitel „Die linierte Seite“. Die „Werkstattnotiz“ in kleiner Schrift fällt zwischen die Rasterlinien; die Überschrift „Linien für die Schrift“ darunter steht eine halbe Zeile neben dem Raster, und der Text unter ihr steht wieder auf den Linien. In der zweiten Spalte folgen „Was eine Gebrauchsseite bewahrt“ und seine Liste mit drei Punkten dem Raster der Nachbarabsätze.](https://postext.dev/cookbook/baseline-grid-book-page/de/p02.webp?v=e523ddf9)

*Seite 10: Die Liste in der zweiten Spalte hält das Raster der Absätze daneben; die Werkstattnotiz fällt zwischen die Rasterlinien, die Überschrift darunter steht eine halbe Zeile daneben, ihr Text wieder auf dem Raster.*

### 2 · Das Raster absichtlich verlassen und zurückkehren

```js
// script.js, Zeilen 115–120
  paragraphStyles: [
    { id: 'note', fontSize: pt(7.8), lineHeight: pt(10), color: col('muted'),
      boldColor: col('rubric'), firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
    { id: 'colophon', fontFamily: 'Vollkorn SC', fontSize: pt(7.5), lineHeight: pt(10),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
```

Die Notiz ist ein Container `:::paragraphs` in 7,8 auf 10 pt, außerhalb des Rasters. Nach ihrem letzten Absatz wird die Position auf die nächste Rasterlinie aufgerundet, sodass die folgende Überschrift dort steht, wo sie nach Grundtext stünde ([der Container `:::paragraphs`](https://postext.dev/de/docs/configuration-styles.md#der-container-paragraphs)). Das Impressum nutzt denselben Container in 7,5 auf 10 pt: Seine vier Zeilen nehmen 40 pt ein, 0,2 pt weniger als drei Zeilen, sodass die letzte Spalte des Essays bündig mit der danebenstehenden endet.

### 3 · Abbildungen dort platzieren, wo das Raster sie aufnehmen kann

```js
// script.js, Zeilen 319–332
// mm: a column wide and just short enough for a 17-lead band with its caption; a block wide
const CANON = [COLUMN_W, 61.8], PAGES = [BLOCK_W, 64];
const figure = (id, [width, height], placement, [caption, altText]) => ({
  id, typeId: 'figure', kind: 'svg', svg: { fileId: `${id}.svg`, width, height },
  placement, caption, altText, createdAt: 0, updatedAt: 0,
});
const resources = [
  // A float never lands above the paragraph that cites it. A column 'top' float cited in
  // the first column can still take the head of the second, on the same page.
  figure('canon', CANON, { position: 'top' }, t(CAPTIONS.canon)),
  // A page-wide 'top' float cannot, so it waits for the next page (gotcha: top-float-next-page).
  // Each band is rounded up to whole leads, so the text under it stays on the grid.
  figure('pages', PAGES, { position: 'top', span: 'page' }, t(CAPTIONS.pages)),
];
```

Ein Gleitobjekt landet nie über dem Absatz, der es zitiert ([Platzierung](https://postext.dev/de/docs/document-format.md#platzierung)). Der Kanon, in der ersten Spalte zitiert, kann trotzdem den Kopf der zweiten Spalte auf Seite 9 einnehmen; der seitenbreite Vergleich, auf Seite 10 zitiert, wartet auf Seite 11. Jedes Band wird auf ganze Zeilen aufgerundet, sodass der Text unter einer Zeichnung auf einer Rasterlinie beginnt, bündig mit der Nachbarspalte und mit der gegenüberliegenden Seite. Bemessen Sie eine Zeichnung nach dieser Rundung: Mit 61,8 mm nimmt das Band des Kanons samt Bildunterschrift 17 Zeilen ein; mit 62,5 mm wären es 18, und unter der Bildunterschrift bliebe fast eine Zeile Weißraum.

![Seite 11. Abbildung 1.2. Die Seite dieses Buches, 210 × 280 mm, zweimal. Links vom Kanon in Neuntel geteilt, mit einem Satzspiegel von 140 × 187 mm. Rechts so, wie das Buch sie setzt: Stege von 16, 24, 32 und 48 mm und zwei Spalten zu 44 Zeilen. Eine rechte Seite mit dem Bundsteg links, unter dem Kolumnentitel „Geheimer Kanon“: Abbildung 1.2, die beiden verglichenen Seiten, steht über die ganze Breite des Satzspiegels am Kopf der Seite, und der Essay endet darunter in zwei gleich langen Spalten; das Impressum „Gesetzt aus der Vollkorn, 9,8 auf 13,4 Punkt“ schließt die zweite.](https://postext.dev/cookbook/baseline-grid-book-page/de/p03.webp?v=e523ddf9)

*Seite 11: Der Text unter der seitenbreiten Abbildung beginnt auf einer Rasterlinie, und der Essay endet in zwei gleich langen Spalten, das Impressum schließt die zweite.*

### 4 · Den Spaltenausgleich die Spalten bündig machen lassen

```js
// script.js, Zeilen 86–87
// On by default: it fills a column a keep rule leaves short, and cuts the last page level.
const balancing = { enabled: true };
```

Jede volle Spalte auf den Seiten 9 und 10 endet auf Zeile 44, weil der Satzspiegel genau 44 Zeilen hoch ist und der Text darauf eingepasst wurde. „Mit Folgetext halten“ und die Regeln für Witwen, Waisen und zu kurze Ausgangszeilen sind standardmäßig aktiv. Wenn eine davon eine Spalte um eine Zeile zu kurz enden lässt, setzt der Spaltenausgleich den fehlenden Raum über eine frühere Überschrift ([Spaltenausgleich](https://postext.dev/de/docs/configuration-text.md#spaltenausgleich)), wo er als Lücke sichtbar wird; passen Sie den Text also ein, bis der Ausgleich nichts mehr zu füllen hat. Auf der letzten Seite verteilt er die übrigen Zeilen so, dass beide Spalten auf derselben Zeile enden.

### 5 · Die Ränder spiegeln und ab Seite 9 nummerieren

```js
// script.js, Zeilen 57–82
const OUTER = 4 * UNIT; // mm: the heads end where the text block does
const RISE = 1.5 * UNIT; // mm: heads 12 mm into the head margin; the drop folio 12 mm below
const TAB = 7; // mm from a folio to the title beside it
const onPage = (edge, x) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(RISE) } });
const head = (id, content, parity, placement, extra = {}) => ({
  kind: 'text', id, content, parity, placement, pages: 'body', // never on the opener
  fontFamily: 'Vollkorn SC', fontSize: pt(8.5), fontWeight: 600, letterSpacing: pt(0.8),
  color: col('muted'), ...extra,
});
const folio = { fontFamily: 'Vollkorn', fontWeight: 700, letterSpacing: pt(0),
  color: col('rubric') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', onPage('top-left', OUTER), folio),
  head('verso-title', '{title}', 'even', onPage('top-left', OUTER + TAB)),
  head('recto-title', '{chapterTitle}', 'odd', onPage('top-right', -(OUTER + TAB))),
  head('recto-folio', '{pageNumber}', 'odd', onPage('top-right', -OUTER), folio),
] };
// The opener drops its folio into the foot margin, centred under the text block.
const underBlock = { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(RISE) } };
const footer = { elements: [
  head('drop-folio', '{pageNumber}', 'all', underBlock, { ...folio, pages: 'opener' }),
] };
// startAt prints the folios from 9; pageIndexOffset counts the 8 pages before them, so
// mirroring, parity and breakBefore follow the book (7 would add a blank page).
const continuation = { pageIndexOffset: 8, pageNumbering: { startAt: 9 } };
```

Die kurze Antwort berechnet den Fußsteg: Das Endformat von 280 mm abzüglich des Kopfstegs von 24 mm und eines Satzspiegels von genau `LINES` Zeilen (44 × 13,4 pt = 208 mm) lässt 48 mm, sechs Einheiten, wie es die Folge 2:3:4:6 verlangt. Mit `mirror: true` ist der als `left` gesetzte Rand von 16 mm der Bundsteg, links auf einer rechten Seite und rechts auf einer linken ([gespiegelte Ränder](https://postext.dev/de/docs/configuration-page-layout.md#gespiegelte-ränder)); Kolumnentitel, die `OUTER` Millimeter von der Seitenkante verankert sind, enden also dort, wo der Satzspiegel endet, auf der Seite des Außenstegs. `pages: 'body'` hält sie vom Auftakt fern. `startAt: 9` druckt die Paginas 9 bis 11, und `pageIndexOffset: 8` zählt die acht Seiten davor, sodass die Spiegelung, die Parität der Kolumnentitel und `breakBefore: 'odd'` dem gebundenen Buch folgen. Mit einem Versatz von 7 zählte die erste Seite als linke Seite, und der Umbruch auf eine ungerade Seite setzte eine Vakatseite vor den Essay.

## 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/baseline-grid-book-page

### script.js

```js
// ═══ Postext Cookbook · Nº 013 · A book page on a baseline grid ═════════════════════
// https://postext.dev/en/cookbook/baseline-grid-book-page
// Code: MIT · Text: original (CC BY 4.0) · Pictures: drawn in code (CC BY 4.0)
// Fonts: Vollkorn, Playfair Display, Vollkorn SC (SIL OFL 1.1) · Needs postext ≥ 1.25.0
// An essay on the canon of page proportions, on a page that shows its geometry and grid.
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'de'; // @lang: the sample document's language ('en' | 'es' | 'pt' | 'fr' | 'de' | 'it')
const RECIPE = 'baseline-grid-book-page';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// A scribe's colours: ink, one rubric red and the pale red of a ruled page.
const palette = {
  ink: '#211d1a', // text: a warm near-black
  rubric: '#b3261e', // the one accent: title, numbers, folios, the drawings' lines
  grid: '#efc6bd', // the baseline grid, as pale as a manuscript's ruling
  rule: '#d5cbbb', // the column rule and the drawings' hairlines
  muted: '#716860', // running heads, the workshop note, the colophon (5.1:1 on paper)
  tint: '#ede4d3', // the ground of the drawings
  paper: '#fbf8f1',
};
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' } })),
  // Defaults this config does not restate link to main-color: point it at the rubric.
  { id: 'main-color', name: 'rubric (defaults)', value: { hex: palette.rubric, model: 'hex' } },
];

// #region answer: one grid for the whole spread: trim, mirrored margins, two columns, leading
const LEAD = 13.4; // pt: the body leading is the pitch of the baseline grid
const UNIT = 8; // mm: the margins step 2:3:4:6 in units of 8 mm, like the canon's
const LINES = 44; // lines per column: the text block is a whole number of leads tall
const TRIM = { width: 210, height: 280 }; // mm
const MM_PER_PT = 25.4 / 72;
const page = {
  sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { mirror: true, // left is the inner margin; mirror swaps it on every verso
    left: mm(2 * UNIT), top: mm(3 * UNIT), right: mm(4 * UNIT), // 16, 24 and 32 mm
    bottom: mm(TRIM.height - 3 * UNIT - LINES * LEAD * MM_PER_PT) }, // 48 mm: six units
  // The layout keeps to the grid whether or not the overlay draws it.
  baselineGrid: { enabled: true, color: col('grid') },
};
const GUTTER = 6; // mm: 162 mm of text block make two columns of 78 mm
const layout = { layoutType: 'double', gutterWidth: mm(GUTTER),
  columnRule: { enabled: true, color: col('rule'), lineWidth: pt(0.4) } };
const body = { fontFamily: 'Vollkorn', fontSize: pt(9.8), lineHeight: pt(LEAD) };
// Lists add no space of their own (any whole number of leads would do). The grid never
// absorbs a list's top margin: the default 1.5 em (14.7 pt here) would set the items
// 1.3 pt off the lines of the column beside them, until the list reaches a new column.
const onGrid = { marginTop: pt(0), marginBottom: pt(0) };
// #endregion

// #region heads: the book's title on versos, the essay's on rectos, folios from page 9
const OUTER = 4 * UNIT; // mm: the heads end where the text block does
const RISE = 1.5 * UNIT; // mm: heads 12 mm into the head margin; the drop folio 12 mm below
const TAB = 7; // mm from a folio to the title beside it
const onPage = (edge, x) => ({ anchor: { to: 'page', edge },
  offset: { x: mm(x), y: mm(RISE) } });
const head = (id, content, parity, placement, extra = {}) => ({
  kind: 'text', id, content, parity, placement, pages: 'body', // never on the opener
  fontFamily: 'Vollkorn SC', fontSize: pt(8.5), fontWeight: 600, letterSpacing: pt(0.8),
  color: col('muted'), ...extra,
});
const folio = { fontFamily: 'Vollkorn', fontWeight: 700, letterSpacing: pt(0),
  color: col('rubric') };
const header = { elements: [
  head('verso-folio', '{pageNumber}', 'even', onPage('top-left', OUTER), folio),
  head('verso-title', '{title}', 'even', onPage('top-left', OUTER + TAB)),
  head('recto-title', '{chapterTitle}', 'odd', onPage('top-right', -(OUTER + TAB))),
  head('recto-folio', '{pageNumber}', 'odd', onPage('top-right', -OUTER), folio),
] };
// The opener drops its folio into the foot margin, centred under the text block.
const underBlock = { anchor: { to: 'container', edge: 'top' }, offset: { y: mm(RISE) } };
const footer = { elements: [
  head('drop-folio', '{pageNumber}', 'all', underBlock, { ...folio, pages: 'opener' }),
] };
// startAt prints the folios from 9; pageIndexOffset counts the 8 pages before them, so
// mirroring, parity and breakBefore follow the book (7 would add a blank page).
const continuation = { pageIndexOffset: 8, pageNumbering: { startAt: 9 } };
// #endregion

// #region balance: full columns end flush, and the last page ends level
// On by default: it fills a column a keep rule leaves short, and cuts the last page level.
const balancing = { enabled: true };
// #endregion

const config = () => ({
  locale: t({ en: 'en-us', es: 'es', pt: 'pt', fr: 'fr', de: 'de', it: 'it' }), // hyphenation, caption labels
  colorPalette,
  page,
  layout,
  bodyText: { ...body, color: col('ink'), boldColor: col('ink'), italicColor: col('ink'),
    referenceColor: col('rubric'), referenceBold: false,
    firstLineIndent: mm(4), indentAfterHeading: false }, // hyphenation and widow rules are on
  // #region flow: heads that leave the grid and come back to it; lists that never leave it
  headings: { fontFamily: 'Playfair Display', color: col('ink'), balancing,
    levels: [
      // parity 'odd': the essay opens on a recto.
      // Two lines of 46 pt, each four leads tall, then one lead: the text starts on line 10.
      { level: 1, breakBefore: { enabled: true, parity: 'odd' },
        fontSize: pt(46), lineHeight: pt(4 * LEAD), italic: true, color: col('rubric'),
        marginBottom: pt(LEAD) },
      // A lead and a half above puts the head between two grid lines. The margin below
      // needs no fitting: the engine snaps the text under a head to the next grid line.
      { level: 2, fontSize: pt(12.5), lineHeight: pt(LEAD), marginTop: pt(1.5 * LEAD) },
    ] },
  // Lists add no space of their own (onGrid, in the short answer): items stay on the grid.
  unorderedLists: { ...onGrid, bulletChar: '–', color: col('rubric'), fontWeight: 400 },
  orderedLists: { ...onGrid, color: col('rubric') }, // numbers in the rubric, text in ink
  // #endregion
  // #region note: small type leaves the grid on purpose, and the flow snaps back after it
  paragraphStyles: [
    { id: 'note', fontSize: pt(7.8), lineHeight: pt(10), color: col('muted'),
      boldColor: col('rubric'), firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
    { id: 'colophon', fontFamily: 'Vollkorn SC', fontSize: pt(7.5), lineHeight: pt(10),
      color: col('muted'), textAlign: 'left', firstLineIndent: pt(0), marginTop: pt(LEAD) },
  ],
  // #endregion
  captionStyle: { fontSize: pt(8), labelColor: col('rubric'), descriptionItalic: true },
  header,
  footer,
});

// #region art: the two drawings, in the page's palette (lines only: no text, no filters)
const BLOCK_W = TRIM.width - 6 * UNIT; // mm: the text block, 162 mm
const COLUMN_W = (BLOCK_W - GUTTER) / 2; // mm: one column, 78 mm
const svgFile = (w, h, markup) => `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}"`
  + ` width="${w * 10}" height="${h * 10}">`
  + `<rect width="${w}" height="${h}" fill="${palette.tint}"/>${markup}</svg>`;
const ln = (x1, y1, x2, y2, stroke, w = 0.4) => `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}"`
  + ` stroke="${stroke}" stroke-width="${w}" stroke-linecap="round"/>`;
const leaf = (x, y, w, h) => `<rect x="${x}" y="${y}" width="${w}" height="${h}"`
  + ` fill="${palette.paper}" stroke="${palette.rule}" stroke-width="0.3"/>`;
const ruling = (x, y, w, h, n) => Array.from({ length: n }, (_, i) => // a ruled text block
  ln(x, y + (h * (i + 0.8)) / n, x + w, y + (h * (i + 0.8)) / n, palette.rule, 0.25)).join('');
const dot = (x, y) => `<circle cx="${x}" cy="${y}" r="0.8" fill="${palette.rubric}"/>`;

// Figure 1: the canon constructed on an open spread of 2:3 pages (sizes in mm).
function canonSvg(w, h) {
  const W = (w - 8) / 2, H = 1.5 * W, X = 4, Y = 3.5; // two 2:3 pages, the ninths under them
  const at = (fx, fy) => [X + fx * W, Y + fy * H]; // a point in page widths and heights
  const line = (a, b) => ln(...at(...a), ...at(...b), palette.rubric);
  let s = leaf(X, Y, W, H) + leaf(X + W, Y, W, H);
  s += ruling(...at(2 / 9, 1 / 9), (6 * W) / 9, (6 * H) / 9, 24); // verso text block
  s += ruling(...at(10 / 9, 1 / 9), (6 * W) / 9, (6 * H) / 9, 24); // recto text block
  s += `<circle cx="${X + W / 2}" cy="${Y + (4 * H) / 9}" r="${W / 2}" fill="none"`
    + ` stroke="${palette.muted}" stroke-width="0.35"/>`; // a page wide, a block tall
  s += ln(X + W, Y, X + W, Y + H, palette.muted, 0.5); // the spine
  s += line([0, 1], [2, 0]) + line([0, 0], [2, 1]); // 1 · the spread's diagonals
  s += line([1, 0], [0, 1]) + line([1, 0], [2, 1]); // 2 · each page's diagonal
  s += line([2 / 3, 1 / 3], [2 / 3, 0]) + line([4 / 3, 1 / 3], [4 / 3, 0]); // 3 · the verticals
  s += line([2 / 3, 0], [4 / 3, 1 / 3]) + line([4 / 3, 0], [2 / 3, 1 / 3]); // 4 · across the spine
  for (let i = 0; i <= 18; i++) { // the ninths, ticked under the spread
    const [x, y] = at(i / 9, 1);
    s += ln(x, y + 1.2, x, y + (i % 9 ? 2.8 : 4), palette.muted, 0.3);
  }
  for (const c of [[2 / 9, 1 / 9], [8 / 9, 1 / 9], [2 / 9, 7 / 9], [10 / 9, 1 / 9], [16 / 9, 1 / 9],
    [16 / 9, 7 / 9]]) s += dot(...at(...c)); // 5 · the corners the lines fix
  return svgFile(w, h, s);
}

// Figure 2: this book's page twice, at a quarter of its size.
function pagesSvg(w, h) {
  const k = (h - 8) / TRIM.height, W = TRIM.width * k, H = TRIM.height * k, Y = 4, GAP = 20;
  const X1 = (w - 2 * W - GAP) / 2, X2 = X1 + W + GAP;
  let s = leaf(X1, Y, W, H) + leaf(X2, Y, W, H);
  for (let i = 1; i < 9; i++) { // the canon's ninths
    s += ln(X1 + (i * W) / 9, Y, X1 + (i * W) / 9, Y + H, palette.grid, 0.3);
    s += ln(X1, Y + (i * H) / 9, X1 + W, Y + (i * H) / 9, palette.grid, 0.3);
  }
  s += ruling(X1 + W / 9, Y + H / 9, (6 * W) / 9, (6 * H) / 9, 32);
  s += `<rect x="${X1 + W / 9}" y="${Y + H / 9}" width="${(6 * W) / 9}" height="${(6 * H) / 9}"`
    + ` fill="none" stroke="${palette.rubric}" stroke-width="0.45"/>`;
  const colW = COLUMN_W * k, top = Y + 3 * UNIT * k, tall = LINES * LEAD * MM_PER_PT * k;
  const left = X2 + 2 * UNIT * k;
  for (const x of [left, left + colW + GUTTER * k]) s += ruling(x, top, colW, tall, LINES);
  s += `<rect x="${left}" y="${top}" width="${BLOCK_W * k}" height="${tall}"`
    + ` fill="none" stroke="${palette.rubric}" stroke-width="0.45"/>`;
  s += ln(X1, Y, X1, Y + H, palette.muted, 0.6) + ln(X2, Y, X2, Y + H, palette.muted, 0.6);
  return svgFile(w, h, s);
}
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Die linierte Seite"
subtitle: "Essays über das Büchermachen"
---

# Geheimer Kanon

Ehe ein Schreiber im Mittelalter ein Wort schrieb, war die Seite schon vermessen. Die Blätter einer Lage wurden am Rand mit der Ahle durchstochen und von Einstich zu Einstich mit dem Griffel oder dem Bleistift liniert, damit jede Zeile auf einer Linie stand und jedes Blatt des Buches denselben Rahmen hatte. In vielen Handschriften ist die Liniierung noch zu sehen, blass wie ein Wasserzeichen. Sie hält zwei Entscheidungen fest, die die Schreiber nie erklärt haben: wie groß die beschriebene Fläche sein sollte und wo auf dem Blatt sie zu stehen hatte. Die Zeichnung in :ref{id="canon"} gibt die Antwort, die drei Männer im zwanzigsten Jahrhundert unabhängig voneinander an den alten Büchern selbst nachgemessen haben.

1946 veröffentlichte J. A. van de Graaf in einer niederländischen Zeitschrift eine Konstruktion, die den Satzspiegel einer Seite allein mit dem Lineal findet, ohne irgendetwas zu messen. Ein Jahr später vertrat der argentinische Typograf Raúl Rosarivo in *Divina proporción tipográfica* die These, Gutenberg, Peter Schöffer und Nicolas Jenson hätten ihre Seiten in Neuntel geteilt. Und Jan Tschichold, der in seiner Jugend für die asymmetrische Typografie gestritten hatte, zeigte im Alter, warum sich die alten Proportionen so schwer verbessern lassen. Ihre Ergebnisse decken sich weitgehend. Die Methode heißt heute der geheime Kanon, weil niemand sie aufgeschrieben hat; messen lässt sie sich aber in Handschrift um Handschrift und in den ersten Drucken.

## Den Kanon zeichnen

Für die Konstruktion braucht es nur eine aufgeschlagene Doppelseite und ein Lineal:

1. Ziehen Sie die beiden Diagonalen der Doppelseite.
2. Ziehen Sie auf jeder Seite die Diagonale vom oberen Ende des Bundes zur unteren Außenecke.
3. Ziehen Sie dort, wo sie eine Diagonale der Doppelseite kreuzt, eine Senkrechte bis zum Kopf der Seite.
4. Verbinden Sie ihr oberes Ende mit dem Kreuzungspunkt auf der gegenüberliegenden Seite.
5. Die neue Linie schneidet die Seitendiagonale an der inneren oberen Ecke des Satzspiegels. Die äußere obere Ecke liegt auf der Diagonale der Doppelseite, die äußere untere auf der Seitendiagonale, und das Rechteck schließt sich von selbst.

Das Rechteck ist der Satzspiegel der Schreiber. Der Bundsteg misst ein Neuntel der Seitenbreite, der Außensteg zwei Neuntel; der Kopfsteg misst ein Neuntel der Seitenhöhe, der Fußsteg zwei Neuntel. Auf einer Seite im Verhältnis zwei zu drei stehen die vier Stege im Verhältnis 2:3:4:6, in der Folge Bund, Kopf, außen, Fuß. Der Satzspiegel hat die Proportionen der Seite und ist so hoch, wie die Seite breit ist; das zeigt der Kreis.

## Villards Figur

Mittelalterliche Zeichner konnten eine Strecke auch ohne Maßstab teilen. Das Bauhüttenbuch des Villard de Honnecourt aus dem dreizehnten Jahrhundert, dreiunddreißig Blätter mit Zeichnungen, die heute in der Bibliothèque nationale de France liegen, enthält eine Figur, die eine Strecke allein mit dem Lineal in gleiche Teile teilt: einige Diagonalen über einem Rechteck, und jeder Schnittpunkt markiert ein Drittel, ein Viertel oder ein Fünftel seiner Seite, so weit der Zeichner gehen will. Tschichold zeigte, dass dieselbe Figur die Neuntel einer Seite findet. Die Blätter des Buches messen, so wie sie erhalten sind, etwa 235 auf 155 Millimeter, nahe an zwei zu drei.

Rosarivo gelangte vom anderen Ende her zu den Neunteln. Er untersuchte mit Zirkel und Lineal Drucke des fünfzehnten Jahrhunderts und schloss, ihre Satzspiegel seien gefunden worden, indem man die Seite in beiden Richtungen in neun Teile teilte, von denen der Text sechs einnimmt; das Verhältnis 2:3 dieser Seiten nannte er die geheime Zahl. Fachleute des Gutenberg-Museums in Mainz prüften seine Befunde, die das *Gutenberg-Jahrbuch* später noch einmal veröffentlichte.

:::paragraphs{style="note"}
**Werkstattnotiz.** Auf einer Seite von 160 auf 240 Millimetern ergibt der Kanon Stege von 17,8 Millimetern am Bund, 26,7 am Kopf, 35,6 außen und 53,3 am Fuß sowie einen Satzspiegel von 106,7 auf 160 Millimetern, so hoch, wie die Seite breit ist. Teilen Sie Breite und Höhe in neun Teile und zählen Sie: ein Teil innen, einer oben, zwei außen und zwei unten.
:::

## Linien für die Schrift

Mit der Liniierung lagen außer dem Rahmen auch die Zeilen fest. Bis ins frühe dreizehnte Jahrhundert schrieben die meisten die erste Zeile auf die oberste gezogene Linie; binnen einer Generation hängten sie sie stattdessen darunter, ein Wechsel, der so regelmäßig ist, dass Paläografen Handschriften danach datieren. N. R. Ker, der ihn 1960 beschrieb, setzte ihn um 1230 an. Die Liniierung zeichnete auch die Spalten vor: Paare senkrechter Linien begrenzten jede Spalte eines zweispaltigen Buches, und die Haarlinie zwischen den Spalten dieser Seite ist ihre letzte Spur.

Die Drucker hielten an dieser Disziplin fest. Die Gutenberg-Bibel ist auf ihren ersten Seiten in zwei Spalten mit je vierzig Zeilen gesetzt, auf Seite zehn mit einundvierzig und auf fast allen folgenden mit zweiundvierzig; daher nennen Bibliografen sie die 42-zeilige Bibel. Im selben Satzspiegel rückten die Zeilen enger zusammen, sodass der Rahmen mehr Text aufnahm und die ganze Bibel mit weniger Papier auskam.

## Was eine Gebrauchsseite bewahrt

Der Kanon war für große Bücher gemacht, die Bestand haben sollten, mit Platz in den Rändern für die Notizen eines Lesers. Heute können sich das nur wenige Bücher leisten. Auf dieser Seite von 210 auf 280 Millimetern verlangte er einen Satzspiegel von 140 auf 187, und mehr als die Hälfte des Papiers bliebe leer. Die Seite, die Sie lesen, übernimmt stattdessen drei Dinge vom Kanon:

- die Abfolge seiner Stege, weiterhin 2:3:4:6, jetzt in Schritten von acht Millimetern: 16 am Bund, 24 am Kopf, 32 außen und 48 am Fuß;
- die Doppelseite als Einheit der Gestaltung: Zwei Bundstege zusammen sind so breit wie ein Außensteg, sodass das Weiß um jeden Satzspiegel über das aufgeschlagene Buch hinweg gleichmäßig wirkt;
- die Liniierung selbst, jetzt ein Grundlinienraster von 13,4 Punkt, auf dem der Text beider Spalten steht, auf dieser Seite wie auf der gegenüberliegenden.

Auf die Neuntel verzichtet sie. Ihr Satzspiegel, 162 auf 208 Millimeter, ist weit größer als der des Kanons und in zwei Spalten von 78 Millimetern geteilt, mit einem Spaltenabstand von sechs. Über den ganzen Satzspiegel gesetzt, hätte eine Zeile dieser Schrift mehr als hundert Zeichen, zu lang, als dass das Auge den Anfang der nächsten fände; eine Spalte fasst etwa fünfzig und genau 44 Zeilen. :ref{id="pages"} zeigt die beiden Seiten nebeneinander.

Eine Seite, die ihre Zeilen zählt, kann auch die Füße ihrer Spalten auf eine Höhe bringen: Jede volle Spalte dieses Essays endet auf der vierundvierzigsten Zeile. Wenn die Regeln, die eine Überschrift bei ihrem Text halten oder verhindern, dass ein Absatz eine einzelne Zeile zurücklässt, eine Spalte eine Zeile zu früh enden ließen, kommt die fehlende Zeile weiter oben als etwas mehr Raum über einer Überschrift hinzu, und die Spalte erreicht trotzdem die letzte Zeile. Auf der letzten Seite eines Essays werden die Spalten dagegen auf dieselbe Länge gekürzt, und der Text endet in einem ebenen Block.

Ein Grundlinienraster legt fest, wo jede Zeile stehen wird, bevor ein Wort gesetzt ist, wie es Ahle und Griffel auf dem Pergament taten. Die Listen in diesem Essay fügen keinen eigenen Abstand hinzu, sodass ihre Punkte auf dieselben Linien fallen wie die Absätze der Nachbarspalte. Eine Überschrift darf zwischen zwei Linien des Rasters stehen, mit anderthalb Zeilen darüber und einer halben darunter, doch der Text unter ihr kehrt sofort ins Raster zurück. Die Werkstattnotiz in kleinerer Schrift läuft auf einem eigenen, engeren Zeilenabstand und findet dort, wo sie endet, ins Raster zurück. Eine Abbildung wird ebenso behandelt: Ihr Block wird samt Bildunterschrift auf ganze Zeilen aufgerundet (siebzehn für :ref{id="canon"}), sodass der Text unter einer Zeichnung am Kopf einer Seite mit dem Text der gegenüberliegenden Seite Linie hält.

Das Raster ist auf diesen Seiten blassrot gezogen, damit man den Text daran prüfen kann. Schlagen Sie das Buch bei einer beliebigen Doppelseite auf: Jede Zeile der linken Seite setzt sich über den Bund hinweg in einer Zeile der rechten fort, so wie die Liniierung der Schreiber von Einstich zu Einstich über beide Hälften des aufgeschlagenen Bogens lief.

:::paragraphs{style="colophon"}
Gesetzt aus der Vollkorn, 9,8 auf 13,4 Punkt,

mit der Playfair Display und der Vollkorn SC

(SIL Open Font License 1.1)

Text und Zeichnungen: Postext Cookbook, CC BY 4.0
:::
`; // content.<lang>.md, inlined by the Cookbook
// Captions and alt text in every sample language: [caption, altText].
const CAPTIONS = {
  canon: {
    en: ['The canon on an open spread of 2:3 pages. The diagonals fix the corners of both text '
      + 'blocks; the circle, as wide as a page, is exactly as tall as its block.',
    'Two facing pages crossed by red construction lines, a ruled text block where they cross '
      + 'on each page, and a circle as wide as the left page.'],
    es: ['El canon sobre un pliego abierto de páginas 2:3. Las diagonales fijan las esquinas de '
      + 'las dos cajas; el círculo, tan ancho como la página, es tan alto como su caja.',
    'Dos páginas enfrentadas cruzadas por líneas rojas de construcción, una caja pautada donde '
      + 'se cortan en cada página y un círculo tan ancho como la página izquierda.'],
    pt: ['O cânone sobre uma página dupla aberta de páginas 2:3. As diagonais fixam os cantos '
      + 'das duas manchas; o círculo, da largura da página, tem exatamente a altura da mancha.',
    'Duas páginas lado a lado cruzadas por linhas vermelhas de construção, uma mancha pautada '
      + 'onde elas se cruzam em cada página e um círculo da largura da página esquerda.'],
    fr: ['Le canon sur une double page ouverte de pages 2:3. Les diagonales fixent les angles '
      + 'des deux blocs de texte ; le cercle, aussi large que la page, est exactement aussi haut '
      + 'que son bloc.',
    'Deux pages en regard traversées de lignes de construction rouges, un bloc de texte réglé '
      + 'là où elles se croisent sur chaque page, et un cercle aussi large que la page de gauche.'],
    de: ['Der Kanon auf einer aufgeschlagenen Doppelseite aus Seiten im Verhältnis 2:3. Die '
      + 'Diagonalen legen die Ecken beider Satzspiegel fest; der Kreis, so breit wie die Seite, '
      + 'ist genau so hoch wie ihr Satzspiegel.',
    'Zwei gegenüberliegende Seiten mit roten Konstruktionslinien, auf jeder Seite ein linierter '
      + 'Satzspiegel an ihren Schnittpunkten und ein Kreis so breit wie die linke Seite.'],
    it: ['Il canone su una doppia pagina aperta di pagine 2:3. Le diagonali fissano gli angoli '
      + 'delle due gabbie; il cerchio, largo quanto la pagina, è alto esattamente quanto la sua '
      + 'gabbia.',
    'Due pagine affiancate attraversate da linee di costruzione rosse, una gabbia rigata dove '
      + 'le linee si incrociano su ogni pagina e un cerchio largo quanto la pagina di sinistra.'],
  },
  pages: {
    en: ['This book’s 210 × 280 mm page twice. Left, divided by the canon into ninths, with a '
      + 'text block of 140 × 187 mm. Right, as the book sets it: margins of 16, 24, 32 and 48 '
      + 'mm and two columns of 44 lines.',
    'Two identical pages: the left one divided into a nine-by-nine grid around its text block, '
      + 'the right one holding two ruled columns.'],
    es: ['La página de 210 × 280 mm de este libro, dos veces. A la izquierda, dividida en '
      + 'novenos por el canon, con una caja de 140 × 187 mm. A la derecha, tal como la compone '
      + 'el libro: márgenes de 16, 24, 32 y 48 mm y dos columnas de 44 líneas.',
    'Dos páginas iguales: la izquierda, dividida en una cuadrícula de nueve por nueve alrededor '
      + 'de su caja; la derecha, con dos columnas pautadas.'],
    pt: ['A página de 210 × 280 mm deste livro, duas vezes. À esquerda, dividida em nonos pelo '
      + 'cânone, com uma mancha de 140 × 187 mm. À direita, como o livro a compõe: margens de '
      + '16, 24, 32 e 48 mm e duas colunas de 44 linhas.',
    'Duas páginas iguais: a da esquerda dividida numa grade de nove por nove em volta da '
      + 'mancha, a da direita com duas colunas pautadas.'],
    fr: ['La page de 210 × 280 mm de ce livre, deux fois. À gauche, divisée en neuvièmes par le '
      + 'canon, avec un bloc de texte de 140 × 187 mm. À droite, telle que le livre la compose : '
      + 'marges de 16, 24, 32 et 48 mm et deux colonnes de 44 lignes.',
    'Deux pages identiques : celle de gauche divisée en une grille de neuf sur neuf autour de '
      + 'son bloc de texte, celle de droite avec deux colonnes réglées.'],
    de: ['Die Seite dieses Buches, 210 × 280 mm, zweimal. Links vom Kanon in Neuntel geteilt, mit '
      + 'einem Satzspiegel von 140 × 187 mm. Rechts so, wie das Buch sie setzt: Stege von 16, 24, '
      + '32 und 48 mm und zwei Spalten zu 44 Zeilen.',
    'Zwei gleiche Seiten: die linke um ihren Satzspiegel in ein Raster von neun mal neun Feldern '
      + 'geteilt, die rechte mit zwei linierten Spalten.'],
    it: ['La pagina di 210 × 280 mm di questo libro, due volte. A sinistra, divisa in noni dal '
      + 'canone, con una gabbia di 140 × 187 mm. A destra, come la compone il libro: margini di '
      + '16, 24, 32 e 48 mm e due colonne di 44 righe.',
    'Due pagine uguali: quella di sinistra divisa in una griglia di nove per nove attorno alla '
      + 'sua gabbia, quella di destra con due colonne rigate.'],
  },
};

// #region figures: one figure heads the next column, the other the next page
// mm: a column wide and just short enough for a 17-lead band with its caption; a block wide
const CANON = [COLUMN_W, 61.8], PAGES = [BLOCK_W, 64];
const figure = (id, [width, height], placement, [caption, altText]) => ({
  id, typeId: 'figure', kind: 'svg', svg: { fileId: `${id}.svg`, width, height },
  placement, caption, altText, createdAt: 0, updatedAt: 0,
});
const resources = [
  // A float never lands above the paragraph that cites it. A column 'top' float cited in
  // the first column can still take the head of the second, on the same page.
  figure('canon', CANON, { position: 'top' }, t(CAPTIONS.canon)),
  // A page-wide 'top' float cannot, so it waits for the next page (gotcha: top-float-next-page).
  // Each band is rounded up to whole leads, so the text under it stays on the grid.
  figure('pages', PAGES, { position: 'top', span: 'page' }, t(CAPTIONS.pages)),
];
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the pages paint, loaded before the first build.
const FONTS = { // text, display and label faces (Vollkorn SC ships no italic)
  Vollkorn: ['400', '400i', '700'], 'Playfair Display': ['700', '700i'],
  'Vollkorn SC': ['400', '600'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadSvg('canon.svg', canonSvg(...CANON));
await loadSvg('pages.svg', pagesSvg(...PAGES));
const doc = await buildDocumentWithFonts({ markdown, resources, continuation }, config(),
  kitFonts(FONTS));
showPages(doc, { title: t({ en: 'A book page on a baseline grid',
  es: 'Una página de libro sobre una rejilla base',
  pt: 'Uma página de livro sobre a grade de linhas de base',
  fr: 'Une page de livre sur une grille de ligne de base',
  de: 'Eine Buchseite auf dem Grundlinienraster',
  it: 'Una pagina di libro sulla griglia della linea di base' }) });

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

### Das Raster für die Druckerei ausblenden

`enabled` steuert nur die über die fertigen Seiten gezeichneten Linien; das Layout hält das Raster von 13,4 pt in jedem Fall ein, und keine Zeile verschiebt sich.

```diff
-  baselineGrid: { enabled: true, color: col('grid') },
+  baselineGrid: { enabled: false },
```

### Sehen, was der Spaltenausgleich leistet

Ohne Spaltenausgleich setzt Seite 11 ihre letzten acht Zeilen und das Impressum in die erste Spalte und lässt die zweite leer.

```diff
-const balancing = { enabled: true };
+const balancing = { enabled: false };
```

## Häufige Fehler

- **Ein 'top'-Gleitobjekt kommt auf die Seite nach seinem Verweis, außer es darf die verweisende Seite eröffnen.** Standardmäßig steht ein Gleitobjekt nie über seinem eigenen Verweis, sodass ein seitenbreites 'top'-Gleitobjekt, auf das auf Seite N verwiesen wird, Seite N+1 eröffnet. Setzen Sie placement.citingPage: true an der Ressource oder am defaultPlacement ihres Typs (oder layout.floatsAtCitingPage für das ganze Buch, seit postext 1.25), dann steht es stattdessen oben auf Seite N, und der Text über dem Verweis rückt darunter, sofern es in layout.maxTopFraction passt und die verweisende Zeile auf dieser Seite bleibt. Es eröffnet nie die erste Seite eines Kapitels oder eine Seite nach einem erzwungenen Umbruch: dort eröffnet es weiterhin die nächste Seite, und 'auto' oder 'bottom' können den Fuß der verweisenden Seite einnehmen.

- Die Regel, dass ein Gleitobjekt `top` nie auf der zitierenden Seite landet, gilt für seitenbreite Gleitobjekte. Ein Spalten-Gleitobjekt `top` kann auf seiner zitierenden Seite landen, am Kopf einer späteren Spalte, wie Abbildung 1.1 auf Seite 9.
- Seitenformate, Ränder und Spaltenabstand verlangen absolute Einheiten (mm, pt, cm, in). Ein `em` hat dort keine Schriftgröße, an der es sich messen könnte, und der Build bricht mit „dimensionToPx: baseFontSizePx is required for unit "em"“ ab.
- Der Wortzwischenraum von Vollkorn ist schmal, 0,2 em; in einer Spalte von etwa 50 Zeichen fallen daher manche Blocksatzzeilen zu locker oder zu eng aus. Das Beispiel wurde Zeile für Zeile anhand des Berichts über zu lockere Zeilen aus der Aufnahme eingepasst; prüfen Sie es nach jeder Änderung an Text oder Satzbreite erneut.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: Der Essay „The Secret Canon“ und seine spanische, portugiesische, französische und deutsche Fassung, „El canon secreto“, „O cânone secreto“, „Le canon secret“ und „Geheimer Kanon“, für dieses Rezept geschrieben: Postext Cookbook, CC-BY-4.0
- Bilder: Die beiden Zeichnungen, in Code mit der Farbpalette der Seite erstellt: Postext Cookbook, CC-BY-4.0
- Schrift: Vollkorn (OFL-1.1), Playfair Display (OFL-1.1), Vollkorn SC (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 016 · Spanischer Blocksatz in einem Taschenbuchroman](https://postext.dev/de/cookbook/spanish-pocket-novel.md): Der Anfang von Kapitel I der Marianela als Taschenausgabe: spanische Trennung, Wortabstände unter 1,7×, keine zu kurzen Ausgangszeilen, Initiale, Figura 1. · Stufe 2 (Mittel) · Belletristik, Drama und Prosa
- [Nº 005 · Kolumnentitel nach Seitenparität in einem Essayband](https://postext.dev/de/cookbook/running-heads-by-parity.md): Kopfzeilenelemente nach Parität und Seitenrolle: Buchtitel links, gekürzter Essaytitel rechts, Paginamarken am Rand, Pagina am Fuß der Auftaktseiten. · Stufe 2 (Mittel) · Belletristik, Drama und Prosa
- [Nº 018 · Abschnittsüberschriften in sieben Ebenen](https://postext.dev/de/cookbook/section-heads-field-manual.md): Abschnitte 1.1 bis 1.12 in einer bernsteinfarbenen Kapsel, die mit der Nummer wächst, vier Ebenen darunter und eine siebte aus einem Überschriftenformat. · Stufe 2 (Mittel) · Handbücher und Nachschlagewerke
