# Figurengedicht: Herberts Easter Wings, zeilenweise zentriert

> George Herberts Easter Wings und The Altar als zentrierter Grundtext, ein Absatz pro Verszeile, sodass die Zeilenlängen die beiden Figuren zeichnen.

- HTML-Fassung: https://postext.dev/de/cookbook/shaped-verse
- Rezept Nº 062 · Schrift und Text · Stufe 1 (Einfach) · Ausgaben: Canvas
- Gattungen: Lyrik
- Benötigt postext ≥ 1.25.0 · getestet mit 1.31.0 am 2026-10-13
- Seiten: [1](https://postext.dev/cookbook/shaped-verse/en/p01.webp?v=f9f3fdde), [2](https://postext.dev/cookbook/shaped-verse/en/p02.webp?v=f9f3fdde), [3](https://postext.dev/cookbook/shaped-verse/en/p03.webp?v=f9f3fdde), [4](https://postext.dev/cookbook/shaped-verse/en/p04.webp?v=f9f3fdde)
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=shaped-verse&lang=en (.postext: https://postext.dev/cookbook/shaped-verse/en/shaped-verse.postext)
- Zuletzt aktualisiert: 2026-10-11
- Andere Sprachen: [en](https://postext.dev/en/cookbook/shaped-verse.md), [es](https://postext.dev/es/cookbook/shaped-verse.md), [ca](https://postext.dev/ca/cookbook/shaped-verse.md), [fr](https://postext.dev/fr/cookbook/shaped-verse.md), [it](https://postext.dev/it/cookbook/shaped-verse.md), [pt](https://postext.dev/pt/cookbook/shaped-verse.md), [zh](https://postext.dev/zh/cookbook/shaped-verse.md), [ja](https://postext.dev/ja/cookbook/shaped-verse.md), [ar](https://postext.dev/ar/cookbook/shaped-verse.md)

## Kurz gesagt

Zwei alte englische Gedichte, deren Zeilen so gedruckt sind, dass jedes Gedicht wie ein Bild aussieht: ein Altar und ein Paar Flügel. Jede Zeile ist einfach zentriert, und ihre Länge zeichnet die Form.

## Was Sie setzen

Ein Erinnerungsheft aus vier A5-Seiten, ein einmal gefalteter A4-Bogen, mit zwei Figurengedichten aus George Herberts *The Temple* in der Schreibweise der Erstausgabe von 1633. Die Titelseite folgt der von 1633 bis hin zu ihrem Motto aus Psalm 29. Innen steht *The Altar* gegenüber *Easter Wings*. Keine Zeile der beiden Gedichte ist eingezogen oder mit Leerzeichen aufgefüllt; jede ist zentriert, sodass der Altar auf einem schmalen Schaft steht und jede Strophe der Flügel sich auf zwei Wörter verengt und wieder weitet. Auf der letzten Seite erzählt unter Herberts Wahlspruch eine Anmerkung, wie die Drucker von 1633 die Flügel quer setzten, und das Impressum wird Zeile für Zeile kürzer. Alles ist als Grundtext oder in einer von drei Überschriftenebenen aus den IM-Fell-Schriften gesetzt, ohne benannte Formate und ohne Seitengestaltungen. Die spanische Ausgabe setzt eine zeilengetreue Fassung, die beide Formen bewahrt.

**Dieses Rezept beantwortet:**

- Wie setze ich Abstände auf der Titelseite und zwischen Strophen, wenn Leerzeilen im Markdown nichts bewirken?
- Wie setze ich Lyrik: eine Zeile pro Vers, Abstand zwischen Strophen, hängende Einzüge für umbrochene Zeilen, keine Silbentrennung?
- Wie setze ich ein Motto, eine Widmung, eine Unterschrift oder ein herausgestelltes Zitat mit großem Anführungszeichen?
- Wie erzwinge ich einen Seiten- oder Spaltenumbruch und lasse jedes Kapitel auf einer rechten Seite beginnen?

## Die kurze Antwort

```js
// script.js, Zeilen 26–38
// Postext joins the lines of a Markdown paragraph, so every line of the poem is a paragraph
// of its own, with a blank line after it. Centred, each line keeps its natural width on the
// axis of the measure, and the lengths Herbert gave his lines draw the figure:
//   Lord, who createdst man in wealth and store,
//
//   Though foolishly he lost the same,
//
//   Decaying more and more,
const verse = {
  fontFamily: 'IM Fell English', fontSize: pt(12.4), lineHeight: pt(LEAD), color: col('ink'),
  textAlign: 'center', // ragged on both sides; only justified text is hyphenated by default
  firstLineIndent: pt(0), // the default 1.5 em moves every line 3.3 mm off the titles' axis
};
```

## Zutaten

**Zeigt**

- [Einzüge, Ausrichtung und Absatzabstände](https://postext.dev/de/docs/configuration-text.md#fließtext): Blocksatz oder Flattersatz, Erstzeileneinzüge mit stumpfem ersten Absatz nach einer Überschrift oder Blockabsätze mit Abstand dazwischen.
- [Überschriftenebenen](https://postext.dev/de/docs/configuration-text.md#überschreibungen-pro-ebene): Schrift, Größenskala, Farbe, Schriftstärke, Abstände, Kursive und Großbuchstaben für jede Überschriftenebene, H1 bis H6.
- [Expliziter vertikaler Abstand](https://postext.dev/de/docs/document-format.md#space): Fügt ganze oder gebrochene Zeilen Abstand zwischen zwei Blöcken ein, wo Leerzeilen nichts bewirken; entfällt am Spaltenanfang.

**Verwendet außerdem**

- [Seiten- und Spaltenumbrüche](https://postext.dev/de/docs/document-format.md#pagebreak)
- [Kapitel, die rechts beginnen](https://postext.dev/de/docs/configuration-text.md#umbruch-davor)
- [Grundschrift](https://postext.dev/de/docs/configuration-text.md#fließtext)
- [Fett, kursiv und ihre Farben](https://postext.dev/de/docs/configuration-text.md#fließtext)
- [Endformat](https://postext.dev/de/docs/configuration-page-layout.md#vorgegebene-seitenformate)
- [Gespiegelte Ränder](https://postext.dev/de/docs/configuration-page-layout.md#gespiegelte-ränder)
- [Eine oder zwei Spalten](https://postext.dev/de/docs/configuration-page-layout.md#layout-typen)
- [Papierfarbe](https://postext.dev/de/docs/configuration-page-layout.md#seite)
- [Semantische Farbpalette](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette)
- [Seiten auf einem Canvas](https://postext.dev/de/docs/configuration-programmatic-usage.md#eine-seite-als-bitmap-rendern)

**Die Konfiguration auf einen Blick**

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

**APIs**

- [`buildDocumentWithFonts`](https://postext.dev/de/docs/configuration-programmatic-usage.md#schriften-vor-dem-layout-laden), [`renderPageToCanvas`](https://postext.dev/de/docs/configuration-programmatic-usage.md#eine-seite-als-bitmap-rendern)

**Schriften**

- IM Fell English (OFL-1.1), IM Fell Great Primer (OFL-1.1), IM Fell English SC (OFL-1.1)

## Zubereitung

### 1 · Eine Verszeile ist ein zentrierter Absatz

Der Code steht oben in [der kurzen Antwort](https://postext.dev/de/cookbook/shaped-verse.md#die-kurze-antwort). Postext fügt die Zeilen eines Markdown-Absatzes zu einer zusammen, darum ist jede Verszeile ein Absatz, mit einer Leerzeile danach. `textAlign: 'center'` setzt jede Zeile in ihrer natürlichen Breite auf die Achse der Satzbreite, und da nur Blocksatz getrennt wird, solange `hyphenation.ragged` nicht eingeschaltet ist, wird kein Wort geteilt ([Grundtext](https://postext.dev/de/docs/configuration-text.md#fließtext)). Jede Zeile ist die erste ihres Absatzes, also würde der Standardeinzug der ersten Zeile von 1,5 em sie 3,3 mm rechts von der Achse der Titel verschieben; `firstLineIndent: pt(0)` hält Gedichte und Titel auf einer Achse.

### 2 · Die Satzbreite richtet sich nach der längsten Zeile

```js
// script.js, Zeilen 42–47
// A line too long for the measure wraps, and both halves are centred, which adds a step to
// the outline.
// The widest line, 'Lord, who createdst man in wealth and store,', is 80.7 mm in 12.4 pt
// Fell, so the 110 mm measure takes every line of both poems whole.
const TRIM = { w: 148, h: 210 }; // mm: A5, one A4 sheet folded once
const MARGIN = { top: 22, bottom: 24, inner: 17, outer: 21 }; // mm: the measure is 110 mm
```

Eine Zeile, die nicht passt, bricht um, und beide Hälften werden zentriert, sodass der Umriss eine Stufe bekommt, die nicht in Herberts Figur steht. Bei einer Satzbreite von 75 mm brechen drei Zeilen von *The Altar* und zwei von *Easter Wings* um, und das Heft wächst auf sechs Seiten. Die breiteste Zeile hier, die erste von *Easter Wings*, misst auf Englisch 80,7 mm und auf Spanisch 80,0 mm, sodass die Satzbreite von 110 mm der A5-Seite jede Zeile ganz aufnimmt.

### 3 · Schlichte Überschriften, zentriert

```js
// script.js, Zeilen 51–71
// headings.textAlign centres every level. The Fell faces ship weight 400 only, so no level
// asks for bold.
const titles = {
  fontFamily: 'IM Fell Great Primer', fontWeight: 400, color: col('violet'), textAlign: 'center',
  levels: [
    // The keepsake's title opens page 1, where a break changes nothing; a second # title
    // would open on a recto.
    { level: 1, fontSize: pt(40), lineHeight: pt(LEAD * 3), textTransform: 'uppercase',
      marginBottom: pt(LEAD), breakBefore: { enabled: true, parity: 'odd' } },
    // A poem opens the next page, recto or verso: The Altar (page 2) faces Easter Wings.
    // A heading that opens a page drops its top margin, so the title is lowered by its
    // line box instead: three lines deep, it sets the title about 5 mm down. The default
    // half-em margin under it rounds up to a whole line, and each poem starts on line 5.
    { level: 2, fontSize: pt(24), lineHeight: pt(LEAD * 3), textTransform: 'uppercase',
      breakBefore: { enabled: true, parity: 'any' } },
    // The small-capital labels on pages 1 and 4: the sister face at the body size, with no
    // margins, since the :::space lines place them.
    { level: 3, fontFamily: 'IM Fell English SC', fontSize: pt(12.4), lineHeight: pt(LEAD),
      marginTop: pt(0), marginBottom: pt(0) },
  ],
};
```

Keine Ebene hat eine Gestaltung, nur Schrifteinstellungen, und `headings.textAlign` zentriert alle drei ([Überschriften](https://postext.dev/de/docs/configuration-text.md#überschriften)). Die Parität `'any'` eröffnet jedes Gedicht auf der nächsten Seite, rechts oder links, sodass der Altar auf Seite 2 den Flügeln auf Seite 3 gegenübersteht. Ein `marginTop` kann diese Titel nicht absenken, weil der Abstand über einer Überschrift, die eine Seite eröffnet, entfällt. Stattdessen senkt der drei Zeilen hohe Zeilenkasten der Ebene 2 jeden Titel um etwa 5 mm, und beide Gedichte beginnen auf Zeile 5 des Satzspiegels. Ebene 1 eröffnet das Dokument und braucht hier keinen Umbruch; sie trägt trotzdem einen, damit ein zweiter Titel mit `#` auf einer rechten Seite begänne. Ebene 3 setzt die vier Marken der Seiten 1 und 4 in den Kapitälchen der IM Fell English SC, in Grundschriftgröße.

### 4 · Das Markdown setzt die Abstände und bricht die Seiten um

```js
// script.js, Zeilen 75–92
// A blank line adds no space, so every gap on these pages is a :::space{lines=N}, which adds
// N lines of the 17 pt grid (one without the attribute). ## opens a page for each poem, and
// :::pagebreak opens the last page.
const config = () => ({
  colorPalette,
  locale: t({ en: 'en-us', es: 'es' }), // the language of each edition, and of its bundle
  page: { // mirror: left is the inner margin; 150 dpi is for the screen
    sizePreset: 'custom', width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true },
  },
  layout: { layoutType: 'single' }, // the default is two columns
  bodyText: verse, // a :ref added to these pages takes main-color, the ink, like bold and italic
  headings: titles,
  header: { elements: [] }, // a folded keepsake of four pages: no running heads,
  footer: { elements: [] }, // and no folios
});
```

Eine Leerzeile zwischen Absätzen schafft keinen Abstand, darum stammt jede Lücke auf diesen Seiten aus `:::space`: eine Zeile zwischen den Strophen von *Easter Wings* und auf der Titelseite fünf Zeilen über der Psalm-Marke und zehn unter dem Motto ([`:::space`](https://postext.dev/de/docs/document-format.md#space)). Mit diesen Zahlen endet das Impressum auf Seite 1 auf der letzten Zeile des Satzspiegels, und die Marken über dem Psalm und über der Anmerkung stehen beide auf Zeile 13, 94 mm unter der Seitenoberkante. `:::pagebreak` eröffnet die letzte Seite ([`:::pagebreak`](https://postext.dev/de/docs/document-format.md#pagebreak)). Auch das Impressum ist mit einem Absatz pro Zeile gesetzt, umbrochen am Ende einer Wortgruppe, sodass seine vier Zeilen zu einer Schlussvignette zulaufen.

## 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/shaped-verse

### script.js

```js
// ═══ Postext Cookbook · Nº 062 · Shaped verse: Herbert's Easter Wings, centred line by line ═
// https://postext.dev/en/cookbook/shaped-verse
// Code: MIT · Text: G. Herbert, The Temple, 1633 (PD; EEBO-TCP, CC0) · Spanish version: CC BY 4.0
// Fonts: IM Fell English, Great Primer and English SC (SIL OFL 1.1) · Needs postext ≥ 1.25.0
import { buildDocumentWithFonts, renderPageToCanvas } from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
const palette = { // every colour in the config links to one of these
  ink: '#241d29', // the text: a violet-black
  violet: '#5c2a6f', // the one accent: the titles and the small-capital labels
  paper: '#fbf7ee', // the sheet: an uncoated cream
};
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' } })),
  // Italic and bold take their colour from 'main-color' unless set. With 'main-color' in ink,
  // the italic subtitle, epigraph and motto print in ink instead of the default blue.
  { id: 'main-color', name: 'ink (defaults)', value: { hex: palette.ink, model: 'hex' } },
];
const LEAD = 17; // pt: the leading of every line, verse and prose alike

// #region answer: centred text, one line of verse to a paragraph
// Postext joins the lines of a Markdown paragraph, so every line of the poem is a paragraph
// of its own, with a blank line after it. Centred, each line keeps its natural width on the
// axis of the measure, and the lengths Herbert gave his lines draw the figure:
//   Lord, who createdst man in wealth and store,
//
//   Though foolishly he lost the same,
//
//   Decaying more and more,
const verse = {
  fontFamily: 'IM Fell English', fontSize: pt(12.4), lineHeight: pt(LEAD), color: col('ink'),
  textAlign: 'center', // ragged on both sides; only justified text is hyphenated by default
  firstLineIndent: pt(0), // the default 1.5 em moves every line 3.3 mm off the titles' axis
};
// #endregion

// #region measure: an A5 leaf whose measure holds the longest line whole
// A line too long for the measure wraps, and both halves are centred, which adds a step to
// the outline.
// The widest line, 'Lord, who createdst man in wealth and store,', is 80.7 mm in 12.4 pt
// Fell, so the 110 mm measure takes every line of both poems whole.
const TRIM = { w: 148, h: 210 }; // mm: A5, one A4 sheet folded once
const MARGIN = { top: 22, bottom: 24, inner: 17, outer: 21 }; // mm: the measure is 110 mm
// #endregion

// #region titles: plain headings, centred, in the display face and the accent
// headings.textAlign centres every level. The Fell faces ship weight 400 only, so no level
// asks for bold.
const titles = {
  fontFamily: 'IM Fell Great Primer', fontWeight: 400, color: col('violet'), textAlign: 'center',
  levels: [
    // The keepsake's title opens page 1, where a break changes nothing; a second # title
    // would open on a recto.
    { level: 1, fontSize: pt(40), lineHeight: pt(LEAD * 3), textTransform: 'uppercase',
      marginBottom: pt(LEAD), breakBefore: { enabled: true, parity: 'odd' } },
    // A poem opens the next page, recto or verso: The Altar (page 2) faces Easter Wings.
    // A heading that opens a page drops its top margin, so the title is lowered by its
    // line box instead: three lines deep, it sets the title about 5 mm down. The default
    // half-em margin under it rounds up to a whole line, and each poem starts on line 5.
    { level: 2, fontSize: pt(24), lineHeight: pt(LEAD * 3), textTransform: 'uppercase',
      breakBefore: { enabled: true, parity: 'any' } },
    // The small-capital labels on pages 1 and 4: the sister face at the body size, with no
    // margins, since the :::space lines place them.
    { level: 3, fontFamily: 'IM Fell English SC', fontSize: pt(12.4), lineHeight: pt(LEAD),
      marginTop: pt(0), marginBottom: pt(0) },
  ],
};
// #endregion

// #region pages: four pages, spaced and broken from the Markdown
// A blank line adds no space, so every gap on these pages is a :::space{lines=N}, which adds
// N lines of the 17 pt grid (one without the attribute). ## opens a page for each poem, and
// :::pagebreak opens the last page.
const config = () => ({
  colorPalette,
  locale: t({ en: 'en-us', es: 'es' }), // the language of each edition, and of its bundle
  page: { // mirror: left is the inner margin; 150 dpi is for the screen
    sizePreset: 'custom', width: mm(TRIM.w), height: mm(TRIM.h), dpi: 150,
    backgroundColor: col('paper'),
    margins: { top: mm(MARGIN.top), bottom: mm(MARGIN.bottom), left: mm(MARGIN.inner),
      right: mm(MARGIN.outer), mirror: true },
  },
  layout: { layoutType: 'single' }, // the default is two columns
  bodyText: verse, // a :ref added to these pages takes main-color, the ink, like bold and italic
  headings: titles,
  header: { elements: [] }, // a folded keepsake of four pages: no running heads,
  footer: { elements: [] }, // and no folios
});
// #endregion

// ─── 2 · Content ────────────────────────────────────────────────────────────
const markdown = String.raw`---
title: "The Temple"
author: "George Herbert"
---

# The Temple

*Sacred Poems and Private Ejaculations*

:::space

By Mr. George Herbert

:::space{lines=5}

### Psal. 29.

*In his Temple doth every man speak of his honour.*

:::space{lines=10}

### Two shaped poems

in the text of the first edition

Cambridge, 1633

## The Altar

A broken ALTAR, Lord, thy servant reares,

Made of a heart, and cemented with teares:

Whose parts are as thy hand did frame;

No workmans tool hath touch’d the same.

A HEART alone

Is such a stone,

As nothing but

Thy pow’r doth cut.

Wherefore each part

Of my hard heart

Meets in this frame,

To praise thy name.

That if I chance to hold my peace,

These stones to praise thee may not cease.

O let thy blessed SACRIFICE be mine,

And sanctifie this ALTAR to be thine.

## Easter Wings

Lord, who createdst man in wealth and store,

Though foolishly he lost the same,

Decaying more and more,

Till he became

Most poore:

With thee

O let me rise

As larks, harmoniously,

And sing this day thy victories:

Then shall the fall further the flight in me.

:::space

My tender age in sorrow did beginne:

And still with sicknesses and shame

Thou didst so punish sinne,

That I became

Most thinne.

With thee

Let me combine,

And feel this day thy victorie:

For, if I imp my wing on thine,

Affliction shall advance the flight in me.

:::pagebreak

### Herbert’s motto

*Lesse then the least of Gods mercies.*

:::space

With it, his printers wrote, he used to conclude “all things that might seem to tend any way to his own honour”.

:::space{lines=7}

### A note on the text

The poems keep the spelling of the first edition, printed at Cambridge in 1633. There “The Altar” stands upright on page 18, and the two stanzas of “Easter Wings” lie sideways on pages 34 and 35. Here they stand upright; give the booklet a quarter turn and each opens into a pair of wings.

:::space{lines=5}

Set in IM Fell English, English SC and Great Primer,

Igino Marini’s revivals of the types

John Fell gathered at Oxford.

Text from EEBO-TCP.
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
// Every face the design uses. Layout measures with the browser's fonts, so the
// kit loads them from Fontsource before the first build.
const FONTS = {
  'IM Fell English': ['400', '400i'],
  'IM Fell Great Primer': ['400'],
  'IM Fell English SC': ['400'],
};

// ─── 4 · Build & show ───────────────────────────────────────────────────────
const doc = await buildDocumentWithFonts({ markdown }, config(), kitFonts(FONTS));
showPages(doc, { title: t({ en: 'Easter Wings and The Altar', es: 'Alas de Pascua y El altar' }) });

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

## Varianten

### Jedes Gedicht auf einer rechten Seite eröffnen

Mit der Parität `'odd'` beginnen *The Altar* und *Easter Wings* auf den Seiten 3 und 5, beides rechte Seiten, und das Heft wächst auf sechs Seiten mit leeren Seiten 2 und 4.

```diff
-      breakBefore: { enabled: true, parity: 'any' } },
+      breakBefore: { enabled: true, parity: 'odd' } },
```

### Die Verse linksbündig setzen

Mit `textAlign: 'left'` beginnt jede Zeile am Rand, und die beiden Gedichte behalten nur ihren rechten Umriss; auch der übrige Grundtext, vom Untertitel bis zum Impressum, wird linksbündig, und nur die Überschriften bleiben zentriert.

```diff
-  textAlign: 'center', // ragged on both sides; only justified text is hyphenated by default
+  textAlign: 'left', // ragged right; only justified text is hyphenated by default
```

## Häufige Fehler

- Eine Verszeile, die für die Satzbreite zu lang ist, bricht um, und beide Hälften werden jeweils für sich zentriert. Messen Sie die breiteste Zeile, bevor Sie Schriftgröße und Ränder wählen. Für linksbündig gesetzte Verse mit hängenden Folgezeilen siehe [Gedichte, Zeile für Zeile gesetzt](https://postext.dev/de/cookbook/poetry-collection.md).
- Führende Leerzeichen werden aus einem Absatz entfernt, Sie können eine Zeile also nicht mit Leerzeichen in Form bringen. Die Breite jeder Zeile ergibt sich allein aus ihren Wörtern, und eine Übersetzung muss Zeilen finden, die etwa so breit sind wie die des Originals, wie es die spanische Ausgabe tut.
- Zentrieren hält jede Zeile in ihrer natürlichen Breite, darum werden Platte und Sockel von *The Altar* nicht rechteckig: Die dritte und vierte Zeile der Platte sind schmaler als die ersten beiden. Auch Blocksatz kann sie nicht begradigen, denn die letzte Zeile eines Blocksatzabsatzes behält ihre natürliche Breite, und jede dieser Zeilen ist ein eigener Absatz.
- Titelseite und letzte Seite sind bis zur letzten Rasterzeile gefüllt. Eine Zeile mehr auf der Titelseite, durch ein längeres Motto, eine umbrechende Zeile oder ein größeres `:::space`, schiebt die Marke an ihrem Fuß und die zwei Zeilen darunter auf eine eigene Seite; eine Zeile mehr auf der letzten Seite schiebt die letzte Zeile des Impressums allein auf eine fünfte Seite. Um einer der beiden Seiten eine Zeile hinzuzufügen, nehmen Sie eine aus einem `:::space` darüber heraus.
- Postext setzt keinen Text quer, wie es die Drucker von 1633 mit *Easter Wings* taten: Nur ganze Abbildungen und Tabellen werden gedreht. Hier stehen die Strophen aufrecht, und die Anmerkung auf Seite 4 sagt das.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Text: “The Altar” and “Easter wings”, the title, subtitle, byline and epigraph of the title page, and Herbert’s motto with a clause of the printers’ preface, from the first edition of The Temple (Cambridge, 1633) in the EEBO-TCP transcription (CC0 1.0); the letters and stops it marks as illegible or unknown at six line ends (four in The Altar, two in Easter wings) are supplied: George Herbert; Text Creation Partnership ([Quelle](https://github.com/textcreationpartnership/A03058)), gemeinfrei
- Text: Psalm 29:9 in the Spanish edition, from the Reina-Valera Bible of 1909: Reina-Valera 1909 ([Quelle](https://es.wikisource.org/wiki/Biblia_Reina-Valera_1909/Salmos/29)), gemeinfrei
- Text: The Spanish versions of both poems, of the subtitle, the motto and the printers’ clause, and the title-page imprint, the note on the text and the colophon of both editions, written for this recipe: Ignacio Ferro, CC-BY-4.0
- Schrift: IM Fell English (OFL-1.1), IM Fell Great Primer (OFL-1.1), IM Fell English SC (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 015 · Gedichte, Zeile für Zeile gesetzt](https://postext.dev/de/cookbook/poetry-collection.md): Jedes Gedicht ist ein Block :::verse, geschrieben wie gedruckt: Die Einzüge von 1918 sind Leerzeichen, Folgezeilen hängen 2 em ein, Leerzeilen trennen Strophen. · Stufe 2 (Mittel) · Lyrik
- [Nº 043 · Übersetzung gegenüber, Strophe für Strophe](https://postext.dev/de/cookbook/bilingual-facing-verse.md): Jedes Gedicht ist ein randloser Kasten mit zwei Spalten: Ein fester Spaltenumbruch beginnt das Englische beim Titel, :::space hält die Strophen auf einer Höhe. · Stufe 2 (Mittel) · Lyrik
- [Nº 044 · Kritische Ausgabe: Zeilenzählung und Noten nach Zeilen](https://postext.dev/de/cookbook/critical-edition-line-numbers.md): Miltons „Lycidas“ im Text von 1645: lineNumbers druckt jede fünfte Zeilennummer in der Seitenspalte, und jede Note beginnt mit ihrer Zeilennummer als Chip. · Stufe 3 (Fortgeschritten) · Lyrik
