# Klassiker im Taschenformat: kurze Kapitel, fortlaufend

> Dom Casmurro als Taschenbuch: Kurze Kapitel laufen unter in der Spalte gezeichneten Köpfen fort, und kein Seitenumbruch lässt eine Zeile oder einen Kopf allein.

- HTML-Fassung: https://postext.dev/de/cookbook/short-chapters-run-on
- Rezept Nº 065 · Überschriften und Auftakte · Stufe 2 (Mittel) · Ausgaben: Canvas
- Gattungen: Belletristik, Drama und Prosa
- Benötigt postext ≥ 1.25.0 · getestet mit 1.31.0 am 2026-10-13
- Seiten: [1](https://postext.dev/cookbook/short-chapters-run-on/en/p01.webp?v=686485db), [212](https://postext.dev/cookbook/short-chapters-run-on/en/p02.webp?v=686485db), [213](https://postext.dev/cookbook/short-chapters-run-on/en/p03.webp?v=686485db), [214](https://postext.dev/cookbook/short-chapters-run-on/en/p04.webp?v=686485db), [215](https://postext.dev/cookbook/short-chapters-run-on/en/p05.webp?v=686485db), [216](https://postext.dev/cookbook/short-chapters-run-on/en/p06.webp?v=686485db), [217](https://postext.dev/cookbook/short-chapters-run-on/en/p07.webp?v=686485db)
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=short-chapters-run-on&lang=en (.postext: https://postext.dev/cookbook/short-chapters-run-on/en/short-chapters-run-on.postext)
- Zuletzt aktualisiert: 2026-10-10
- Andere Sprachen: [en](https://postext.dev/en/cookbook/short-chapters-run-on.md), [es](https://postext.dev/es/cookbook/short-chapters-run-on.md), [ca](https://postext.dev/ca/cookbook/short-chapters-run-on.md), [fr](https://postext.dev/fr/cookbook/short-chapters-run-on.md), [it](https://postext.dev/it/cookbook/short-chapters-run-on.md), [pt](https://postext.dev/pt/cookbook/short-chapters-run-on.md), [zh](https://postext.dev/zh/cookbook/short-chapters-run-on.md), [ja](https://postext.dev/ja/cookbook/short-chapters-run-on.md), [ar](https://postext.dev/ar/cookbook/short-chapters-run-on.md)

## Kurz gesagt

Fünf Seiten eines klassischen brasilianischen Romans als kleines Taschenbuch. Jedes kurze Kapitel beginnt direkt nach dem vorigen, und keine Seite beginnt oder endet mit einer einzelnen Zeile.

## Was Sie setzen

Fünf Textseiten vom Ende von *Dom Casmurro*, Machado de Assis’ Roman von 1899, im portugiesischen Original als Taschenbuch von 110 × 178 mm in einer erfundenen Reihe gesetzt, der Coleção Casuarina. Ein Umschlag in Pflaume und Creme mit pflaumenfarbenen Bändern trägt den Titel in Abril Fatface und das safrangelbe Medaillon der Reihe, und eine gemalte Bildtafel des schweren Meers vor Flamengo, mit Diffusionsmodellen erzeugt, steht Seite 213 gegenüber. Der Text läuft vom Ende von Kapitel CXVIII bis zum Ende von CXXIII, und kein Kapitel beginnt eine Seite. Jedes beginnt zwei Zeilen unter dem Ende des vorigen (drei bei CXIX, um Seite 213 zu füllen), unter einer pflaumenfarbenen römischen Ziffer, seinem Titel in gesperrten Versalien und einer kurzen safrangelben Linie. Seite 214 hat zwei Kapitelköpfe, 215 und 216 je einen. Jede Textseite außer der letzten endet auf ihrer 33. Zeile, und keine beginnt mit einer einzelnen Zeile.

**Dieses Rezept beantwortet:**

- Wie setze ich kurze Kapitel fortlaufend, jeden Kopf beim Text, ohne Waisen und Ausgangszeilen aus einem Wort?
- Wie erreiche ich bündige Spaltenenden und eine ausgeglichene letzte Seite (vertikaler Ausgleich)?
- Wie nummeriere ich Überschriften (1, 1.1, 1.1.1) und gestalte jede Ebene anders?
- Wie setze ich Kolumnentitel: Buchtitel auf der linken Seite, Kapiteltitel auf der rechten, Seitenzahl außen?

## Die kurze Antwort

```js
// script.js, Zeilen 37–59
// Machado's chapters run a page or two, so none opens a page. The level's break is off (the
// H1 default would open each chapter on a recto) and a chapter starts two lines under the last.
const [NUMERAL, TITLE, TRACK, GAP] = [14, 7.5, 1.3, 1.6]; // pt, pt, pt, mm
const RULE_Y = NUMERAL * PT + GAP + TITLE * 1.2 * PT + GAP; // mm: under the title's line
const chapterHead = { enabled: true, slot: { elements: [ // no span: the head stays in the text
  { kind: 'text', id: 'numeral', content: '{number}', fontFamily: DISPLAY, fontSize: pt(NUMERAL),
    lineHeight: 1, color: col('plum'), align: 'center',
    placement: { ...at('container', 'top'), size: { width: 'fill' } } },
  { kind: 'text', id: 'title', content: '{titleText}', ...caps(TITLE, TRACK), color: col('ink'),
    align: 'center', overflow: 'wrap', // a long title takes a second line
    placement: { anchor: { to: '#numeral', edge: 'below' }, offset: { y: mm(GAP) },
      size: { width: 'fill' } } },
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('saffron'),
    placement: { ...at('container', 'top', 0, RULE_Y), size: { width: mm(8) } } },
] } }; // 11.7 mm deep: the head takes three lines, and the text under it stays on the grid
const chapters = { level: 1, numberingTemplate: '{1:I}', // {number} prints CXIX, CXX…
  fontSize: pt(TITLE), // the hidden heading line, measured in the headings' face
  breakBefore: { enabled: false }, marginTop: pt(2 * LEAD), advancedDesign: chapterHead };
// The keep rules are on by default. avoidWidows keeps widowMinLines (2) lines of a paragraph
// at the foot of a page, and headings.keepWithNext takes the head along when the paragraph
// moves on; avoidOrphans keeps two lines at the head of the next page; avoidRunts weighs a
// last line shorter than about 20 characters as a fault. Column balancing adds lines above
// a head so that the page ends on line 33.
```

## Zutaten

**Zeigt**

- [Witwen, Waisen und kurze Ausgangszeilen](https://postext.dev/de/docs/configuration-text.md#waisen-witwen-zu-kurze-ausgangszeilen-und-regeln-zum-zusammenhalten): Hält einzelne Zeilen von Spaltenanfang und Spaltenfuß fern und einwortige Ausgangszeilen aus den Absätzen; hält eine Überschrift bei ihrem Text und einen Absatz, der mit einem Doppelpunkt endet, bei der Liste, die er einleitet.
- [Gestaltete Auftakte](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung): Eine Überschrift, gezeichnet als freie Komposition aus Text, Linien, Kästen und Bildern, die die nötige Höhe über dem Fließtext reserviert.

**Verwendet außerdem**

- [Kapitel, die rechts beginnen](https://postext.dev/de/docs/configuration-text.md#umbruch-davor)
- [Nummerierte Überschriften](https://postext.dev/de/docs/configuration-text.md#überschreibungen-pro-ebene)
- [Spaltenausgleich](https://postext.dev/de/docs/configuration-text.md#spaltenausgleich)
- [Silbentrennung und Dokumentsprache](https://postext.dev/de/docs/justification.md#unterstützte-sprachen)
- [Einzüge, Ausrichtung und Absatzabstände](https://postext.dev/de/docs/configuration-text.md#fließtext)
- [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)
- [Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Kapitel ohne Nummer](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Umschläge, Titelseiten und Impressum](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Seiten- und Spaltenumbrüche](https://postext.dev/de/docs/document-format.md#pagebreak)
- [Bilder in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#bildelemente)
- [Text, Linien und Kästen in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Gespiegelte Ränder](https://postext.dev/de/docs/configuration-page-layout.md#gespiegelte-ränder)
- [Gleitende Kästen](https://postext.dev/de/docs/configuration-styles.md#der-container-callout)
- [Dokument-Metadaten](https://postext.dev/de/docs/document-format.md#frontmatter)
- [Kastensymbole und Eckplaketten](https://postext.dev/de/docs/configuration-styles.md#kastenstile)
- [Kästen](https://postext.dev/de/docs/configuration-styles.md#kastenstile)
- [Papierfarbe](https://postext.dev/de/docs/configuration-page-layout.md#seite)
- [Abbildungen und Tabellen als Ressourcen](https://postext.dev/de/docs/document-format.md#ressourcen)
- [Römisch paginierte Titelei](https://postext.dev/de/docs/document-format.md#numbering)
- [Zeilenumbrüche in Titeln](https://postext.dev/de/docs/document-format.md#zeilenumbrüche-in-titeln)

**Die Konfiguration auf einen Blick**

- [`bodyText`](https://postext.dev/de/docs/configuration-text.md#fließtext), [`calloutStyles`](https://postext.dev/de/docs/configuration-styles.md#kastenstile), [`colorPalette`](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette), [`footer`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`header`](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen), [`headingStyles`](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile), [`headings`](https://postext.dev/de/docs/configuration-text.md#überschriften), [`layout`](https://postext.dev/de/docs/configuration-page-layout.md#layout), [`locale`](https://postext.dev/de/docs/configuration-text.md#silbentrennung), [`page`](https://postext.dev/de/docs/configuration-page-layout.md#seite)

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

- Tinos (Apache-2.0), Abril Fatface (OFL-1.1), League Spartan (OFL-1.1)

## Zubereitung

### 1 · Die Kapitel fortlaufend setzen, jeden Kopf bei seinem Text

```js
// script.js, Zeilen 37–59
// Machado's chapters run a page or two, so none opens a page. The level's break is off (the
// H1 default would open each chapter on a recto) and a chapter starts two lines under the last.
const [NUMERAL, TITLE, TRACK, GAP] = [14, 7.5, 1.3, 1.6]; // pt, pt, pt, mm
const RULE_Y = NUMERAL * PT + GAP + TITLE * 1.2 * PT + GAP; // mm: under the title's line
const chapterHead = { enabled: true, slot: { elements: [ // no span: the head stays in the text
  { kind: 'text', id: 'numeral', content: '{number}', fontFamily: DISPLAY, fontSize: pt(NUMERAL),
    lineHeight: 1, color: col('plum'), align: 'center',
    placement: { ...at('container', 'top'), size: { width: 'fill' } } },
  { kind: 'text', id: 'title', content: '{titleText}', ...caps(TITLE, TRACK), color: col('ink'),
    align: 'center', overflow: 'wrap', // a long title takes a second line
    placement: { anchor: { to: '#numeral', edge: 'below' }, offset: { y: mm(GAP) },
      size: { width: 'fill' } } },
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('saffron'),
    placement: { ...at('container', 'top', 0, RULE_Y), size: { width: mm(8) } } },
] } }; // 11.7 mm deep: the head takes three lines, and the text under it stays on the grid
const chapters = { level: 1, numberingTemplate: '{1:I}', // {number} prints CXIX, CXX…
  fontSize: pt(TITLE), // the hidden heading line, measured in the headings' face
  breakBefore: { enabled: false }, marginTop: pt(2 * LEAD), advancedDesign: chapterHead };
// The keep rules are on by default. avoidWidows keeps widowMinLines (2) lines of a paragraph
// at the foot of a page, and headings.keepWithNext takes the head along when the paragraph
// moves on; avoidOrphans keeps two lines at the head of the next page; avoidRunts weighs a
// last line shorter than about 20 characters as a fault. Column balancing adds lines above
// a head so that the page ends on line 33.
```

Bei der Kapitelebene ist der Umbruch abgeschaltet, und `marginTop` setzt jeden Kopf zwei Zeilen unter die letzte Zeile des vorigen Kapitels. Seine Gestaltung hat kein `span`, deshalb wird der Kopf in der Spalte gezeichnet, in einem Block von drei Zeilen Tiefe, und der Text darunter bleibt im Raster ([Umbruch davor](https://postext.dev/de/docs/configuration-text.md#umbruch-davor), [Span und erweiterte Gestaltung](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)). Die Regeln zum Zusammenhalten bleiben auf ihren Standardwerten. Die vierzeiligen Absätze unter CXIX und CXXI lassen je zwei Zeilen am Fuß ihrer Seite und nehmen zwei mit hinüber; mit `avoidOrphans: false` teilten sie sich drei zu eins, und die Seiten 214 und 215 begännen mit einer einzelnen Zeile ([Waisen, Witwen, zu kurze Ausgangszeilen](https://postext.dev/de/docs/configuration-text.md#waisen-witwen-zu-kurze-ausgangszeilen-und-regeln-zum-zusammenhalten)). Der Spaltenausgleich fügt über CXIX eine dritte Zeile Abstand ein, sodass Seite 213 auf Zeile 33 endet ([Spaltenausgleich](https://postext.dev/de/docs/configuration-text.md#spaltenausgleich)).

![Page 213: CXIX Não faça isso, querida.](https://postext.dev/cookbook/short-chapters-run-on/en/p03.webp?v=686485db)

*Seite 213: Kapitel CXIX beginnt drei Zeilen unter dem Ende von CXVIII und hält zwei Zeilen seines Textes am Fuß.*

### 2 · Eine Seite mit 33 Zeilen und portugiesischer Silbentrennung

```js
// script.js, Zeilen 63–70
const bodyText = { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD),
  color: col('ink'), referenceColor: col('ink'), // a :ref added later prints in ink
  firstLineIndent: mm(5) }; // every paragraph indented, the first after a head too
const page = { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * LEAD * PT), // 16.3 mm
    left: mm(INNER), right: mm(OUTER), mirror: true } };
const LOCALE = 'pt'; // the Portuguese hyphenation patterns
```

Der untere Rand beträgt 16,3 mm, das, was die Höhe von 178 mm nach dem oberen Rand von 15 mm und 33 Zeilen zu 12,6 pt übrig lässt, sodass jede volle Seite auf derselben Zeile endet. Tinos, die die Dickten der Times New Roman hat, setzt auf der Zeilenlänge von 84 mm bei 9,5 pt etwa 59 Zeichen je Zeile. `locale: 'pt'` trennt mit den portugiesischen Mustern: *at-tracção*, *af-fluencia*, *genealogica-mente* ([Unterstützte Locales](https://postext.dev/de/docs/justification.md#unterstützte-sprachen)). Jeder Absatz ist eingezogen, auch der erste nach einem Kopf, wie es portugiesische und brasilianische Ausgaben meist halten, deshalb behält `indentAfterHeading` seinen Standardwert.

### 3 · Das Buch über der linken und das Kapitel über der rechten Seite nennen

```js
// script.js, Zeilen 74–87
const SHIFT = (INNER - OUTER) / 2; // mm: the text block sits off the page's centre
const [HEAD_Y, FOLIO_Y] = [8.5, 167]; // mm below the top edge
const head = (id, content, parity, x) => ({ kind: 'text', id, content, parity,
  pages: 'body', // never on the cover or the plate, which are opener pages
  ...caps(7.5, TRACK), color: col('muted'), align: 'center',
  placement: at('page', 'top', x, HEAD_Y) });
const folio = (id, parity, edge, x) => ({ kind: 'text', id, content: '{pageNumber}', parity,
  pages: 'body', ...caps(7.5, 0), color: col('ink'), placement: at('page', edge, x, FOLIO_Y) });
const header = { elements: [
  head('verso-title', '{title}', 'even', -SHIFT), // the title in the frontmatter
  head('recto-chapter', '{chapterTitle}', 'odd', SHIFT), // last chapter begun on or before it
] };
const footer = { elements: [folio('verso-folio', 'even', 'top-left', OUTER),
  { ...folio('recto-folio', 'odd', 'top-right', -OUTER), align: 'right' }] };
```

`{title}` druckt den Titel aus dem Frontmatter und `{chapterTitle}` das letzte Kapitel, das auf oder vor der Seite beginnt, sodass Seite 217 CXXIII nennt, das auf Seite 216 begann ([Textelemente](https://postext.dev/de/docs/configuration-page-layout.md#textelemente)). Seite 213 nennt CXIX, das an ihrem Fuß beginnt, obwohl ihre ersten 25 Zeilen CXVIII abschließen. `{chapterTitleAtTop}` nennt stattdessen das Kapitel, in dem eine Seite beginnt, aber dieser Auszug enthält keine Überschrift für CXVIII. `SHIFT` rückt die Kolumnentitel auf die Mitte des Satzspiegels, 1 mm neben der Seitenmitte, weil der Bundsteg 2 mm breiter ist als der Außensteg.

### 4 · Umschlag und Bildtafel als Überschriften zeichnen

```js
// script.js, Zeilen 91–134
// span: 'page', in one column too, makes each an opener page: its art runs past the foot of
// the text column, and no running head or folio prints on it. Each design reaches the foot of
// the page, so the heading reserves the whole page and the text starts on the next one.
const onPage = (y, size) => ({ ...at('page', 'top', 0, y), ...(size && { size }) });
const cover = { id: 'cover', numbered: false, span: 'page',
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'box', id: 'top-band', style: { backgroundColor: col('plum') },
      placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(62) } } },
    { kind: 'text', id: 'author', content: '{author}', ...caps(10, 2.4), color: col('paper'),
      align: 'center', placement: at('page', 'top', 0, 44) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(44),
      lineHeight: 1, color: col('plum'), align: 'center', overflow: 'wrap',
      placement: onPage(72, { width: 'fill' }) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.5),
      color: col('saffron'), placement: onPage(111, { width: mm(12) }) },
    { kind: 'box', id: 'foot-band', style: { backgroundColor: col('plum') },
      placement: { ...at('page', 'top-left', 0, 124), size: { width: 'fill', height: mm(54) } } },
    { kind: 'image', id: 'roundel', resourceId: 'roundel',
      placement: onPage(133, { width: mm(20) }) },
    { kind: 'text', id: 'series', content: 'Coleção Casuarina', ...caps(7.5, 1.8),
      color: col('saffron'), align: 'center', placement: at('page', 'top', 0, 159) },
  ] } } };
// The plate faces the first page of text: the morning sea off Flamengo, captioned with the
// line of chapter CXXIII it illustrates, which the heading carries in its quote attribute.
const plate = { id: 'plate', numbered: false, span: 'page',
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'sea', resourceId: 'sea',
      placement: { ...at('page', 'top-left'), size: { width: 'fill', height: 'fill' } } },
    { kind: 'text', id: 'label', content: '{titleText}', ...caps(7.5, 1.6), color: col('saffron'),
      align: 'center', placement: at('page', 'top', 0, 146) },
    { kind: 'text', id: 'quote', content: '{attr.quote}', fontFamily: TEXT, italic: true,
      fontSize: pt(8.6), lineHeight: 1.35, color: col('paper'), align: 'center', overflow: 'wrap',
      placement: onPage(152, { width: mm(78) }) },
  ] } } };
const resources = [
  { id: 'roundel', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
    svg: { fileId: 'roundel.svg', width: 60, height: 60 },
    altText: 'The series mark: a casuarina tree in a ring.' },
  // The plate is a painting, a JPEG in assets/ cut to the trim, declared at its pixels.
  { id: 'sea', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    bitmap: { fileId: 'plate-1100.jpg', format: 'jpeg', width: 1100, height: 1780 },
    altText: 'A heavy morning sea under the Sugarloaf, a low sun, and a lone swimmer among '
      + 'the breakers.' },
];
```

Beide sind [Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile) mit `numbered: false`, sodass das erste nummerierte Kapitel weiterhin CXIX ist. `span: 'page'` macht jedes zu einer Auftaktseite. Ohne das enden das Fußband des Umschlags und das Bild der Tafel am Fuß des Satzspiegels, und Kolumnentitel und Pagina drucken auf beiden Seiten. Die Bildunterschrift der Tafel kommt aus ihrer Überschrift: CAPÍTULO CXXIII ist der Text der Überschrift, das kursive Zitat ihr Attribut `quote` ([Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)). Jede Gestaltung reicht bis zum Fuß ihrer Seite, sodass die Überschrift die ganze Seite reserviert und der Text ohne `:::pagebreak` auf der nächsten beginnt.

### 5 · Dem Auszug sagen, wo er im Buch steht

```js
// script.js, Zeilen 276–276
const continuation = { headings: { h1: 118, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } };
```

`continuation.headings` gibt die Überschriftenzähler an, bei denen der Auszug beginnt. Mit `h1: 118` ist die erste nummerierte Überschrift Kapitel CXIX; ohne das werden die fünf Kapitel I bis V nummeriert. `numberingTemplate: '{1:I}'` in der Kapitelebene setzt diese Nummern in römischen Ziffern, und die Gestaltung druckt sie mit `{number}`. `:::numbering{startAt=212}` nach dem Umschlag nummeriert die Tafel mit 212 und die Textseiten mit 213 bis 217 ([Nummerierung](https://postext.dev/de/docs/document-format.md#numbering)).

## 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/short-chapters-run-on

### script.js

```js
// ═══ Postext Cookbook · Nº 065 · Pocket classic: short chapters that run on ═══════════
// https://postext.dev/en/cookbook/short-chapters-run-on
// Code: MIT · Text: Machado de Assis, Dom Casmurro, 1899 (PD, Gutenberg #55752) · Plate: painted
// Fonts: Tinos (Apache 2.0), Abril Fatface, League Spartan (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

const LANG = 'en'; // @lang: the language of the viewer's title; the sample is Portuguese
const RECIPE = 'short-chapters-run-on';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#24202a', // the text: a violet near-black
  paper: '#f6f1e6', // the pocket book's paper
  plum: '#4f2a49', // the series colour: cover bands, the chapter numerals
  saffron: '#d9a03c', // the second ink: rules and drawings, never text on paper (2.1:1)
  muted: '#6b616e', // the running heads and the colophon (5.2:1 on paper)
};
// Each colour names its palette entry and carries its hex.
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' } })),
  // The default bold and italic colours link to main-color, set here to the ink, not blue.
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
const [TEXT, DISPLAY, LABEL] = ['Tinos', 'Abril Fatface', 'League Spartan'];
const TRIM = { width: 110, height: 178 }; // mm: a pocket book
const [TOP, INNER, OUTER] = [15, 14, 12]; // mm; mirrored, so the inner margin is at the spine
const [BODY, LEAD, LINES] = [9.5, 12.6, 33]; // pt, pt, and the lines of a full page
const PT = 25.4 / 72; // mm per point
const at = (to, edge, x = 0, y = 0) => ({ anchor: { to, edge }, offset: { x: mm(x), y: mm(y) } });
const caps = (size, track) => ({ fontFamily: LABEL, fontWeight: 600, fontSize: pt(size),
  letterSpacing: pt(track), textTransform: 'uppercase' });

// #region answer: chapters that run on, each under a centred head drawn in the column
// Machado's chapters run a page or two, so none opens a page. The level's break is off (the
// H1 default would open each chapter on a recto) and a chapter starts two lines under the last.
const [NUMERAL, TITLE, TRACK, GAP] = [14, 7.5, 1.3, 1.6]; // pt, pt, pt, mm
const RULE_Y = NUMERAL * PT + GAP + TITLE * 1.2 * PT + GAP; // mm: under the title's line
const chapterHead = { enabled: true, slot: { elements: [ // no span: the head stays in the text
  { kind: 'text', id: 'numeral', content: '{number}', fontFamily: DISPLAY, fontSize: pt(NUMERAL),
    lineHeight: 1, color: col('plum'), align: 'center',
    placement: { ...at('container', 'top'), size: { width: 'fill' } } },
  { kind: 'text', id: 'title', content: '{titleText}', ...caps(TITLE, TRACK), color: col('ink'),
    align: 'center', overflow: 'wrap', // a long title takes a second line
    placement: { anchor: { to: '#numeral', edge: 'below' }, offset: { y: mm(GAP) },
      size: { width: 'fill' } } },
  { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1), color: col('saffron'),
    placement: { ...at('container', 'top', 0, RULE_Y), size: { width: mm(8) } } },
] } }; // 11.7 mm deep: the head takes three lines, and the text under it stays on the grid
const chapters = { level: 1, numberingTemplate: '{1:I}', // {number} prints CXIX, CXX…
  fontSize: pt(TITLE), // the hidden heading line, measured in the headings' face
  breakBefore: { enabled: false }, marginTop: pt(2 * LEAD), advancedDesign: chapterHead };
// The keep rules are on by default. avoidWidows keeps widowMinLines (2) lines of a paragraph
// at the foot of a page, and headings.keepWithNext takes the head along when the paragraph
// moves on; avoidOrphans keeps two lines at the head of the next page; avoidRunts weighs a
// last line shorter than about 20 characters as a fault. Column balancing adds lines above
// a head so that the page ends on line 33.
// #endregion

// #region text: a pocket page of 33 lines, in Portuguese
const bodyText = { fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD),
  color: col('ink'), referenceColor: col('ink'), // a :ref added later prints in ink
  firstLineIndent: mm(5) }; // every paragraph indented, the first after a head too
const page = { sizePreset: 'custom', width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150,
  backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - LINES * LEAD * PT), // 16.3 mm
    left: mm(INNER), right: mm(OUTER), mirror: true } };
const LOCALE = 'pt'; // the Portuguese hyphenation patterns
// #endregion

// #region heads: the book's title over the verso, the chapter over the recto, folios at the foot
const SHIFT = (INNER - OUTER) / 2; // mm: the text block sits off the page's centre
const [HEAD_Y, FOLIO_Y] = [8.5, 167]; // mm below the top edge
const head = (id, content, parity, x) => ({ kind: 'text', id, content, parity,
  pages: 'body', // never on the cover or the plate, which are opener pages
  ...caps(7.5, TRACK), color: col('muted'), align: 'center',
  placement: at('page', 'top', x, HEAD_Y) });
const folio = (id, parity, edge, x) => ({ kind: 'text', id, content: '{pageNumber}', parity,
  pages: 'body', ...caps(7.5, 0), color: col('ink'), placement: at('page', edge, x, FOLIO_Y) });
const header = { elements: [
  head('verso-title', '{title}', 'even', -SHIFT), // the title in the frontmatter
  head('recto-chapter', '{chapterTitle}', 'odd', SHIFT), // last chapter begun on or before it
] };
const footer = { elements: [folio('verso-folio', 'even', 'top-left', OUTER),
  { ...folio('recto-folio', 'odd', 'top-right', -OUTER), align: 'right' }] };
// #endregion

// #region cover: the cover and the plate, heading styles that fill a page each
// span: 'page', in one column too, makes each an opener page: its art runs past the foot of
// the text column, and no running head or folio prints on it. Each design reaches the foot of
// the page, so the heading reserves the whole page and the text starts on the next one.
const onPage = (y, size) => ({ ...at('page', 'top', 0, y), ...(size && { size }) });
const cover = { id: 'cover', numbered: false, span: 'page',
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'box', id: 'top-band', style: { backgroundColor: col('plum') },
      placement: { ...at('page', 'top-left'), size: { width: 'fill', height: mm(62) } } },
    { kind: 'text', id: 'author', content: '{author}', ...caps(10, 2.4), color: col('paper'),
      align: 'center', placement: at('page', 'top', 0, 44) },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, fontSize: pt(44),
      lineHeight: 1, color: col('plum'), align: 'center', overflow: 'wrap',
      placement: onPage(72, { width: 'fill' }) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(1.5),
      color: col('saffron'), placement: onPage(111, { width: mm(12) }) },
    { kind: 'box', id: 'foot-band', style: { backgroundColor: col('plum') },
      placement: { ...at('page', 'top-left', 0, 124), size: { width: 'fill', height: mm(54) } } },
    { kind: 'image', id: 'roundel', resourceId: 'roundel',
      placement: onPage(133, { width: mm(20) }) },
    { kind: 'text', id: 'series', content: 'Coleção Casuarina', ...caps(7.5, 1.8),
      color: col('saffron'), align: 'center', placement: at('page', 'top', 0, 159) },
  ] } } };
// The plate faces the first page of text: the morning sea off Flamengo, captioned with the
// line of chapter CXXIII it illustrates, which the heading carries in its quote attribute.
const plate = { id: 'plate', numbered: false, span: 'page',
  advancedDesign: { enabled: true, slot: { elements: [
    { kind: 'image', id: 'sea', resourceId: 'sea',
      placement: { ...at('page', 'top-left'), size: { width: 'fill', height: 'fill' } } },
    { kind: 'text', id: 'label', content: '{titleText}', ...caps(7.5, 1.6), color: col('saffron'),
      align: 'center', placement: at('page', 'top', 0, 146) },
    { kind: 'text', id: 'quote', content: '{attr.quote}', fontFamily: TEXT, italic: true,
      fontSize: pt(8.6), lineHeight: 1.35, color: col('paper'), align: 'center', overflow: 'wrap',
      placement: onPage(152, { width: mm(78) }) },
  ] } } };
const resources = [
  { id: 'roundel', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
    svg: { fileId: 'roundel.svg', width: 60, height: 60 },
    altText: 'The series mark: a casuarina tree in a ring.' },
  // The plate is a painting, a JPEG in assets/ cut to the trim, declared at its pixels.
  { id: 'sea', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0,
    bitmap: { fileId: 'plate-1100.jpg', format: 'jpeg', width: 1100, height: 1780 },
    altText: 'A heavy morning sea under the Sugarloaf, a low sun, and a lone swimmer among '
      + 'the breakers.' },
];
// #endregion

// The colophon floats to the foot of the last page, beside the series roundel.
const colophon = { id: 'colophon', placement: 'bottom', backgroundEnabled: false,
  padding: { top: pt(0), right: pt(0), bottom: pt(0), left: pt(0) }, marginBottom: pt(0),
  icon: { kind: 'resource', resourceId: 'roundel', size: mm(10) }, titleStyle: { gap: mm(3) },
  body: { fontFamily: TEXT, fontSize: pt(7.6), lineHeight: pt(LEAD * 0.8), color: col('muted'),
    italicColor: col('muted'), textAlign: 'left', firstLineIndent: pt(0) } };

const config = () => ({
  locale: LOCALE,
  colorPalette,
  page,
  layout: { layoutType: 'single' },
  bodyText,
  // The heading's own line is hidden under its design but still measured, in this face.
  headings: { fontFamily: LABEL, fontWeight: 600, levels: [chapters] },
  headingStyles: [cover, plate],
  calloutStyles: [colophon],
  header,
  footer,
});

// #region art: the series roundel, drawn in code in the book's two inks
let seed = 1871; // Mulberry32, seeded: never Math.random() in a recipe
const rand = () => {
  let r = Math.imul((seed = (seed + 0x6d2b79f5) | 0) ^ (seed >>> 15), 1 | seed);
  r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
  return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
};
const n = (v) => v.toFixed(2);
const svg = (w, h, body) => `<svg xmlns="http://www.w3.org/2000/svg" width="${w}mm" `
  + `height="${h}mm" viewBox="0 0 ${w} ${h}">${body}</svg>`;
const line = (d, stroke, width, extra = '') => `<path d="${d}" fill="none" stroke="${stroke}" `
  + `stroke-width="${n(width)}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`;

// The casuarina of Bento's garden (chapter II): a leaning trunk, and branches that arch out
// on alternate sides and let their needles hang.
function roundelSvg() {
  const { plum, saffron } = palette;
  const out = [`<circle cx="30" cy="30" r="29" fill="${saffron}"/>`,
    line('M30 30m-25.5 0a25.5 25.5 0 1 0 51 0a25.5 25.5 0 1 0 -51 0', plum, 0.9),
    line('M28.6 50Q30.6 33 30.2 10.5', plum, 1.6), line('M18.5 50.2H41.5', plum, 1.3)];
  for (let k = 0; k < 9; k++) {
    const side = k % 2 ? 1 : -1;
    const y0 = 12.5 + k * 3.7;
    const reach = (3 + k * 1.55) * (0.8 + rand() * 0.35);
    const x1 = 30.2 + side * reach;
    const y1 = y0 + 1.2 + rand() * 1.4;
    out.push(line(`M30.2 ${n(y0)}Q${n(30.2 + side * reach * 0.5)} ${n(y0 - 2.2)} ${n(x1)} ${n(y1)}`,
      plum, 0.95));
    const strands = 3 + Math.round(reach / 1.6);
    for (let j = 1; j <= strands; j++) { // needles hang from the arch
      const u = j / (strands + 0.5);
      const x = 30.2 + side * reach * u;
      const y = y0 + (y1 - y0) * u ** 2 - 2.2 * 2 * u * (1 - u) + 0.3;
      out.push(line(`M${n(x)} ${n(y)}q${n(side * 0.4)} ${n(2)} ${n(side * 0.1)} `
        + `${n(3 + rand() * 2.4)}`, plum, 0.7));
    }
  }
  return svg(60, 60, out.join(''));
}

// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
// Dom Casmurro in Portuguese, in the first edition's spelling: the cover, the plate, then
// pages 213 to 217 (:::numbering in the text), from the end of chapter CXVIII.
const markdown = String.raw`---
title: "Dom Casmurro"
author: "Machado de Assis"
---

# Dom \\ Casmurro {style="cover"}

:::numbering{startAt=212}

# Capítulo CXXIII {style="plate" quote="«…grandes e abertos, como a vaga do mar lá fóra, como se quizesse tragar tambem o nadador da manhã.»"}

O retrato de Escobar, que eu tinha alli, ao pé do de minha mãe, falou-me como se fosse a propria pessoa. Combati sinceramente os impulsos que trazia do Flamengo; rejeitei a figura da mulher do meu amigo, e chamei-me desleal. Demais, quem me affirmava que houvesse alguma intenção daquella especie no gesto da despedida e nos anteriores? Tudo podia ligar-se ao interesse da nossa viagem. Sancha e Capitú eram tão amigas que seria um prazer mais para ellas irem juntas. Quando houvesse alguma intenção sexual, quem me provaria que não era mais que uma sensação fulgurante, destinada a morrer com a noite e o somno? Ha remorsos que não nascem de outro peccado, nem tem maior duração. Agarrei-me a esta hypothese que se conciliava com a mão de Sancha, que eu sentia de memoria dentro da minha mão, quente e demorada, apertada e apertando…

Sinceramente, eu achava-me mal entre um amigo e a attracção. A timidez póde ser que fosse outra causa daquella crise; não é só o ceu que dá as nossas virtudes, a timidez tambem, não contando o acaso, mas o acaso é um méro accidente; a melhor origem dellas é o ceu. Entretanto, como a timidez vem do ceu, que nos dá a compleição, a virtude, filha della é, genealogicamente, o mesmo sangue celestial. Assim reflectiria, se pudesse; mas a principio vaguei á tôa. Paixão não era nem inclinação. Capricho seria ou quê? Ao fim de vinte minutos era nada, inteiramente nada. O retrato de Escobar pareceu falar-me; vi-lhe a altitude franca e simples, sacudi a cabeça e fui deitar-me.

# Não faça isso, querida

A leitora, que é minha amiga e abriu este livro com o fim de descançar da cavatina de hontem para a valsa de hoje, quer fechal-o ás pressas, ao ver que beiramos um abysmo. Não faça isso, querida; eu mudo de rumo.

# Os autos

Na manhã seguinte accordei livre das abominações da vespera; chamei-lhes allucinações, tomei café, percorri os jornaes e fui estudar uns autos. Capitú e prima Justina sairam para a missa das nove, na Lapa. A figura de Sancha desappareceu inteiramente no meio das allegações da parte adversa, que eu ia lendo nos autos, allegações falsas, inadmissiveis, sem apoio na lei nem nas praxes. Vi que era facil ganhar a demanda; consultei Dalloz, Pereira e Souza…

Uma só vez olhei para o retrato de Escobar. Era uma bella photographia tirada um anno antes. Estava de pé, sobrecasaca abotoada, a mão esquerda no dorso de uma cadeira, a direita mettida ao peito, o olhar ao longe para a esquerda do espectador. Tinha garbo e naturalidade. A moldura que lhe mandei pôr não encobria a dedicatoria, escripta embaixo, não nas costas do cartão: «Ao meu querido Bentinho o seu querido Escobar 20-4-70.» Estas palavras fortaleceram-me os pensamentos daquella manhã, e espancaram de todo as recordações da vespera. Naquelle tempo a minha vista era boa; eu podia lel-as do logar em que estava. Tornei aos autos.

# A catastrophe

No melhor delles, ouvi passos precipitados na escada, a campainha soou, soaram palmas, golpes na cancella, vozes, acudiram todos, acudi eu mesmo. Era um escravo da casa de Sancha que me chamava:

—Para ir lá… sinhô nadando, sinhô morrendo.

Não disse mais nada, ou eu não lhe ouvi o resto. Vesti-me, deixei recado a Capitú e corri ao Flamengo.

Em caminho, fui adivinhando a verdade. Escobar metteu-se a nadar, como usava fazer, arriscou-se um pouco mais fóra que de costume, apesar do mar bravio, foi enrolado e morreu. As canoas que acudiram mal puderam trazer-lhe o cadaver.

# O enterro

A viuva… Poupo-vos as lagrimas da viuva, as minhas, as da outra gente. Sai de lá cerca de onze horas; Capitú e prima Justina esperavam-me, uma com o parecer abatido e estupido, outra enfastiada apenas.

—Vão fazer companhia a pobre Sanchinha; eu vou cuidar do enterro.

Assim fizemos. Quiz que o enterro fosse pomposo, e a affluencia dos amigos foi numerosa. Praia, ruas, praça da Gloria, tudo eram carros, muitos delles particulares. A casa, não sendo grande, não podiam lá caber todos; muitos estavam na praia, falando do desastre, apontando o logar em que Escobar fallecèra, ouvindo referir a chegada do morto. José Dias ouviu tambem falar dos negocios do finado, divergindo alguns na avaliação dos bens, mas havendo accordo em que o passivo devia ser pequeno. Elogiavam as qualidades de Escobar. Um ou outro discutia o recente gabinete Rio Branco; estavamos em Março de 1871. Nunca me esqueceu o mez nem o anno.

Como eu houvesse resolvido falar no cemiterio, escrevi algumas linhas e mostrei-as em casa a José Dias, que as achou realmente dignas do morto e de mim. Pediu-me o papel, recitou lentamente o discurso, pesando as palavras, e confirmou a primeira opinião; no Flamengo espalhou a noticia. Alguns conhecidos vieram interrogar-me:

—Então, vamos ouvil-o?

—Quatro palavras.

Poucas mais seriam. Tinha-as escripto com receio de que a emoção me impedisse de improvisar. No tilbury em que andei uma ou duas horas, não fizera mais que recordar o tempo do seminario, as relações de Escobar, as nossas sympathias, a nossa amizade, começada, continuada e nunca interrompida, até que um lance da fortuna fez separar para sempre duas creaturas que promettiam ficar por muito tempo unidas. De quando em quando enxugava os olhos. O cocheiro aventurou duas ou tres perguntas sobre a minha situação moral; não me arrancando nada, continuou o seu officio. Chegando a casa, deitei aquellas emoções ao papel; tal seria o discurso.

# Olhos de ressaca

Emfim, chegou a hora da encommendação e da partida. Sancha quiz despedir-se do marido, e o desespero daquelle lance consternou a todos. Muitos homens choravam tambem, as mulheres todas. Só Capitú, amparando a viuva, parecia vencer-se a si mesma. Consolava a outra, queria arrancal-a dalli. A confusão era geral. No meio della, Capitú olhou alguns instantes para o cadaver tão fixa, tão apaixonadamente fixa, que não admira lhe saltassem algumas lagrimas poucas e caladas…

As minhas cessaram logo. Fiquei a ver as della; Capitú enxugou-as depressa, olhando a furto para a gente que estava na sala. Redobrou de caricias para a amiga, e quiz leval-a; mas o cadaver parece que a retinha tambem. Momento houve em que os olhos de Capitú fitaram o defuncto, quaes os da viuva, sem o pranto nem palavras desta, mas grandes e abertos, como a vaga do mar lá fóra, como se quizesse tragar tambem o nadador da manhã.

:::callout{type="colophon"}
Do fim do capítulo CXVIII ao fim do CXXIII de *Dom Casmurro* (1899), com a ortografia da edição Garnier transcrita pelo Project Gutenberg (n.º 55752); corrigiu-se um erro de transcrição no capítulo CXXIII. Ilustração pintada e marca da coleção feitas para esta edição. Composto em Tinos (Apache 2.0), Abril Fatface e League Spartan (SIL OFL).
:::
`;

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages use, loaded before the build
  Tinos: ['400', '400i'], // text, colophon; italic: the plate's quote, the colophon's title
  'Abril Fatface': ['400'], // chapter numerals and the cover title
  'League Spartan': ['600'], // chapter titles, running heads, folios, the cover's capitals
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadSvg('roundel.svg', roundelSvg());
await loadImage('plate-1100.jpg', asset('plate-1100.jpg'));
// #region excerpt: these pages continue a book: chapter CXVIII is under way, the next is CXIX
const continuation = { headings: { h1: 118, h2: 0, h3: 0, h4: 0, h5: 0, h6: 0 } };
// #endregion
const doc = await buildDocumentWithFonts({ markdown, resources, continuation }, config(),
  kitFonts(FONTS));
showPages(doc, { title: t({ en: 'Dom Casmurro, a pocket edition',
  es: 'Dom Casmurro, edición de bolsillo' }) });

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

### Jedes Kapitel auf einer neuen Seite beginnen

Mit einem Umbruch vor jedem Kapitel werden aus den fünf Textseiten sieben: Seite 214 enthält nur CXIX, seinen Kopf und vier Zeilen, und Seite 213, auf der kein Kapitel beginnt, übernimmt den Titel der Tafel, CAPÍTULO CXXIII, als Kolumnentitel.

```diff
-  breakBefore: { enabled: false }, marginTop: pt(2 * LEAD), advancedDesign: chapterHead };
+  breakBefore: { enabled: true, parity: 'any' }, marginTop: pt(2 * LEAD),
+  advancedDesign: chapterHead };
```

### Die Kapitelnummer über die rechte Seite setzen

`{chapterNumber}` druckt die Ziffer desselben Kapitels, aus dem der Titel stammt: *CXIX · NÃO FAÇA ISSO, QUERIDA*.

```diff
-  head('recto-chapter', '{chapterTitle}', 'odd', SHIFT),
+  head('recto-chapter', '{chapterNumber} · {chapterTitle}', 'odd', SHIFT),
```

## Häufige Fehler

- **avoidWidows schützt den Fuß einer Spalte, avoidOrphans ihren Kopf.** Postext benennt die beiden einzelnen Zeilen auf eigene Weise: avoidWidows (widowMinLines, widowPenalty) verhindert, dass die erste Zeile eines Absatzes allein am Fuß einer Spalte steht (im Deutschen eine Waise, der Schusterjunge), und avoidOrphans (orphanMinLines, orphanPenalty), dass seine letzte Zeile allein am Kopf der nächsten steht (eine Witwe, das Hurenkind). Viele Satzhandbücher vergeben die beiden Namen umgekehrt, wählen Sie die Einstellung also danach, wo sie wirkt. Beide sind standardmäßig aktiv, außer auf einem Zeichenraster (cjk.grid): dort ist avoidWidows aus, in der Region Japan auch avoidOrphans, damit eine Seite die Zeilen aufnimmt, die ihr Raster zählt (seit postext 1.28). Sie wirken als Strafwerte: das Layout wägt jeden gegen die Leerzeilen ab, die seine Befolgung hinterließe.
- **:::numbering wirkt ab der nächsten Seite.** :::numbering ändert die Zählung ab der nächsten beginnenden Seite, nicht ab der aktuellen. Setzen Sie es direkt nach ein :::pagebreak (Parität odd vor dem ersten Kapitel), damit die neue Zählung dort beginnt, wo Sie es wollen.

- Wird `widowMinLines` auf 3 erhöht, findet der vierzeilige Absatz unter CXIX keine Stelle mehr zum Teilen: Drei Zeilen und eine ließen Seite 214 mit einer einzelnen Zeile beginnen. Der Kopf wandert mit seinem Text weiter, und Seite 213 endet sechs Zeilen zu früh.
- Wächst CXVIII um ein paar Zeilen, hat CXIX unter seinem Kopf nur noch Platz für eine Zeile. `keepWithNext` trägt den Kopf dann auf Seite 214 hinüber, und Seite 213 endet vier oder fünf Zeilen zu früh; mit `keepWithNext: false` schließt der Kopf Seite 213 ohne Text darunter.
- Die Tafel ist eine H1, deshalb greift `{chapterTitle}` auf sie zurück: Eine Textseite, auf der seit der Tafel kein Kapitel begonnen hat, wie Seite 213, sobald CXIX weiterwandert, druckt CAPÍTULO CXXIII als Kolumnentitel. Geben Sie dem Format der Tafel `runningChapter: false`, und die Kolumnentitel übergehen sie.
- Schalten Sie `avoidRunts` ab, und die letzte Zeile des Buches enthält *manhã.* allein statt *nadador da manhã.*
- Diese Seitenumbrüche gelten nur für diesen Text in 9,5 auf 12,6 pt bei einer Zeilenlänge von 84 mm. Wenn Sie Text, Größe oder Zeilenlänge ändern, prüfen Sie erneut den Fuß jeder Seite und den Abstand über jedem Kopf.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: Dom Casmurro (1899), from the end of chapter CXVIII to the end of chapter CXXIII, in the spelling of the Garnier edition, with one transcription slip corrected (homens, chapter CXXIII): Machado de Assis ([Quelle](https://www.gutenberg.org/ebooks/55752)), gemeinfrei
- Text: The colophon, and the series name Coleção Casuarina (invented for this recipe): Postext Cookbook, Original
- Bilder: The plate of the sea off Flamengo under the Sugarloaf, a painting: Generated With Diffusion Models, Original
- Bilder: The casuarina roundel, drawn in code in the book’s palette: Ignacio Ferro, MIT
- Schrift: Tinos (Apache-2.0), Abril Fatface (OFL-1.1), League Spartan (OFL-1.1)
- Code: MIT · Beispielinhalt: MIT

## 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º 017 · Fünf Kapitelauftakte in einem Buch](https://postext.dev/de/cookbook/five-chapter-openers.md): Ein Auftakt auf Ebene 1 und vier im Markdown benannte Überschriftenstile; jeder tauscht die Akzentfarbe, manche ändern auch Ränder, Spalten oder Seitenzahlen. · Stufe 3 (Fortgeschritten) · Jedes Genre
- [Nº 014 · Gleichmäßige Wortabstände in schmalen Blocksatzspalten](https://postext.dev/de/cookbook/justification-lab.md): Knuth-Plass mit Wortabständen vom 0,8- bis 1,6-Fachen neben einer gierigen Kontrolle, deren lockere, allein stehende und kurze Zeilen aus dem VDT markiert sind. · Stufe 3 (Fortgeschritten) · Magazine und Zines
