# Spanischer Blocksatz in einem Taschenbuchroman

> Der Anfang von Kapitel I der Marianela als Taschenausgabe: spanische Trennung, Wortabstände unter 1,7×, keine zu kurzen Ausgangszeilen, Initiale, Figura 1.

- HTML-Fassung: https://postext.dev/de/cookbook/spanish-pocket-novel
- Rezept Nº 016 · Schrift und Text · 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: [8](https://postext.dev/cookbook/spanish-pocket-novel/es/p01.webp?v=38e05bc2), [9](https://postext.dev/cookbook/spanish-pocket-novel/es/p02.webp?v=38e05bc2), [10](https://postext.dev/cookbook/spanish-pocket-novel/es/p03.webp?v=38e05bc2), [11](https://postext.dev/cookbook/spanish-pocket-novel/es/p04.webp?v=38e05bc2), [12](https://postext.dev/cookbook/spanish-pocket-novel/es/p05.webp?v=38e05bc2)
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=spanish-pocket-novel&lang=es (.postext: https://postext.dev/cookbook/spanish-pocket-novel/es/spanish-pocket-novel.postext)
- Zuletzt aktualisiert: 2026-10-10
- Andere Sprachen: [en](https://postext.dev/en/cookbook/spanish-pocket-novel.md), [es](https://postext.dev/es/cookbook/spanish-pocket-novel.md), [ca](https://postext.dev/ca/cookbook/spanish-pocket-novel.md), [fr](https://postext.dev/fr/cookbook/spanish-pocket-novel.md), [it](https://postext.dev/it/cookbook/spanish-pocket-novel.md), [pt](https://postext.dev/pt/cookbook/spanish-pocket-novel.md), [zh](https://postext.dev/zh/cookbook/spanish-pocket-novel.md), [ja](https://postext.dev/ja/cookbook/spanish-pocket-novel.md), [ar](https://postext.dev/ar/cookbook/spanish-pocket-novel.md)

## Kurz gesagt

Die ersten Seiten eines spanischen Romans von 1878 als Taschenbuch. Sie zeigen, wie man die Abstände zwischen den Wörtern gleichmäßig hält und Wörter an den richtigen spanischen Silben trennt.

## Was Sie setzen

Der Anfang von Galdós’ *Marianela* (1878) in einer Taschenausgabe von 11,5 × 18 cm, mit einer mutmaßlichen Karte der Minen von Socartes gegenüber Kapitel I. Über dem kursiven Kapiteltitel in Libre Bodoni steht *capítulo primero* in Kapitälchen ausgeschrieben; der Text in Gentium Book Plus beginnt nach einem Viertel der Seite mit einer ochsenblutroten hochgestellten Initiale. Wörter werden an spanischen Silben getrennt (*hie-rro*, *esta-blecimiento*), und Wortzwischenräume bleiben unter dem 1,7-Fachen ihrer normalen Breite; kein Absatz endet mit dem Rest eines getrennten Worts. Dialoge stehen mit *rayas*, den Geviertstrichen, die auch die Einschübe des Erzählers (*incisos*) öffnen und schließen. Die Losung des Reisenden steht in «comillas latinas», und in englischen doppelten Anführungszeichen, wo die Notiz der Ausgabe sie innerhalb eines Zitats zitiert. Die Karte ist *Figura 1*, im Text als [fig. 1] zitiert. Kolumnentitel in Marcellus SC nennen auf den linken Seiten den Autor und auf den rechten den Titel.

**Dieses Rezept beantwortet:**

- Wie setze ich Spanisch im Blocksatz mit Trennung, Dialogstrichen, Guillemets und „Figura“ statt „Figure“?
- Wie vermeide ich Witwen, Waisen und Ausgangszeilen mit nur einem Wort, und wie halte ich eine Überschrift bei ihrem Text?
- Wie schreibe ich Dialogstriche, Jahreszahlen am Absatzanfang, Preise und wörtliche Symbole, ohne dass Markdown sie falsch liest?
- Wie erhalte ich die Beschriftungen „Abbildung“ und „Tabelle“ in der Sprache meines Dokuments?
- Wie füge ich einem Auftakt eine Autorenzeile, einen Vorspann oder einen Einstieg mit Initiale hinzu?

## Die kurze Antwort

```js
// script.js, Zeilen 47–62
const LOCALE = 'es'; // config().locale: Spanish hyphenation, and Spanish caption labels
const bodyText = { // config().bodyText
  fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  referenceBold: false, // '[fig. 1]' reads in roman, like the words around it
  firstLineIndent: mm(4), indentAfterHeading: false, // a paragraph after a heading starts flush
  // The defaults justify, hyphenate, break by Knuth–Plass and keep widows and orphans out.
  // Long Spanish words on an 86 mm measure need two more settings. Spaces under 1.7×: at
  // the default 2×, eight lines here open past 1.6×; at 1.7 it hyphenates hie-rro, ca-lles.
  // A last line under 26 space widths is a runt; at 20, 'siem- / pre adelante.' got through.
  maxWordSpacing: 1.7, runtMinCharacters: 26,
};
// config().resourceTypes: the built-in types, 'Figura' and 'Fig.' in Spanish, renumbered
// through the book (Figura 1) where the default counts by chapter (Figura 1.1).
const resourceTypes = defaultResourceTypes(LOCALE)
  .map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' }));
```

## Zutaten

**Zeigt**

- [Silbentrennung und Dokumentsprache](https://postext.dev/de/docs/justification.md#unterstützte-sprachen): Silbentrennung nach TeX-Mustern in acht Sprachen, gewählt über das Dokument-Locale, das auch die Fortsetzungshinweise von Tabellen und die Sprache des getaggten PDF festlegt.
- [Optimaler Zeilenumbruch (Knuth-Plass)](https://postext.dev/de/docs/justification.md#knuth-plass-der-blick-auf-den-ganzen-absatz): Bricht jeden Absatz als Ganzes um, für gleichmäßige Wortabstände innerhalb der Grenzen für den Wortabstand, statt Zeile für Zeile aufzufüllen.
- [Initialen in Textabsätzen](https://postext.dev/de/docs/configuration-text.md#initialen): dropCap an einer Überschriftenebene, einem Überschriftenformat oder einem Absatzformat eröffnet einen Textabsatz mit seinem ersten Buchstaben groß neben den ersten Zeilen, die verkürzt und mit dem Text gesetzt werden; eine hochgestellte Initiale, ein Anführungszeichen am Anfang, ausgehängt oder mit dem Buchstaben gesetzt, ein Auftakt in Kapitälchen.

**Verwendet außerdem**

- [Gestaltete Auftakte](https://postext.dev/de/docs/configuration-text.md#seitenbreite-und-erweiterte-gestaltung)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Text, Linien und Kästen in Seitengestaltungen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Witwen, Waisen und kurze Ausgangszeilen](https://postext.dev/de/docs/configuration-text.md#waisen-witwen-zu-kurze-ausgangszeilen-und-regeln-zum-zusammenhalten)
- [Gespiegelte Ränder](https://postext.dev/de/docs/configuration-page-layout.md#gespiegelte-ränder)
- [Kapitel, die rechts beginnen](https://postext.dev/de/docs/configuration-text.md#umbruch-davor)
- [Kopfzeilen nach Seitenrolle](https://postext.dev/de/docs/configuration-page-layout.md#textelemente)
- [Kolumnentitel und Seitenzahlen](https://postext.dev/de/docs/configuration-page-layout.md#kopf--und-fußzeilen)
- [Verweise, die Abbildungen platzieren](https://postext.dev/de/docs/document-format.md#verweis-im-text-die-hauptform)
- [Nummerierte Bildunterschriften](https://postext.dev/de/docs/document-format.md#nummerierung-beim-ersten-verweis)
- [Abbildung und Tabelle in Ihrer Sprache](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)
- [Abbildungen genau hier](https://postext.dev/de/docs/document-format.md#blockeinbettung-optional-ausdrückliche-platzierung-im-text)
- [Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)
- [Expliziter vertikaler Abstand](https://postext.dev/de/docs/document-format.md#space)
- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)
- [Maskierung und wörtliche Zeichen](https://postext.dev/de/docs/document-format.md#konventionen-beim-schreiben)
- [Papierfarbe](https://postext.dev/de/docs/configuration-page-layout.md#seite)
- [Eigene Ressourcentypen](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen)
- [Abbildungen und Tabellen als Ressourcen](https://postext.dev/de/docs/document-format.md#ressourcen)
- [Kolumnentitel je Abschnitt](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)

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

**APIs**

- [`buildDocumentWithFonts`](https://postext.dev/de/docs/configuration-programmatic-usage.md#schriften-vor-dem-layout-laden), [`defaultResourceTypes`](https://postext.dev/de/docs/configuration-resources.md#ressourcentypen), [`inlineSvgFonts`](https://postext.dev/de/docs/configuration-resources.md#schriften-in-svg-text), [`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**

- Gentium Book Plus (OFL-1.1), Libre Bodoni (OFL-1.1), Marcellus SC (OFL-1.1)

## Zubereitung

### 1 · Spanische Silben, gezügelte Wortabstände

Der Code steht oben in [der kurzen Antwort](https://postext.dev/de/cookbook/spanish-pocket-novel.md#die-kurze-antwort). `locale: 'es'` wählt die spanischen Trennmuster, ebenso wie ein regionales Tag wie `'es-ES'` oder `'es-MX'`; die amerikanischen Regeln der voreingestellten Locale würden *cabal-los*, *hi-erro* und *establec-imiento* trennen ([unterstützte Locales](https://postext.dev/de/docs/justification.md#unterstützte-sprachen)). Blocksatz, Knuth-Plass und die Regeln für Witwen und Waisen sind standardmäßig aktiv. Die Satzbreite von 86 mm braucht außerdem gezügelte Wortabstände: Bei der Voreinstellung `maxWordSpacing: 2` öffnen sich acht Zeilen über das 1,6-Fache, und die breiteste erreicht das 1,89-Fache; bei 1.7 liegt die breiteste beim 1,65-Fachen, weil der Umbruchalgorithmus stattdessen *hie-rro* und *ca-lles* trennt ([Grenzen der Wortabstände](https://postext.dev/de/docs/justification.md#grenzen-des-wortabstands)). Jenseits der Grenze kostet eine Zeile weit mehr, bleibt aber zulässig; auf einer schmaleren Satzbreite ohne besseren Umbruch kann sie also trotzdem darüber hinausgehen. `runtMinCharacters: 26` fängt die getrennten Reste ab, die die Voreinstellung von 20 Leerzeichenbreiten durchlässt: Aus *siem- / pre adelante.* wird *Adelante, / siempre adelante.*, und aus *So- / cartes [fig. 1].* wird *de / Socartes [fig. 1].* ([zu kurze Ausgangszeilen](https://postext.dev/de/docs/configuration-text.md#waisen-witwen-zu-kurze-ausgangszeilen-und-regeln-zum-zusammenhalten)).

### 2 · Eine Taschenbuchseite aus ganzen Zeilen

```js
// script.js, Zeilen 35–43
const TRIM = { width: 115, height: 180 }; // mm: 11.5 × 18 cm
const [TOP, INNER, OUTER] = [16, 15, 14]; // mm: a pocket page keeps its margins tight
const LINES = 30; // lines of LEAD per page, so every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 86 mm: about 57 characters of Gentium at 10 pt
const page = {
  width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72),
    left: mm(INNER), right: mm(OUTER), mirror: true }, // left is inner on a recto
};
```

Der Fußsteg wird aus Endformat, Kopfsteg und `LINES` berechnet, sodass der Satzspiegel genau 30 Zeilen zu 13,8 pt fasst und jede volle Seite auf derselben Grundlinie endet, wie [die Seiten 10 und 11](https://postext.dev/cookbook/spanish-pocket-novel/es/p03.webp?v=38e05bc2). Mit `mirror: true` bleibt der Bundsteg von 15 mm am Bund und der Außensteg von 14 mm am Vorderschnitt, auf beiden Seiten einer Doppelseite ([gespiegelte Ränder](https://postext.dev/de/docs/configuration-page-layout.md#gespiegelte-ränder)), und die Satzbreite von 86 mm fasst etwa 57 Zeichen Gentium Book Plus zu 10 pt.

### 3 · Das Kapitel in Worten und eine hochgestellte Initiale

```js
// script.js, Zeilen 66–95
const [LABEL_Y, RULE_Y, TITLE_Y] = [4, 10.5, 13.5]; // mm below the top of the text block
const SINK = 8; // lines of LEAD above the text: the chapter drops a quarter of the page
const INITIAL = 3 * LEAD; // pt: the initial's size, three leads
const RISE = 2; // lines of LEAD the raised initial keeps clear above the first line
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
const opener = {
  enabled: true,
  slot: { elements: [
    // The ordinal is written in the heading, # Perdido {ordinal="primero"}: the plate before
    // the chapter is a level-1 heading too, so the level carries no counter.
    { kind: 'text', id: 'chapter', content: 'capítulo {attr.ordinal}', ...smallCaps,
      placement: centred(LABEL_Y) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('oxblood'), placement: { ...centred(RULE_Y), size: { width: mm(9) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, italic: true,
      fontSize: pt(26), lineHeight: 1.1, color: col('ink'), align: 'center',
      overflow: 'wrap', // a longer title takes a second line
      placement: centred(TITLE_Y) },
  ] },
  // The text starts SINK lines down; the initial's rise takes the last RISE of them.
  minHeight: pt((SINK - RISE) * LEAD),
};
// parity 'any': each chapter opens on the next page, as pocket books do.
// marginBottom replaces the level's default, a blank line of its own. The first paragraph
// of the chapter opens with a raised initial: lines: 1 stands it on the first baseline,
// three leads tall, and the paragraph keeps its rise clear above it.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
  marginBottom: pt(0), advancedDesign: opener,
  dropCap: { lines: 1, fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(INITIAL),
    color: col('oxblood') } };
```

*capítulo primero* stammt aus dem eigenen Attribut `ordinal` der Überschrift ([Attribute von Überschriften](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)). Eine mit `'{1:ordinal}'` nummerierte Ebene würde das Wort von selbst ausschreiben, doch die Tafel vor dem Kapitel ist ebenfalls eine Überschrift der Ebene 1; diese Ebene trägt daher keinen Zähler. Die Initiale gehört zur Kapitelebene: Ihr `dropCap` eröffnet den ersten Absatz nach jeder Überschrift der Ebene 1, und der Absatz steht im Markdown so, wie er gelesen wird ([Initialen](https://postext.dev/de/docs/configuration-text.md#initialen)). `lines: 1` mit einer `fontSize` von drei Zeilenabständen macht daraus eine hochgestellte Initiale, die auf der ersten Grundlinie steht; die erste Zeile wird um ihre Breite und den voreingestellten Abstand gekürzt und mit dem Rest des Absatzes im Blocksatz gesetzt. Der Buchstabe ragt über die erste Zeile hinaus, und der Absatz hält diesen Überstand in ganzen Zeilen darüber frei: hier zwei (`RISE`), sodass `minHeight` den Auftakt bei `SINK - RISE` Zeilen enden lässt und die erste Textzeile dennoch auf Zeile `SINK` fällt, nach einem Viertel der Seite. `marginBottom: 0` der Kapitelebene ersetzt den voreingestellten Abstand, der eine Leerzeile hinzufügen würde. Ein Absatz mit Initiale verzichtet auf seinen Erstzeileneinzug.

![Página de apertura 9: Perdido.](https://postext.dev/cookbook/spanish-pocket-novel/es/p02.webp?v=38e05bc2)

*Seite 9: Label und Titel des Kapitels und die hochgestellte Initiale neben der ersten Zeile des Texts im Blocksatz.*

### 4 · Die Karte steht dem Kapitel als Figura 1 gegenüber

```js
// script.js, Zeilen 120–141
const MAP_H = 112; // mm: a frontispiece map, the measure wide
const resources = [{
  id: 'plano', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'plano.svg', width: MEASURE, height: MAP_H }, // the ratio: set column-wide
  placement: { position: 'here' }, // where ::resource{id="plano"} stands, not a float
  caption: 'El camino de Golfín: de Villafangosa, por la pasadera y el cerro, al talud de '
    + 'las minas de Socartes.',
  note: 'Plano conjetural dibujado para esta edición a partir del capítulo primero.',
  altText: 'Plano: abajo, la villa, el río y la pasadera; en medio, un cerro arbolado; arriba, '
    + 'las minas en gradas. Un punteado lleva de la villa al talud de las minas.',
}];
// The plate's heading, # Las minas de Socartes {style="lamina"}, opens a page the heads skip.
const frontispiece = {
  id: 'lamina', // its own break, or it takes the chapter's (gotcha: style-inherits-break)
  breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0),
  footer: { elements: [] }, // no drop folio
  advancedDesign: { enabled: true, slot: { elements: [{ kind: 'text', id: 'name',
    content: '{titleText}', ...smallCaps, // a line down: the plate centres on the page
    placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: pt(LEAD) } } }] } },
};
const captionStyle = { fontFamily: TEXT, fontSize: pt(8.3), gap: mm(2),
  labelColor: col('oxblood'), descriptionItalic: true, note: { color: col('muted') } };
```

`position: 'here'` setzt die Karte dorthin, wo `::resource{id="plano"}` steht, unter eine eigene Überschrift. Diese Überschrift macht Buchseite 8 zu einer Auftaktseite, und die Kolumnentitel überspringen Auftaktseiten. Ihr Format leert die Fußzeile und setzt einen eigenen Umbruch, was die Tafel gegenüber Kapitel I hält, ohne Kolumnentitel oder Pagina, selbst wenn Kapitel auf rechten Seiten beginnen ([Überschriftenformate](https://postext.dev/de/docs/configuration-styles.md#überschriftenstile)). Die spanischen Typen der kurzen Antwort drucken *Figura 1.* unter der Karte (ohne `resourceTypes` lautet sie *Figura 1.1*, nach Kapiteln nummeriert) und *Fig. 1* im Text, was `case="lower"` im `:ref` des Markdowns zu *fig. 1* macht. Der Verweis steht in eckigen Klammern, mit denen spanische Ausgaben kennzeichnen, was der Herausgeber dem Text des Autors hinzufügt.

### 5 · Der Autor auf der linken, der Titel auf der rechten Seite

```js
// script.js, Zeilen 99–116
const [HEAD_Y, DROP_Y] = [9, -10]; // mm: heads from the top edge, drop folio from the foot
const head = (id, content, parity, edge, x, y = HEAD_Y, pages = 'body') => ({
  kind: 'text', id, content, parity, pages, // running heads skip the openers
  fontFamily: LABEL, fontSize: pt(8), letterSpacing: pt(1.2), color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } },
});
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
// Folios in the text face (Marcellus SC's 1 and 0 read as I and O), on the heads' baseline.
const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
  { ...head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER), ...folio },
  head('verso-author', '{author}', 'even', 'top', -SHIFT),
  head('recto-title', '{title}', 'odd', 'top', SHIFT),
  { ...head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER), ...folio },
] };
const drop = (parity, x) => ({ ...head(`drop-${parity}`, '{pageNumber}', parity, 'bottom', x,
  DROP_Y, 'opener'), ...folio }); // a chapter can open on either side of the spread
const footer = { elements: [drop('odd', SHIFT), drop('even', -SHIFT)] };
```

`parity: 'even'` setzt den Autor auf die linken Seiten und `'odd'` den Titel auf die rechten, und `pages: 'body'` hält beide von der Auftaktseite fern, auf der nur die mit `pages: 'opener'` gesetzte Pagina am Fuß steht. Jeder Kolumnentitel ist an der Seite verankert und um `SHIFT` auf die Mitte des Satzspiegels verschoben, die einen halben Millimeter neben der Seitenmitte liegt, weil Bund- und Außensteg verschieden sind. Aus demselben Grund gibt es die Pagina am Fuß in zwei Paritäten. Die Paginas stehen in Gentium, weil Marcellus SC 1 und 0 wie I und O zeichnet.

### 6 · Spanische Zeichensetzung im Markdown

```js
// script.js, Zeilen 145–153
const paragraphStyles = [
  { id: 'asterismo', fontFamily: DISPLAY, fontSize: pt(11), color: col('oxblood'),
    textAlign: 'center' },
  // In ink: a muted note would need italicColor too, for its italic Marianela.
  { id: 'nota', fontSize: pt(8.3), lineHeight: pt(LEAD * 0.8), firstLineIndent: pt(0),
    marginTop: pt(LEAD) },
  { id: 'colofon', fontSize: pt(7.5), color: col('muted'), textAlign: 'center',
    firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
];
```

[Seite 12](https://postext.dev/cookbook/spanish-pocket-novel/es/p05.webp?v=38e05bc2), die letzte, endet mit drei Absatzformaten. Die Sternchen in ochsenblutroter Bodoni stehen zentriert nach einem [`:::space{lines=2}`](https://postext.dev/de/docs/document-format.md#space), da Leerzeilen keinen Abstand hinzufügen. Die Notiz der Ausgabe steht in 8,3 pt auf 11 pt Zeilenabstand, und das Impressum in 7,5 pt im Grau der Kolumnentitel. Die Notiz bleibt in der Textfarbe; setzt man sie grau, braucht das Format auch eine `italicColor`, sonst druckt das kursive *Marianela* dunkler als die Wörter um es herum. Im Markdown beginnt ein Dialog mit einer Raya (—), nie mit einem Bindestrich, weil ein Bindestrich mit Leerzeichen am Zeilenanfang eine Liste eröffnet. *1878.* am Anfang der Notiz würde eine nummerierte Liste eröffnen; deshalb steht vor der Jahreszahl ein Wortverbinder (U+2060). Die Sternchen sind maskiert (`\*`); unmaskiert würde das erste eine Aufzählung eröffnen und die beiden anderen eine Kursivpassage, und die Zeile verschwände.

## 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/spanish-pocket-novel

### script.js

```js
// ═══ Postext Cookbook · Nº 016 · Justified Spanish in a pocket novel ═════════════════
// https://postext.dev/en/cookbook/spanish-pocket-novel
// Code: MIT · Text: B. Pérez Galdós, Marianela, 1878 (PD, Gutenberg #17340) · Map: drawn in code
// Fonts: Gentium Book Plus, Libre Bodoni, Marcellus SC (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import {
  buildDocumentWithFonts, renderPageToCanvas, defaultResourceTypes, registerResourceImage,
  inlineSvgFonts,
} from 'https://esm.sh/postext';

const LANG = 'es'; // @lang: the language of the sample document (this recipe is Spanish only)
const RECIPE = 'spanish-pocket-novel';

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = {
  ink: '#1e1b18', // text: a warm near-black
  oxblood: '#8b2e2a', // the one accent: chapter label, rule, initial, asterisks, route
  muted: '#6d645a', // running heads, the map's paths, the caption's credit
  paper: '#f7f2e8', // an ivory book paper
  slag: '#c8553d', moss: '#6f7a4f', water: '#58707f', // the map: mined earth, woods, river
};
// col(id): a colour linked to its palette entry, in text styles and design slots alike.
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' } })),
  { id: 'main-color', name: 'oxblood (defaults)', value: { hex: palette.oxblood, model: 'hex' } },
];
const TEXT = 'Gentium Book Plus'; // the text face
const DISPLAY = 'Libre Bodoni'; // the chapter's title and the initial
const LABEL = 'Marcellus SC'; // small capitals: chapter label, running heads, map names
const [BODY, LEAD] = [10, 13.8]; // pt: the body size, and its leading: the grid's pitch
const smallCaps = { fontFamily: LABEL, fontSize: pt(9), letterSpacing: pt(1.6), // 2 labels
  color: col('oxblood'), align: 'center' };

// #region page: a pocket paperback; the text block holds 30 whole lines
const TRIM = { width: 115, height: 180 }; // mm: 11.5 × 18 cm
const [TOP, INNER, OUTER] = [16, 15, 14]; // mm: a pocket page keeps its margins tight
const LINES = 30; // lines of LEAD per page, so every full page ends on the same line
const MEASURE = TRIM.width - INNER - OUTER; // 86 mm: about 57 characters of Gentium at 10 pt
const page = {
  width: mm(TRIM.width), height: mm(TRIM.height), dpi: 150, backgroundColor: col('paper'),
  margins: { top: mm(TOP), bottom: mm(TRIM.height - TOP - (LINES * LEAD * 25.4) / 72),
    left: mm(INNER), right: mm(OUTER), mirror: true }, // left is inner on a recto
};
// #endregion

// #region answer: Spanish syllables, word spaces under 1.7×, captions that say Figura
const LOCALE = 'es'; // config().locale: Spanish hyphenation, and Spanish caption labels
const bodyText = { // config().bodyText
  fontFamily: TEXT, fontSize: pt(BODY), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  referenceBold: false, // '[fig. 1]' reads in roman, like the words around it
  firstLineIndent: mm(4), indentAfterHeading: false, // a paragraph after a heading starts flush
  // The defaults justify, hyphenate, break by Knuth–Plass and keep widows and orphans out.
  // Long Spanish words on an 86 mm measure need two more settings. Spaces under 1.7×: at
  // the default 2×, eight lines here open past 1.6×; at 1.7 it hyphenates hie-rro, ca-lles.
  // A last line under 26 space widths is a runt; at 20, 'siem- / pre adelante.' got through.
  maxWordSpacing: 1.7, runtMinCharacters: 26,
};
// config().resourceTypes: the built-in types, 'Figura' and 'Fig.' in Spanish, renumbered
// through the book (Figura 1) where the default counts by chapter (Figura 1.1).
const resourceTypes = defaultResourceTypes(LOCALE)
  .map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' }));
// #endregion

// #region opener: the chapter spelled out, its title, and a raised initial on the text
const [LABEL_Y, RULE_Y, TITLE_Y] = [4, 10.5, 13.5]; // mm below the top of the text block
const SINK = 8; // lines of LEAD above the text: the chapter drops a quarter of the page
const INITIAL = 3 * LEAD; // pt: the initial's size, three leads
const RISE = 2; // lines of LEAD the raised initial keeps clear above the first line
const centred = (y) => ({ anchor: { to: 'container', edge: 'top' }, offset: { y: mm(y) } });
const opener = {
  enabled: true,
  slot: { elements: [
    // The ordinal is written in the heading, # Perdido {ordinal="primero"}: the plate before
    // the chapter is a level-1 heading too, so the level carries no counter.
    { kind: 'text', id: 'chapter', content: 'capítulo {attr.ordinal}', ...smallCaps,
      placement: centred(LABEL_Y) },
    { kind: 'rule', id: 'rule', direction: 'horizontal', thickness: pt(0.6),
      color: col('oxblood'), placement: { ...centred(RULE_Y), size: { width: mm(9) } } },
    { kind: 'text', id: 'title', content: '{titleText}', fontFamily: DISPLAY, italic: true,
      fontSize: pt(26), lineHeight: 1.1, color: col('ink'), align: 'center',
      overflow: 'wrap', // a longer title takes a second line
      placement: centred(TITLE_Y) },
  ] },
  // The text starts SINK lines down; the initial's rise takes the last RISE of them.
  minHeight: pt((SINK - RISE) * LEAD),
};
// parity 'any': each chapter opens on the next page, as pocket books do.
// marginBottom replaces the level's default, a blank line of its own. The first paragraph
// of the chapter opens with a raised initial: lines: 1 stands it on the first baseline,
// three leads tall, and the paragraph keeps its rise clear above it.
const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
  marginBottom: pt(0), advancedDesign: opener,
  dropCap: { lines: 1, fontFamily: DISPLAY, fontWeight: 700, fontSize: pt(INITIAL),
    color: col('oxblood') } };
// #endregion

// #region heads: the author on the verso, the title on the recto; a drop folio on the opener
const [HEAD_Y, DROP_Y] = [9, -10]; // mm: heads from the top edge, drop folio from the foot
const head = (id, content, parity, edge, x, y = HEAD_Y, pages = 'body') => ({
  kind: 'text', id, content, parity, pages, // running heads skip the openers
  fontFamily: LABEL, fontSize: pt(8), letterSpacing: pt(1.2), color: col('muted'),
  placement: { anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } },
});
const SHIFT = (INNER - OUTER) / 2; // mm: the text block's centre is off the page's centre
// Folios in the text face (Marcellus SC's 1 and 0 read as I and O), on the heads' baseline.
const folio = { fontFamily: TEXT, letterSpacing: pt(0), color: col('ink') };
const header = { elements: [
  { ...head('verso-folio', '{pageNumber}', 'even', 'top-left', OUTER), ...folio },
  head('verso-author', '{author}', 'even', 'top', -SHIFT),
  head('recto-title', '{title}', 'odd', 'top', SHIFT),
  { ...head('recto-folio', '{pageNumber}', 'odd', 'top-right', -OUTER), ...folio },
] };
const drop = (parity, x) => ({ ...head(`drop-${parity}`, '{pageNumber}', parity, 'bottom', x,
  DROP_Y, 'opener'), ...folio }); // a chapter can open on either side of the spread
const footer = { elements: [drop('odd', SHIFT), drop('even', -SHIFT)] };
// #endregion

// #region figure: the map faces chapter I as Figura 1; the text cites it as [fig. 1]
const MAP_H = 112; // mm: a frontispiece map, the measure wide
const resources = [{
  id: 'plano', typeId: 'figure', kind: 'svg', createdAt: 0, updatedAt: 0,
  svg: { fileId: 'plano.svg', width: MEASURE, height: MAP_H }, // the ratio: set column-wide
  placement: { position: 'here' }, // where ::resource{id="plano"} stands, not a float
  caption: 'El camino de Golfín: de Villafangosa, por la pasadera y el cerro, al talud de '
    + 'las minas de Socartes.',
  note: 'Plano conjetural dibujado para esta edición a partir del capítulo primero.',
  altText: 'Plano: abajo, la villa, el río y la pasadera; en medio, un cerro arbolado; arriba, '
    + 'las minas en gradas. Un punteado lleva de la villa al talud de las minas.',
}];
// The plate's heading, # Las minas de Socartes {style="lamina"}, opens a page the heads skip.
const frontispiece = {
  id: 'lamina', // its own break, or it takes the chapter's (gotcha: style-inherits-break)
  breakBefore: { enabled: true, parity: 'any' }, marginBottom: pt(0),
  footer: { elements: [] }, // no drop folio
  advancedDesign: { enabled: true, slot: { elements: [{ kind: 'text', id: 'name',
    content: '{titleText}', ...smallCaps, // a line down: the plate centres on the page
    placement: { anchor: { to: 'container', edge: 'top' }, offset: { y: pt(LEAD) } } }] } },
};
const captionStyle = { fontFamily: TEXT, fontSize: pt(8.3), gap: mm(2),
  labelColor: col('oxblood'), descriptionItalic: true, note: { color: col('muted') } };
// #endregion

// #region styles: the asterisks that close the excerpt, the edition's note, the colophon
const paragraphStyles = [
  { id: 'asterismo', fontFamily: DISPLAY, fontSize: pt(11), color: col('oxblood'),
    textAlign: 'center' },
  // In ink: a muted note would need italicColor too, for its italic Marianela.
  { id: 'nota', fontSize: pt(8.3), lineHeight: pt(LEAD * 0.8), firstLineIndent: pt(0),
    marginTop: pt(LEAD) },
  { id: 'colofon', fontSize: pt(7.5), color: col('muted'), textAlign: 'center',
    firstLineIndent: pt(0), marginTop: pt(LEAD / 2) },
];
// #endregion

const config = () => ({
  locale: LOCALE,
  resourceTypes,
  colorPalette,
  page,
  layout: { layoutType: 'single' },
  bodyText,
  headings: {
    fontFamily: DISPLAY, // the designs paint the titles; this keeps Open Sans unloaded
    levels: [chapter],
  },
  headingStyles: [frontispiece],
  paragraphStyles,
  captionStyle,
  header,
  footer,
});

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "Marianela"
author: "Benito Pérez Galdós"
---

# Las minas de Socartes {style="lamina"}

::resource{id="plano"}

# Perdido {ordinal="primero"}

Se puso el sol. Tras el breve crepúsculo vino tranquila y oscura la noche, en cuyo negro seno murieron poco a poco los últimos rumores de la tierra soñolienta, y el viajero siguió adelante en su camino, apresurando su paso a medida que avanzaba la noche. Iba por angosta vereda, de esas que sobre el césped traza el constante pisar de hombres y brutos, y subía sin cansancio por un cerro en cuyas vertientes se alzaban pintorescos grupos de guinderos, hayas y robles. (Ya se ve que estamos en el Norte de España.)

Era un hombre de mediana edad, de complexión recia, buena talla, ancho de espaldas, resuelto de ademanes, firme de andadura, basto de facciones, de mirar osado y vivo, ligero a pesar de su regular obesidad, y (dígase de una vez aunque sea prematuro) excelente persona por doquiera que se le mirara. Vestía el traje propio de los señores acomodados que viajan en verano, con el redondo sombrerete, que debe a su fealdad el nombre de hongo, gemelos de campo pendientes de una correa, y grueso bastón que, entre paso y paso, le servía para apalear las zarzas cuando extendían sus ramas llenas de afiladas uñas para atraparle la ropa.

Detúvose, y mirando a todo el círculo del horizonte, parecía impaciente y desasosegado. Sin duda no tenía gran confianza en la exactitud de su itinerario y aguardaba el paso de algún aldeano que le diese buenos informes topográficos para llegar pronto y derechamente a su destino.

—No puedo equivocarme —murmuró—. Me dijeron que atravesara el río por la pasadera… así lo hice. Después que marchara adelante, siempre adelante. En efecto, allá, detrás de mí queda esa apreciable villa, a quien yo llamaría *Villafangosa* por el buen surtido de lodos que hay en sus calles y caminos… De modo que por aquí, adelante, siempre adelante (me gusta esta frase, y si yo tuviera escudo no le pondría otra divisa) he de llegar a las famosas minas de Socartes [:ref{id="plano" case="lower"}].

Después de andar largo trecho, añadió:

—Me he perdido, no hay duda de que me he perdido… Aquí tienes, Teodoro Golfín, el resultado de tu «adelante, siempre adelante». Estos palurdos no conocen el valor de las palabras. O han querido burlarse de ti, o ellos mismos ignoran dónde están las minas de Socartes. Un gran establecimiento minero ha de anunciarse con edificios, chimeneas, ruido de arrastres, resoplido de hornos, relincho de caballos, trepidación de máquinas, y yo no veo, ni huelo, ni oigo nada… Parece que estoy en un desierto… ¡qué soledad! Si yo creyera en brujas, pensaría que mi destino me proporcionaba esta noche el honor de ser presentado a ellas… ¡Demonio!, ¿pero no hay gente en estos lugares?… Aún falta media hora para la salida de la luna. ¡Ah!, bribona, tú tienes la culpa de mi extravío… Si al menos pudiera conocer el sitio donde me encuentro… ¿Pero qué más da? (Al decir esto, hizo un gesto propio del hombre esforzado que desprecia los peligros). Golfín, tú que has dado la vuelta al mundo, ¿te acobardarás ahora?… ¡Ah!, los aldeanos tenían razón: adelante, siempre adelante. La ley universal de la locomoción no puede fallar en este momento.

Y puesta denodadamente en ejecución aquella osada ley, recorrió un kilómetro, siguiendo a capricho las veredas que le salían al paso y se cruzaban y se quebraban en ángulos mil, cual si quisiesen engañarle y confundirle más. Por grande que fuera su resolución e intrepidez, al fin tuvo que pararse. Las veredas, que al principio subían, luego empezaron a bajar, enlazándose; y al fin bajaron tanto, que nuestro viajero hallose en un talud, por el cual solo habría podido descender echándose a rodar.

—¡Bonita situación! —exclamó sonriendo y buscando en su buen humor lenitivo a la enojosa contrariedad—. ¿En dónde estás, querido Golfín? Esto parece un abismo. ¿Ves algo allá abajo? Nada, absolutamente nada… pero el césped ha desaparecido, el terreno está removido. Todo es aquí pedruscos y tierra sin vegetación, teñida por el óxido de hierro… Sin duda estoy en las minas… pero ni alma viviente, ni chimeneas humeantes, ni ruido, ni un tren que murmure a lo lejos, ni siquiera un perro que ladre… ¿Qué haré?, hay por aquí una vereda que vuelve a subir. ¿Seguirela? ¿Desandaré lo andado?… ¡Retroceder! ¡Qué absurdo! O yo dejo de ser quien soy, o llegaré esta noche a las famosas minas de Socartes y abrazaré a mi querido hermano. Adelante, siempre adelante.

Dio un paso y hundiose en la frágil tierra movediza.

—¿Esas tenemos, señor planeta?… ¿Conque quiere usted tragarme?… Si ese holgazán satélite quisiera alumbrar un poco, ya nos veríamos las caras usted y yo… Y a fe que por aquí abajo no hemos de ir a ningún paraíso. Parece esto el cráter de un volcán apagado… Hay que andar suavemente por tan delicioso precipicio. ¿Qué es esto? ¡Ah! Una piedra; magnífico asiento para echar un cigarro, esperando a que salga la luna.

El discreto Golfín se sentó tranquilamente como podría haberlo hecho en el banco de un paseo; y ya se disponía a fumar, cuando sintió una voz… sí, indudablemente era una voz humana que lejos sonaba, un quejido patético, mejor dicho, melancólico canto, formado de una sola frase, cuya última cadencia se prolongaba apianándose en la forma que los músicos llamaban *morendo*, y que se apagaba al fin en el plácido silencio de la noche, sin que el oído pudiera apreciar su vibración postrera.

:::space{lines=2}

:::paragraphs{style="asterismo"}
\*  \*  \*
:::

:::paragraphs{style="nota"}
⁠1878. Galdós publica *Marianela* en Madrid, en la Imprenta y Litografía de La Guirnalda. Seguimos el comienzo de su capítulo primero en el texto de dominio público de Project Gutenberg (eBook 17340), con la ortografía y la puntuación actuales: raya en los diálogos y comillas latinas para la consigna del viajero, que se vuelven inglesas cuando la cita va dentro de otra: «el resultado de tu “adelante, siempre adelante”». Los corchetes señalan lo que añade el editor.
:::

:::paragraphs{style="colofon"}
Compuesto en Gentium Book Plus, Libre Bodoni y Marcellus SC (SIL OFL)
:::
`; // content.es.md, inlined by the Cookbook

// #region art: the map of Socartes, in millimetres at its printed size, in the palette
function mulberry32(seed) { // a seeded PRNG: the same drawing on every run
  return () => {
    seed = (seed + 0x6d2b79f5) | 0;
    let r = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    r = (r + Math.imul(r ^ (r >>> 7), 61 | r)) ^ r;
    return ((r ^ (r >>> 14)) >>> 0) / 4294967296;
  };
}
const mix = (hex, other, k) => `#${[1, 3, 5].map((i) => Math.round(
  parseInt(hex.slice(i, i + 2), 16) * (1 - k) + parseInt(other.slice(i, i + 2), 16) * k)
  .toString(16).padStart(2, '0')).join('')}`;
const n2 = (v) => +v.toFixed(2);
// A smooth path through the points (Catmull-Rom as cubic Béziers), open or closed.
function smooth(list, close = false) {
  const p = close ? [list.at(-1), ...list, list[0], list[1]] : [list[0], ...list, list.at(-1)];
  let d = `M${n2(p[1][0])} ${n2(p[1][1])}`;
  for (let i = 1; i < p.length - 2; i++) {
    const [a, b, c, e] = [p[i - 1], p[i], p[i + 1], p[i + 2]];
    d += `C${n2(b[0] + (c[0] - a[0]) / 6)} ${n2(b[1] + (c[1] - a[1]) / 6)} `
      + `${n2(c[0] - (e[0] - b[0]) / 6)} ${n2(c[1] - (e[1] - b[1]) / 6)} ${n2(c[0])} ${n2(c[1])}`;
  }
  return close ? `${d}Z` : d;
}
// The map's names are set in the label face, named on the root element: loadSvg embeds it.
function drawMap(W, H) { // drawn for W 86 × H 112: the mines above, the town below
  const rnd = mulberry32(1878);
  const C = {
    ground: mix(palette.paper, palette.moss, 0.16), hill: mix(palette.moss, palette.paper, 0.45),
    wood: palette.moss, shade: mix(palette.moss, palette.ink, 0.35),
    earth: mix(palette.slag, palette.paper, 0.3), bench: palette.slag,
    deep: mix(palette.slag, palette.ink, 0.2), pit: mix(palette.slag, palette.ink, 0.45),
    edge: mix(palette.slag, palette.ink, 0.5),
    water: palette.water, ink: palette.ink, path: palette.muted, route: palette.oxblood,
    stream: mix(palette.water, palette.ink, 0.3), // the river's name: 5.7:1 on the ground
  };
  const out = [`<rect width="${W}" height="${H}" fill="${C.ground}"/>`];
  const fill = (d, color) => out.push(`<path d="${d}" fill="${color}"/>`);
  const stroke = (d, color, w, extra = '') => out.push(`<path d="${d}" fill="none" `
    + `stroke="${color}" stroke-width="${w}" stroke-linecap="round" `
    + `stroke-linejoin="round"${extra}/>`);
  // The names, set first so that no tree grows over them.
  const labels = [[33, 30.5, 'minas de', 'end'], [33, 34, 'Socartes', 'end'],
    [34.4, 19, 'talleres', 'end'],
    [43.6, 52.4, 'talud', 'end', C.route], [12, 70.5, 'cerro', 'middle'],
    [25.5, 96.4, 'pasadera'], [70, 94.5, 'río', 'middle', C.stream],
    [3, 109.6, 'Villafangosa'], [5, 11.4, 'N', 'middle']];
  const box = ([x, y, text, anchor = 'start']) => {
    const w = text.length * 1.45;
    const left = anchor === 'end' ? x - w : anchor === 'middle' ? x - w / 2 : x;
    return [left - 0.8, y - 2.6, left + w + 0.8, y + 0.8];
  };
  const clear = (x, y) => labels.map(box).every(([a, b, c, d]) => x < a || x > c || y < b || y > d);
  // The mines: iron-stained earth cut in benches down to the pit, the "extinct crater".
  const mine = [[40, 6], [58, 3], [76, 5], [84, 17], [83, 35], [74, 47], [58, 52], [45, 50],
    [36, 40], [35, 20]];
  [[1, C.earth], [0.76, C.bench], [0.52, C.deep], [0.28, C.pit]].forEach(([k, color], i) => {
    const [cx, cy] = [60 + i * 1.6, 27 - i * 1.4]; // each bench deeper, the pit off-centre
    const ring = mine.map(([x, y]) => [cx + (x - 60) * k + (rnd() - 0.5) * 2.4 * k,
      cy + (y - 27) * k + (rnd() - 0.5) * 2.4 * k]);
    out.push(`<path d="${smooth(ring, true)}" fill="${color}" stroke="${C.edge}" `
      + 'stroke-width="0.25"/>');
  });
  fill('M28.4 11.6h6.5v3.2h-6.5zM33 7h0.9v4.7h-0.9z', C.ink); // workshops, chimney, by the rim
  // The hill of cherry, beech and oak, and the paths that cross it at a thousand angles.
  out.push(`<path d="${smooth([[19, 73], [23, 61], [36, 56], [50, 59], [55, 72], [49, 85],
    [35, 90], [23, 84]], true)}" fill="${C.hill}" stroke="${C.shade}" stroke-width="0.25"/>`);
  for (let i = 0; i < 100; i++) {
    const a = rnd() * Math.PI * 2;
    const r = Math.sqrt(rnd()) * 16;
    const [x, y] = [37 + Math.cos(a) * r, 73 + Math.sin(a) * r];
    const [size, shade] = [0.55 + rnd() * 0.55, rnd() < 0.3 ? C.shade : C.wood];
    if (clear(x, y)) {
      out.push(`<circle cx="${n2(x)}" cy="${n2(y)}" r="${n2(size)}" fill="${shade}"/>`);
    }
  }
  [[[22, 80], [30, 74], [28, 64], [38, 58]], [[26, 62], [36, 70], [46, 64], [52, 70]],
    [[27, 87], [33, 79], [42, 82], [50, 76]], [[38, 57], [40, 67], [33, 76], [37, 89]]]
    .forEach((path) => stroke(smooth(path), C.path, 0.3, ' stroke-dasharray="1 0.8"'));
  // The river, the footbridge, and the town the traveller left behind.
  stroke(smooth([[0, 92], [12, 93], [22, 99], [34, 100], [50, 96], [66, 98], [W + 1, 102]]),
    C.water, 2.4);
  stroke('M20.4 95.2L18.6 101.2', C.ink, 0.7);
  for (let i = 0; i < 18; i++) {
    const [x, y] = [3 + (i % 6) * 2.2 + rnd() * 0.5, 98.6 + Math.floor(i / 6) * 2.3 + rnd() * 0.5];
    fill(`M${n2(x)} ${n2(y)}h1.5v1.2h-1.5z`, C.ink);
  }
  fill('M5.2 94.4h1v2.4h-1zM4 96.2h3.4v2h-3.4z', C.ink); // the church
  // Golfín's walk: over the footbridge, up the hill by its paths, down to the edge.
  stroke(smooth([[12, 101], [17, 99.4], [21, 94], [25, 88], [31, 84], [27, 78], [33, 72],
    [40, 75], [45, 68], [40, 62], [45.3, 53.6]]), C.route, 0.75,
  ' stroke-dasharray="0.05 1.3"');
  out.push(`<circle cx="46.4" cy="51.6" r="1.4" fill="none" stroke="${C.route}" `
    + 'stroke-width="0.6"/>');
  // North, up: a half-inked arrowhead over an N.
  fill('M5 3.2L6.6 8.2L5 7.2Z', C.ink);
  out.push(`<path d="M5 3.2L3.4 8.2L5 7.2Z" fill="none" stroke="${C.ink}" stroke-width="0.2"/>`);
  labels.forEach(([x, y, text, anchor = 'start', color = C.ink]) => out.push(`<text x="${x}" `
    + `y="${y}" text-anchor="${anchor}" fill="${color}">${text}</text>`));
  out.push(`<rect x="0.2" y="0.2" width="${W - 0.4}" height="${H - 0.4}" fill="none" `
    + `stroke="${C.ink}" stroke-width="0.4"/>`);
  return `<svg xmlns="http://www.w3.org/2000/svg" width="${W}mm" height="${H}mm" `
    + `viewBox="0 0 ${W} ${H}" font-family="${LABEL}" font-size="2.35">${out.join('')}</svg>`;
}
// #endregion

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // every face the pages use, loaded before the build
  'Gentium Book Plus': ['400', '400i', '700'], // text, captions (700: 'Figura 1.'), the note
  'Libre Bodoni': ['400', '400i', '700'], // asterisks, the chapter's title, the initial
  'Marcellus SC': ['400'], // chapter label, plate name, running heads
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadSvg('plano.svg', drawMap(MEASURE, MAP_H));
// The map is book page 8, a verso, facing chapter I: folios and parity follow the book.
const continuation = { pageIndexOffset: 7, pageNumbering: { startAt: 8 } };
const doc = await buildDocumentWithFonts({ markdown, resources, continuation }, config(),
  kitFonts(FONTS));
showPages(doc, { title: 'Español justificado en una novela 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 rechten Seite beginnen

Mit `'odd'` erhält ein Kapitel, das auf einer linken Seite beginnen würde, eine leere Seite davor, wie in einer großzügigeren Ausgabe. Kapitel I beginnt bereits auf einer rechten Seite, und die Tafel behält ihre Seite, weil ihr Format einen eigenen Umbruch setzt.

```diff
-const chapter = { level: 1, breakBefore: { enabled: true, parity: 'any' },
+const chapter = { level: 1, breakBefore: { enabled: true, parity: 'odd' },
```

### Die Abbildungen nach Kapiteln nummerieren

Unverändert nummerieren die eingebauten spanischen Typen Abbildungen innerhalb jeder Überschrift der Ebene 1: Die Karte wird zu *Figura 1.1* und der Verweis zu *fig. 1.1*.

```diff
-const resourceTypes = defaultResourceTypes(LOCALE)
-  .map((type) => ({ ...type, numberingTemplate: '{n}', resetOn: 'never' }));
+const resourceTypes = defaultResourceTypes(LOCALE);
```

## Häufige Fehler

- **'1998. ' oder '- ' am Absatzanfang eröffnet eine Liste.** Ein Absatz, der mit einer Zahl, einem Punkt und einem Leerzeichen oder mit einem Bindestrich und einem Leerzeichen beginnt, wird zu einem Listenpunkt. Setzen Sie einen Wortverbinder (U+2060) vor die Zahl, und schreiben Sie Dialoge mit einem Geviertstrich.
- **Attributwerte: kein { oder }; einen Wert mit " in einfache Anführungszeichen setzen.** Ein Attributwert endet an der schließenden geschweiften Klammer, kann also kein { oder } enthalten. Ein Wert, der ein doppeltes Anführungszeichen enthält, kommt in einfache Anführungszeichen; ein Dollarzeichen ist unproblematisch.
- **Ein Überschriftenformat erbt den Seitenumbruch seiner Ebene.** Ein Eintrag in headingStyles übernimmt jedes Feld, das er auslässt, von seiner Überschriftenebene, breakBefore eingeschlossen. Ein Inhaltsverzeichnis oder Impressum, das nach einem :::pagebreak über eine H1 gestaltet wird, erbt die Parität 'odd' und landet hinter einer Vakatseite. Geben Sie einem solchen Format breakBefore: { enabled: false }.

- Ein Vorschlag von neun statt acht Zeilen ließ Seite 11 eine Zeile zu kurz, weil `avoidWidows` keinen Absatz in der letzten Zeile der Seite beginnen ließ. Prüfen Sie nach jeder Änderung an Einstellungen oder Text den Fuß jeder Seite.
- `RISE` ist die Zahl der Zeilen, die die hochgestellte Initiale über dem Text freihält. Eine andere Schrift oder Größe ändert diesen Überstand: Eine kleinere Initiale braucht vielleicht nur eine Zeile, und der Text beginnt dann eine Zeile höher, sofern sich `minHeight` nicht mit ändert.
- Keine dieser Schriften hat das Asterismus-Zeichen ⁂ (U+2042) in ihrer Latin-Datei von Fontsource, sodass der Browser es aus einer Systemschrift nähme. Spanische Bücher markieren den Einschnitt ohnehin mit drei gesperrten Sternchen.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: Marianela (1878), the opening of chapter I, “Perdido”, with today’s spelling and punctuation: Benito Pérez Galdós ([Quelle](https://www.gutenberg.org/ebooks/17340)), gemeinfrei
- Text: The edition’s note and the map’s caption: Postext Cookbook, Original
- Bilder: The conjectural map of the Socartes mines, drawn in code in the page’s palette: Ignacio Ferro, MIT
- Schrift: Gentium Book Plus (OFL-1.1), Libre Bodoni (OFL-1.1), Marcellus SC (OFL-1.1)
- Code: MIT · Beispielinhalt: MIT

## Verwandte Rezepte

- [Nº 014 · Gleichmäßige Wortabstände in schmalen Blocksatzspalten](https://postext.dev/de/cookbook/justification-lab.md): Knuth-Plass mit Wortabständen vom 0,8- bis 1,6-Fachen neben einer gierigen Kontrolle, deren lockere, allein stehende und kurze Zeilen aus dem VDT markiert sind. · Stufe 3 (Fortgeschritten) · Magazine und Zines
- [Nº 038 · Taschenbuch: tiefe Auftakte und Kapitel rechts](https://postext.dev/de/cookbook/trade-paperback-novel.md): Beate und Mareile als Taschenbuch von 140 × 216 mm: gemalter Umschlag, Impressum ohne Kolumnentitel, dann tief gesetzte Kapitel rechts unter kursiver Ziffer. · Stufe 2 (Mittel) · Belletristik, Drama und Prosa
- [Nº 065 · Klassiker im Taschenformat: kurze Kapitel, fortlaufend](https://postext.dev/de/cookbook/short-chapters-run-on.md): 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. · Stufe 2 (Mittel) · Belletristik, Drama und Prosa
