# Die Spalte, die die Hurenkind-Regel kurz ließ

> Flattersatzspalten, die auf dem Raster enden: fillShortColumns setzt einen Absatz der kurzen Spalte eine Zeile länger oder kürzer.

- HTML-Fassung: https://postext.dev/de/cookbook/filling-short-columns
- Rezept Nº 177 · Seite und Raster · Stufe 2 (Mittel) · Ausgaben: Canvas
- Gattungen: Magazine und Zines
- Benötigt postext ≥ 1.65.0 · getestet mit 1.65.0 am 2026-10-31
- Seiten: [1](https://postext.dev/cookbook/filling-short-columns/en/p01.webp?v=76529999), [1](https://postext.dev/cookbook/filling-short-columns/en/p04.webp?v=76529999), [2](https://postext.dev/cookbook/filling-short-columns/en/p05.webp?v=76529999), [3](https://postext.dev/cookbook/filling-short-columns/en/p06.webp?v=76529999)
- In der Sandbox öffnen: https://postext.dev/de/sandbox#recipe=filling-short-columns&lang=en (.postext: https://postext.dev/cookbook/filling-short-columns/en/filling-short-columns.postext)
- Zuletzt aktualisiert: 2026-10-31
- Andere Sprachen: [en](https://postext.dev/en/cookbook/filling-short-columns.md), [es](https://postext.dev/es/cookbook/filling-short-columns.md), [ca](https://postext.dev/ca/cookbook/filling-short-columns.md), [fr](https://postext.dev/fr/cookbook/filling-short-columns.md), [it](https://postext.dev/it/cookbook/filling-short-columns.md), [pt](https://postext.dev/pt/cookbook/filling-short-columns.md), [zh](https://postext.dev/zh/cookbook/filling-short-columns.md), [ja](https://postext.dev/ja/cookbook/filling-short-columns.md), [ar](https://postext.dev/ar/cookbook/filling-short-columns.md)

## Kurz gesagt

Wenn eine Spalte eine Zeile über dem Seitenfuß endet, weil ein Absatz dort nicht umbrochen werden darf, bricht diese Einstellung einen Absatz der Spalte neu um, damit sie bis unten reicht.

## Was Sie setzen

Drei Seiten aus *Littoral*, einem Küstenmagazin: ein langer Artikel über die Fresnel-Linse, eröffnet von dem Gemälde einer leuchtenden Linse in ihrer Laterne. Der Text läuft in zwei Flattersatzspalten aus Public Sans, mit einer leeren Rasterzeile zwischen den Absätzen, und die Regeln gegen Hurenkinder und Schusterjungen lassen auf jeder Seite eines Umbruchs mindestens zwei Zeilen eines Absatzes stehen. Der Pen setzt den Artikel zweimal. Ohne die Einstellung endet auf [der Auftaktseite](https://postext.dev/cookbook/filling-short-columns/en/p01.webp?v=76529999) die linke Spalte eine Zeile über der rechten: Der nächste Absatz darf nicht mit einer einzelnen Zeile am Fuß beginnen. Mit ihr läuft auf [derselben Seite](https://postext.dev/cookbook/filling-short-columns/en/p04.webp?v=76529999) der erste Absatz eine Zeile länger, und beide Spalten enden auf der letzten Rasterzeile. Auf der Folgeseite nimmt die Einstellung auch die zusätzliche Luft zurück, die der Ausgleich über zwei Zwischentitel gesetzt hatte.

**Dieses Rezept beantwortet:**

- Warum endet eine Flattersatzspalte eine Zeile zu früh, und wie fülle ich sie ohne Hurenkinder?
- Wie erreiche ich bündige Spaltenenden und eine ausgeglichene letzte Seite (vertikaler Ausgleich)?
- Wie vermeide ich Witwen, Waisen und Ausgangszeilen mit nur einem Wort, und wie halte ich eine Überschrift bei ihrem Text?

## Die kurze Antwort

```js
// script.js, Zeilen 37–55
const balancing = {
  // A ragged column has no lever of its own when the widow and orphan rules hold its last
  // line back: no heading or list end to open, no justified paragraph to set looser. This
  // one lets a paragraph of the short column run a line long, within its own word spacing
  // (or a justified one no looser than maxWordSpacing), or, when none can, set one a line
  // short so the split after it takes the line. A change is kept only when the pages end
  // with fewer short columns; otherwise the column stays as it was.
  fillShortColumns: true, // default false
  // The paragraphs go first: a crosshead keeps its one line of air unless they cannot help.
  maxLinesBeforeLoose: 0,
};
const bodyText = {
  fontFamily: 'Public Sans', fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, // a grid line between
  hyphenation: { enabled: true }, optimalLineBreaking: true, optimalRagged: true,
  avoidWidows: true, widowMinLines: 2, avoidOrphans: true, orphanMinLines: 2, // the cause
  avoidRunts: true,
};
```

## Zutaten

**Zeigt**

- [Kurze Spalten füllen](https://postext.dev/de/docs/configuration-page-flow.md#spaltenausgleich): Wenn die Regeln für Hurenkinder und Schusterjungen eine Spalte ein oder zwei Zeilen über dem Fuß enden lassen, verteilt der Ausgleich die Lücke auf Überschriften, Listen und Absätze der Spalte, bis sie bündig endet (headings.balancing.fillShortColumns).

**Verwendet außerdem**

- [Spaltenausgleich](https://postext.dev/de/docs/configuration-page-flow.md#spaltenausgleich)
- [Witwen, Waisen und kurze Ausgangszeilen](https://postext.dev/de/docs/configuration-page-flow.md#waisen-witwen-zu-kurze-ausgangszeilen-und-regeln-zum-zusammenhalten)
- [Optimaler Zeilenumbruch (Knuth-Plass)](https://postext.dev/de/docs/justification.md#knuth-plass-der-blick-auf-den-ganzen-absatz)
- [Gestaltete Auftakte](https://postext.dev/de/docs/configuration-headings.md#seitenbreite-und-erweiterte-gestaltung)
- [Bilder in Seitengestaltungen](https://postext.dev/de/docs/configuration-headers-footers.md#bildelemente)
- [Überschriftenattribute](https://postext.dev/de/docs/document-format.md#attribute-von-überschriften)
- [Kolumnentitel und Seitenzahlen](https://postext.dev/de/docs/configuration-headers-footers.md#kopf--und-fußzeilen)
- [Kopfzeilen nach Seitenrolle](https://postext.dev/de/docs/configuration-headers-footers.md#textelemente)
- [Semantische Farbpalette](https://postext.dev/de/docs/configuration-fonts-colors-viewers.md#farbpalette)
- [Absatzformate](https://postext.dev/de/docs/configuration-styles.md#absatzformate)
- [Kapitelband über die ganze Breite](https://postext.dev/de/docs/configuration-headings.md#seitenbreite-und-erweiterte-gestaltung)
- [Abbildungen und Tabellen als Ressourcen](https://postext.dev/de/docs/document-format-resources.md#ressourcen)

**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-headers-footers.md#kopf--und-fußzeilen), [`header`](https://postext.dev/de/docs/configuration-headers-footers.md#kopf--und-fußzeilen), [`headings`](https://postext.dev/de/docs/configuration-headings.md#überschriften), [`layout`](https://postext.dev/de/docs/configuration-page-layout.md#layout), [`locale`](https://postext.dev/de/docs/configuration-language.md#silbentrennung), [`page`](https://postext.dev/de/docs/configuration-page-layout.md#seite), [`paragraphStyles`](https://postext.dev/de/docs/configuration-styles.md#absatzformate)

**APIs**

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

**Schriften**

- Public Sans (OFL-1.1), Libre Caslon Display (OFL-1.1), Libre Caslon Text (OFL-1.1)

## Zubereitung

### 1 · Der Flattersatzspalte einen eigenen Hebel geben

```js
// script.js, Zeilen 37–55
const balancing = {
  // A ragged column has no lever of its own when the widow and orphan rules hold its last
  // line back: no heading or list end to open, no justified paragraph to set looser. This
  // one lets a paragraph of the short column run a line long, within its own word spacing
  // (or a justified one no looser than maxWordSpacing), or, when none can, set one a line
  // short so the split after it takes the line. A change is kept only when the pages end
  // with fewer short columns; otherwise the column stays as it was.
  fillShortColumns: true, // default false
  // The paragraphs go first: a crosshead keeps its one line of air unless they cannot help.
  maxLinesBeforeLoose: 0,
};
const bodyText = {
  fontFamily: 'Public Sans', fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, // a grid line between
  hyphenation: { enabled: true }, optimalLineBreaking: true, optimalRagged: true,
  avoidWidows: true, widowMinLines: 2, avoidOrphans: true, orphanMinLines: 2, // the cause
  avoidRunts: true,
};
```

Der Spaltenausgleich füllt eine kurze Spalte, indem er Rasterzeilen über einer Überschrift, nach einer Liste, unter einem Kasten oder einer Abbildung einfügt und zuletzt einen Absatz im Blocksatz eine Zeile lockerer setzt. Eine Flattersatzspalte mit Fließtext hat zwischen ihren Zwischentiteln nichts davon, also bleibt die Zeile leer, die die Regeln gegen Hurenkinder und Schusterjungen zurückhalten. `fillShortColumns` lässt einen Flattersatzabsatz dieser Spalte mit seinen eigenen Wortabständen eine Zeile länger laufen; kann das keiner, wird einer eine Zeile kürzer gesetzt, damit der Absatz nach dem Umbruch nachrückt und die Zeile füllt. Die Engine behält die Änderung nur, wenn danach weniger Spalten kurz enden, also macht die Einstellung keine Seite schlechter. `maxLinesBeforeLoose: 0` probiert die Absätze vor den Überschriften, sodass jeder Zwischentitel genau eine Rasterzeile Luft über sich behält.

### 2 · Den Artikel zweimal setzen und vergleichen

```js
// script.js, Zeilen 204–208
const withFill = (fill) => ({ ...config, headings: { ...config.headings,
  balancing: { ...balancing, fillShortColumns: fill } } }); // a copy: config keeps true
const build = (fill) => buildDocumentWithFonts({ markdown, resources }, withFill(fill),
  kitFonts(FONTS));
const docs = { off: await build(false), on: await build(true) };
```

Beide Dokumente entstehen aus derselben Konfiguration und demselben Markdown. Der zweite Satz übergibt die Konfiguration, wie sie ist; der erste eine Kopie mit ausgeschalteter Einstellung, sodass das Paket, das sich in der Sandbox öffnet, `fillShortColumns: true` trägt. Die beiden Schaltflächen über dem Tisch wechseln zwischen den Sätzen. Nur Seiten, auf die der Text weiterläuft, werden bis zum Fuß gefüllt: Seite 3 schließt den Artikel, und ihre Spalten werden in beiden Sätzen auf gleiche Höhe gekürzt.

### 3 · Mit dem Gemälde eröffnen

```js
// script.js, Zeilen 59–85
const DEPTH = 150; // mm: how far down the page the painting bleeds
const sans = { fontFamily: 'Public Sans', fontWeight: 700, textTransform: 'uppercase' };
const at = (to, edge, x, y, width) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const air = { padding: { bottom: mm(6) } }; // counts: the text starts 6 mm under the byline
const opener = {
  enabled: true,
  slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'lens', placement: { anchor: { to: 'bleed',
      edge: 'top-left' }, size: { width: 'fill', height: mm(DEPTH) } } },
    { kind: 'text', id: 'credit', content: '{attr.credit}', ...sans, fontWeight: 500,
      fontSize: pt(6.5), letterSpacing: pt(0.6), color: col('muted'), align: 'right',
      placement: at('container', 'top-right', 0, DEPTH - TOP + 2) },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...sans, fontSize: pt(8.5),
      letterSpacing: pt(1.7), color: col('brass'), align: 'left',
      placement: at('container', 'top-left', 0, DEPTH - TOP + 9) },
    { kind: 'text', id: 'headline', content: '{titleText}', fontFamily: 'Libre Caslon Display',
      fontSize: pt(46), lineHeight: 1.02, color: col('ink'), align: 'left',
      placement: at('#kicker', 'below', 0, 2.5, 112) },
    { kind: 'text', id: 'standfirst', content: '{attr.standfirst}', fontFamily: 'Libre Caslon Text',
      italic: true, fontSize: pt(12), lineHeight: 1.3, color: col('sea'), align: 'left',
      placement: at('#headline', 'right-of', 8, 2.5) },
    { kind: 'text', id: 'byline', content: '{attr.byline}', ...sans, fontSize: pt(7.5),
      letterSpacing: pt(1.3), color: col('ink'), align: 'left', box: air,
      placement: at('#standfirst', 'below', 0, 3.5) },
  ] },
};
```

Das Gemälde ist ein Bildelement, am Anschnitt verankert und 150 mm hoch, sodass es an drei Seiten bis zum Beschnitt reicht, was kein Float tut. Dachzeile, Überschrift, Vorspann und Autorenzeile kommen aus den Attributen der `#`-Zeile. Die Autorenzeile endet in einem Innenabstand von 6 mm, der zur Höhe des Auftakts zählt, sodass die Spalten auf der ersten Rasterzeile darunter beginnen.

### 4 · Kolumnentitel außerhalb des Auftakts

```js
// script.js, Zeilen 89–106
const label = { ...sans, fontWeight: 600, fontSize: pt(7.5), letterSpacing: pt(1.3),
  color: col('muted') };
const folio = { fontFamily: 'Public Sans', fontWeight: 700, fontSize: pt(8.5), color: col('ink') };
const pin = (edge, x, y) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } });
const sides = [['even', 'left', 1], ['odd', 'right', -1]]; // 1: the outer edge is on the left
const header = { elements: sides.flatMap(([parity, edge, s]) => [
  { kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...folio,
    placement: pin(`top-${edge}`, s * OUTER, 12) },
  { kind: 'text', id: `head-${parity}`, ...label,
    content: s > 0 ? '{title} · {subtitle}' : '{chapterTitle}',
    placement: pin(`top-${edge}`, s * (OUTER + 9), 12.35) }, // the smaller size's baseline
  { kind: 'rule', id: `rule-${parity}`, direction: 'horizontal', thickness: pt(0.5),
    color: col('rule'), placement: { ...pin(`top-${edge}`, s * OUTER, 17),
      size: { width: mm(197) } } },
].map((element) => ({ ...element, parity, pages: 'body' }))) }; // none on the opener
const footer = { elements: sides.map(([parity, edge, s]) => ({ kind: 'text', parity,
  id: `drop-folio-${parity}`, ...folio, content: '{pageNumber}', pages: 'opener', align: edge,
  placement: pin(`bottom-${edge}`, s * OUTER, -11) })) }; // the opener's only folio
```

Die linke Seite nennt Magazin und Ausgabe aus dem Frontmatter, die rechte den Artikel, und `pages: 'body'` hält beide vom Auftakt fern, der nur eine Seitenzahl am Fuß trägt. Das Label steht 0,35 mm tiefer als die Seitenzahl, damit beide Größen eine Grundlinie teilen. Die feine Linie darunter ist 197 mm lang, so breit wie der Satzspiegel.

## 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/filling-short-columns

### script.js

```js
// ═══ Postext Cookbook · Nº 177 · Filling the column the widow rule left short ═════
// https://postext.dev/en/cookbook/filling-short-columns
// Code: MIT · Text: original (CC BY 4.0) · Painting: diffusion model
// Fonts: Public Sans, Libre Caslon Display, Libre Caslon Text (OFL) · Needs postext ≥ 1.65.0
//
// A magazine long read in two ragged columns, set twice. Without fillShortColumns a column
// whose paragraph would split into a widow ends a line above the foot; with it, a paragraph
// of that column is set a line longer or shorter, and the column ends on the last grid line.
import {
  buildDocumentWithFonts, renderPageToCanvas, registerResourceImage,
} from 'https://esm.sh/postext';

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

// ─── 1 · Design ─────────────────────────────────────────────────────────────
// Every colour below is linked to this palette by id.
const palette = {
  ink: '#1b2026', // text: a slate near-black
  brass: '#875a14', // the accent: kicker and crossheads (6:1 on paper)
  sea: '#2c4a5e', // the standfirst
  rule: '#c9ccd0', // hairlines
  muted: '#5d656e', // running heads, credit, colophon
  paper: '#ffffff',
};
const col = (id) => ({ hex: palette[id], model: 'hex', paletteId: id });
const colorPalette = [
  ...Object.entries(palette).map(([id, hex]) => ({ id, name: id, value: { hex, model: 'hex' } })),
  // The engine's defaults link to 'main-color': point it at the accent, so nothing prints blue.
  { id: 'main-color', name: 'brass (defaults)', value: { hex: palette.brass, model: 'hex' } },
];
const TOP = 22; // top margin in mm: the opener's container starts here
const OUTER = 14; // outer margin in mm: folios and running heads align to it
const LEAD = 13.2; // body leading in pt: the baseline grid every column ends on

// #region answer: ragged columns that end on the grid: fillShortColumns
const balancing = {
  // A ragged column has no lever of its own when the widow and orphan rules hold its last
  // line back: no heading or list end to open, no justified paragraph to set looser. This
  // one lets a paragraph of the short column run a line long, within its own word spacing
  // (or a justified one no looser than maxWordSpacing), or, when none can, set one a line
  // short so the split after it takes the line. A change is kept only when the pages end
  // with fewer short columns; otherwise the column stays as it was.
  fillShortColumns: true, // default false
  // The paragraphs go first: a crosshead keeps its one line of air unless they cannot help.
  maxLinesBeforeLoose: 0,
};
const bodyText = {
  fontFamily: 'Public Sans', fontSize: pt(9.4), lineHeight: pt(LEAD), color: col('ink'),
  boldColor: col('ink'), italicColor: col('ink'), referenceColor: col('ink'),
  textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, // a grid line between
  hyphenation: { enabled: true }, optimalLineBreaking: true, optimalRagged: true,
  avoidWidows: true, widowMinLines: 2, avoidOrphans: true, orphanMinLines: 2, // the cause
  avoidRunts: true,
};
// #endregion

// #region opener: the painting bled across the top, then kicker, headline and standfirst
const DEPTH = 150; // mm: how far down the page the painting bleeds
const sans = { fontFamily: 'Public Sans', fontWeight: 700, textTransform: 'uppercase' };
const at = (to, edge, x, y, width) => ({ anchor: { to, edge },
  offset: { x: mm(x), y: mm(y) }, ...(width && { size: { width: mm(width) } }) });
const air = { padding: { bottom: mm(6) } }; // counts: the text starts 6 mm under the byline
const opener = {
  enabled: true,
  slot: { elements: [
    { kind: 'image', id: 'art', resourceId: 'lens', placement: { anchor: { to: 'bleed',
      edge: 'top-left' }, size: { width: 'fill', height: mm(DEPTH) } } },
    { kind: 'text', id: 'credit', content: '{attr.credit}', ...sans, fontWeight: 500,
      fontSize: pt(6.5), letterSpacing: pt(0.6), color: col('muted'), align: 'right',
      placement: at('container', 'top-right', 0, DEPTH - TOP + 2) },
    { kind: 'text', id: 'kicker', content: '{attr.kicker}', ...sans, fontSize: pt(8.5),
      letterSpacing: pt(1.7), color: col('brass'), align: 'left',
      placement: at('container', 'top-left', 0, DEPTH - TOP + 9) },
    { kind: 'text', id: 'headline', content: '{titleText}', fontFamily: 'Libre Caslon Display',
      fontSize: pt(46), lineHeight: 1.02, color: col('ink'), align: 'left',
      placement: at('#kicker', 'below', 0, 2.5, 112) },
    { kind: 'text', id: 'standfirst', content: '{attr.standfirst}', fontFamily: 'Libre Caslon Text',
      italic: true, fontSize: pt(12), lineHeight: 1.3, color: col('sea'), align: 'left',
      placement: at('#headline', 'right-of', 8, 2.5) },
    { kind: 'text', id: 'byline', content: '{attr.byline}', ...sans, fontSize: pt(7.5),
      letterSpacing: pt(1.3), color: col('ink'), align: 'left', box: air,
      placement: at('#standfirst', 'below', 0, 3.5) },
  ] },
};
// #endregion

// #region heads: magazine and issue on the verso, the story on the recto
const label = { ...sans, fontWeight: 600, fontSize: pt(7.5), letterSpacing: pt(1.3),
  color: col('muted') };
const folio = { fontFamily: 'Public Sans', fontWeight: 700, fontSize: pt(8.5), color: col('ink') };
const pin = (edge, x, y) => ({ anchor: { to: 'page', edge }, offset: { x: mm(x), y: mm(y) } });
const sides = [['even', 'left', 1], ['odd', 'right', -1]]; // 1: the outer edge is on the left
const header = { elements: sides.flatMap(([parity, edge, s]) => [
  { kind: 'text', id: `folio-${parity}`, content: '{pageNumber}', ...folio,
    placement: pin(`top-${edge}`, s * OUTER, 12) },
  { kind: 'text', id: `head-${parity}`, ...label,
    content: s > 0 ? '{title} · {subtitle}' : '{chapterTitle}',
    placement: pin(`top-${edge}`, s * (OUTER + 9), 12.35) }, // the smaller size's baseline
  { kind: 'rule', id: `rule-${parity}`, direction: 'horizontal', thickness: pt(0.5),
    color: col('rule'), placement: { ...pin(`top-${edge}`, s * OUTER, 17),
      size: { width: mm(197) } } },
].map((element) => ({ ...element, parity, pages: 'body' }))) }; // none on the opener
const footer = { elements: sides.map(([parity, edge, s]) => ({ kind: 'text', parity,
  id: `drop-folio-${parity}`, ...folio, content: '{pageNumber}', pages: 'opener', align: edge,
  placement: pin(`bottom-${edge}`, s * OUTER, -11) })) }; // the opener's only folio
// #endregion

const config = {
  locale: t({ en: 'en-gb', es: 'es' }), // each edition's hyphenation patterns
  colorPalette,
  page: { width: mm(225), height: mm(297), dpi: 150,
    margins: { top: mm(TOP), bottom: mm(21), left: mm(16), right: mm(OUTER), mirror: true } },
  layout: { layoutType: 'double', gutterWidth: mm(7) }, // a 94 mm measure, about 62 characters
  bodyText,
  headings: {
    fontFamily: 'Libre Caslon Text', fontWeight: 400, color: col('ink'),
    balancing, // the first build turns it off (see builds)
    levels: [
      { level: 1, span: 'page', breakBefore: { enabled: true, parity: 'any' },
        marginBottom: pt(0), advancedDesign: opener },
      { level: 2, fontSize: pt(13), lineHeight: pt(LEAD), italic: true, color: col('brass'),
        marginTop: pt(LEAD), marginBottom: pt(0) }, // crossheads, one grid line above
    ],
  },
  paragraphStyles: [{ id: 'colophon', fontFamily: 'Public Sans', fontSize: pt(6.8),
    lineHeight: pt(9.6), color: col('muted'), textAlign: 'left', firstLineIndent: pt(0),
    marginTop: pt(LEAD) }],
  header, footer,
};

// ─── 2 · Content ────────────────────────────────────────────────────────────
const resources = [
  { id: 'lens', typeId: 'figure', kind: 'bitmap', createdAt: 0, updatedAt: 0, // never cited:
    // only the opener draws it, cropped to its 225 × 150 mm box
    bitmap: { fileId: 'lens-1400.jpg', format: 'jpeg', width: 1400, height: 933 },
    altText: t({ en: 'A lit Fresnel lens of brass and glass rings in a lighthouse lantern at '
      + 'dusk, with the sea and a sunset behind the diagonal panes.',
    es: 'Una lente de Fresnel encendida, de anillos de vidrio y latón, en la linterna de un faro '
      + 'al anochecer, con el mar y la puesta de sol tras los cristales en rombo.' }) },
];
const markdown = String.raw`---
title: "Littoral"
subtitle: "Autumn 2026"
---

# The Lens That Lit the Coast {kicker="History · Lights" standfirst="Two hundred years ago a road engineer in poor health took the mirrors out of a lighthouse and put rings of glass in their place. His lenses still turn on a few headlands, fewer every winter." byline="Words by Marta Quiroga" credit="Painting made for this issue"}

The tower of Cordouan stands on a reef at the mouth of the Gironde, six kilometres from either shore, and at high tide it seems to stand on the sea itself. The masons of Louis de Foix began it in 1584 and finished it in 1611, a lighthouse dressed as a palace, with a royal chapel on its third floor. For two centuries its light was a fire of oak and pitch, and later of whale oil, burning in an open brazier under the lantern. Sailors coming in from the Bay of Biscay often saw the tower before they saw its fire. On a hazy night they saw neither, and more than one ship found the reef of Cordouan before it found the channel into the river and the port of Bordeaux beyond it.

In the summer of 1823 a new apparatus went up the stairs of Cordouan in crates. It was a cage of brass, taller than a man, holding panels of glass cut into steps, and in the middle of it a single oil lamp with several concentric wicks. On the first night it was lit, the light reached the pilots at Royan more clearly than the old reflectors ever had. The engineer who designed it, Augustin Fresnel, was thirty-five and already ill with the tuberculosis that would kill him four years later.

Fresnel was not a lighthouse man. He trained at the École polytechnique and spent his first years building roads in the Drôme for the Corps des Ponts et Chaussées, and he did his real work at night, on the nature of light. His memoir on diffraction won the prize of the Academy of Sciences in 1819, against judges who expected light to be a stream of particles. That same year the Commission des Phares, the body in charge of France's coastal lights, asked him to look at its lamps. He found a waste of light that offended him as a physicist.

The lamp came first. With the physicist François Arago, Fresnel built a burner with several round wicks set one inside another, fed with oil from below by a small pump so that the flames never ran dry. It gave a broad, steady flame, much brighter than one wick, but a brighter flame was no use if most of it lit the inside of the lantern. The question was how to send it out to sea.

## A lamp in a ring of prisms

Before Fresnel, the best lighthouses used reflectors. An Argand lamp burned at the focus of a polished copper bowl, silvered on the inside, and the bowl threw the light forward in a beam. A tower needed a dozen or more of these lamps and mirrors, each with its own wick to trim. The silver tarnished, the bowls were never quite parabolic, and much of the light that fell on them was absorbed or scattered. Keepers spent their days polishing, and the beam was still weak.

A lens could do better, in theory, but a lens large enough for a lighthouse would have been a block of glass thick at the centre, heavy enough to crack its own frame, and too cloudy to pass much light. Buffon had suggested a century earlier that a burning glass could be cut into steps, keeping only the curved surface that does the bending. Fresnel took the idea further. He built his lens from separate rings, each ground to its own curve and all bringing the light to the same point, so that no piece of glass was thicker than a few centimetres.

Around the central belt of rings he added prisms above and below. These catch the light that a lens would let escape towards the sky or the sea and turn it back towards the horizon, by refraction and then, in the outer rows, by reflection inside the glass itself. Fresnel worked out these reflecting prisms in the last years of his life, writing to his brother Léonor about their angles. He did not live to see a light built with them from top to bottom: Léonor, who took over his post at the Commission, saw the first ones lit in the 1840s.

The result was a lamp in a ring of prisms that sent most of its light where a ship could see it. A single wick in a large Fresnel lens did the work of the twenty or so lamps and mirrors it replaced, on a fraction of the oil. On a clear night the light of a coast station could be seen from more than twenty nautical miles, limited less by the lamp than by the curve of the Earth.

The glass itself came in pieces. Each ring of a large lens was ground on a turning table against a cast-iron tool of the right curve, then polished with rouge for days, and the finished pieces were bedded in their brass frame with putty. The lens arrived at the lighthouse as a kit of several hundred parts, and the engineer who put it together had to align every ring with the flame to within a millimetre or two, by eye, at night, moving a lamp and watching where its light fell on the rocks below.

## Seven orders of glass

The Commission soon needed lenses of several sizes, and Fresnel's successors sorted them into orders by focal distance, the space between the flame and the inner face of the glass. A first-order lens, for the great coastal lights, had a focal distance of 920 millimetres and stood about two and a half metres high. A sixth-order lens, for a harbour pier or a river mouth, had 150 millimetres and could be carried by two people. Between them came the second, third and fourth orders, with a three-and-a-half added later for the middle distances of estuaries.

Each lens had to say which light it was. A sailor who sees a light on a dark coast needs to know whether it is Cordouan, the Pointe de Grave or a fishing village, and the answer is in its rhythm. Fixed lights burned steadily. Flashing lights used panels of rings like bull's eyes, set round the lamp and turned by clockwork, so that each panel swept a beam across the sea as it passed. The number of panels and the speed of the turn gave each tower its own count of flashes and dark intervals, printed in the lists of lights that every navigator carried.

The clockwork had to be wound by hand. A heavy weight hung in a tube running down the centre of the tower, and the keeper on watch cranked it back up every few hours through the night. If he fell asleep, the lens slowed and stopped, and the light lost its character: to a ship offshore it became another light, or no light at all. In the 1890s French engineers floated the rotating lens on a bath of mercury, so that a small push turned several tonnes of brass and glass. The lens could turn faster, and the flashes grew shorter and brighter.

Paris made most of the lenses. The workshops of Henry-Lepaute, Sautter and Barbier ground and polished the rings by hand and shipped them in numbered crates to coasts all over the world, with drawings for assembling them on the spot. Chance Brothers, near Birmingham, made glass for the British lights and later lenses of their own, including the largest of all, the hyperradiant lenses of the 1880s, with a focal distance of 1,330 millimetres. In the United States, the Lighthouse Board set up in 1852 replaced the old reflectors with French lenses within a decade.

## When the keepers left

A lens could last for more than a century. The glass did not wear out, and the brass needed only cleaning. What changed around the lenses was the lamp. Oil gave way to paraffin vapour burners with incandescent mantles at the end of the nineteenth century, then to electric lamps, at first in the towers that had a supply nearby and later almost everywhere. The light grew brighter, but the lens around it stayed the same, because it already put nearly all of that light on the horizon.

For the keepers, the routine hardly changed in a hundred years. At sunset they lit the lamp and wound the clockwork; through the night they took turns on watch in the service room under the lantern, writing the weather and the state of the light in the station's log every few hours; at dawn they put the light out, drew linen curtains round the lens so that the sun would not set fire to anything through it, and began cleaning. Brass was polished, glass wiped with spirit and a soft cloth, never anything harder.

Electricity also made the keepers unnecessary. A lamp that did not need its wick trimmed and a motor that did not need winding could be watched from a distance. Through the second half of the twentieth century, lighthouse after lighthouse was automated, its family moved ashore and its door locked. The last keepers of the British lights left North Foreland, in Kent, in November 1998. In France the keepers of the offshore towers, the ones the service called hells, were among the last to go.

The lenses went next. A modern beacon of light-emitting diodes weighs a few kilograms, uses less power than a kettle and needs no mercury, which has its own risks for those who climb the tower. Some lenses were taken down and sold, some broke in storage, and some were cut into pieces for collectors. The ones that survive have mostly gone to museums, where they are lit by a bulb inside and look like what they are, a machine for sending light a long way.

A few still work. At Cordouan a first-order lens still turns in the lantern, and the reef that holds the tower became a World Heritage site in 2021. Visitors who make the crossing at low tide can climb the stairs past the chapel to the lantern gallery and stand beside the rings of glass. The light still shows its own rhythm to the pilots of the Gironde, white over the channel and coloured over the shoals, as the ships coming in from Biscay have read it for two hundred years.

:::paragraphs{style="colophon"}
Set in Public Sans, Libre Caslon Display and Libre Caslon Text (SIL Open Font License) · Text: Postext Cookbook, CC BY 4.0 · Painting made for this issue with a diffusion model · The magazine and the writer are fictional.
:::
`; // content.<lang>.md, inlined by the Cookbook

// ─── 3 · Fonts ──────────────────────────────────────────────────────────────
const FONTS = { // text, display and label faces, loaded before the build
  'Public Sans': ['400', '400i', '500', '600', '700'], 'Libre Caslon Display': ['400'],
  'Libre Caslon Text': ['400', '400i'] };

// ─── 4 · Build & show ───────────────────────────────────────────────────────
await loadImage('lens-1400.jpg', asset('lens-1400.jpg'));
// #region builds: the same text and design, without and then with fillShortColumns
const withFill = (fill) => ({ ...config, headings: { ...config.headings,
  balancing: { ...balancing, fillShortColumns: fill } } }); // a copy: config keeps true
const build = (fill) => buildDocumentWithFonts({ markdown, resources }, withFill(fill),
  kitFonts(FONTS));
const docs = { off: await build(false), on: await build(true) };
// #endregion
const title = t({ en: 'Filling the column the widow rule left short',
  es: 'La columna que la regla de viudas dejaba corta' });
// Two buttons put either build on the desk.
const LABELS = { off: 'fillShortColumns: false', on: 'fillShortColumns: true' };
const switches = Object.keys(docs).map((key) => Object.assign(document.createElement('button'),
  { type: 'button', value: key, textContent: LABELS[key] }));
const show = (key) => {
  showPages(docs[key], { title });
  for (const b of switches) b.ariaPressed = String(b.value === key);
};
for (const b of switches) b.addEventListener('click', () => show(b.value));
show('on');
document.getElementById('pt-actions').prepend(...switches);
document.head.insertAdjacentHTML('beforeend',
  '<style>#pt-actions [aria-pressed=true] { text-decoration: underline }</style>');

// ─── 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 and asks the kit
// for Fontsource's files, the ones the PDF embeds too.

/** buildDocumentWithFonts' options: the resolver, and FONTS' faces loaded
 *  with the config's. 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 files with their ranges: latin, then latin-ext and greek when
 *  `text` needs them (kitSubsetsFor). Null for a face Fontsource lacks. */
async function fontsourceResolver(family, weight, style, { text = '' } = {}) {
  const id = fontsourceId(family);
  const meta = await fontsourceMeta(family);
  if (meta && !(await fontsourceWeights(family, style)).includes(weight)) 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],
  }));
}

/** Each file's unicode-range (a function: the kit runs after the pen's code). */
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: loads `faces` (as FONTS) for the
 *  sample `text`; `optional` skips a face Fontsource lacks. Files are added in
 *  the order asked, the last winning a shared character. Resolves to the count. */
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 && (await fontsourceWeights(family, style)).includes(weight))) 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 face (fonts.check() says yes to any family). */
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, id = fontsourceId(family)) {
  return (fontsourceMeta.c ??= {})[id] ??= fetch(`https://api.fontsource.org/v1/fonts/${id}`)
    .then((res) => (res.ok ? res.json() : null), () => null);
}

/** The weights the package ships in `style`: the API pairs none (no Libre
 *  Caslon 700 italic file), so italics are read from latin-italic.css. */
function fontsourceWeights(family, style, id = fontsourceId(family)) {
  return (fontsourceWeights.c ??= {})[id + style] ??= fontsourceMeta(family).then(async (m) => {
    if (!m?.styles.includes(style)) return m ? [] : [400, 700];
    const css = style === 'italic' ? await fetch(`${await fontsourceAt(id)}latin-italic.css`).then((r) => (r.ok ? r.text() : ''), () => '') : '';
    const w = [...css.matchAll(/weight: (\d+)/g)].map((x) => +x[1]);
    return w.length ? w : m.weights;
  });
}

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

### Die Zwischentitel zuerst ausgleichen lassen

In der Standardreihenfolge nimmt eine Überschrift bis zu zwei Rasterzeilen, bevor ein Absatz verlängert wird, und die Zwischentitel der Folgeseite öffnen sich wieder.

```diff
-  maxLinesBeforeLoose: 0,
+  maxLinesBeforeLoose: 2,
```

### Den Text im Blocksatz setzen

Ein Absatz im Blocksatz konnte auch ohne die Einstellung eine Zeile lockerer laufen, innerhalb von `maxWordSpacing`. Mit `fillShortColumns` darf er auch eine Zeile enger laufen und wird nie lockerer gesetzt als seine lockerste Zeile.

```diff
-  textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, // a grid line between
+  textAlign: 'justify', firstLineIndent: pt(0), paragraphSpacing: true, // a grid line between
```

## Häufige Fehler

- Die Einstellung schließt eine Lücke von einer Zeile. Eine Spalte, der zwei fehlen, etwa eine, die nach dem Abstand zwischen zwei Absätzen nur noch Platz für eine Zeile des nächsten hat, kann kurz bleiben: Gleichen Sie den Text um einen Satz aus.
- Ein Absatz, dessen neuer Umbruch auf einer zu kurzen Ausgangszeile enden würde, wird nicht verwendet; eine Spalte mit nur kurzen Absätzen kann deshalb kurz bleiben.

## Nachweise

- Rezept: Ignacio Ferro ([@drnachio](https://github.com/drnachio))
- Bilder: A lit Fresnel lens in a lighthouse lantern at dusk: Generated With Diffusion Models, Original
- Schrift: Public Sans (OFL-1.1), Libre Caslon Display (OFL-1.1), Libre Caslon Text (OFL-1.1)
- Code: MIT · Beispielinhalt: CC-BY-4.0

## Verwandte Rezepte

- [Nº 116 · Fotos, die wachsen, um eine kurze Spalte zu füllen](https://postext.dev/de/cookbook/photos-fill-short-columns.md): Jedes Foto markiert einen Sicherheitsbereich; der Spaltenausgleich darf außerhalb beschneiden und das Foto höher setzen, sodass eine kurze Spalte bündig endet. · Stufe 2 (Mittel) · Magazine und Zines
- [Nº 037 · Jahresbericht mit bündig endenden Spalten](https://postext.dev/de/cookbook/annual-report-flush-columns.md): Der Jahresbericht einer Energiegenossenschaft in zwei Spalten im Blocksatz, die auf derselben Rasterlinie enden, mit Kennzahlenkasten und bündiger Schlussseite. · Stufe 3 (Fortgeschritten) · Berichte
- [Nº 004 · Magazinreportage: vom Fotoauftakt bis zum Schlusszeichen](https://postext.dev/de/cookbook/magazine-feature-opener.md): Ein Auftakt mit advancedDesign setzt ein randabfallendes Foto und Dachzeile, Titel, Vorspann und Autorenzeile der Überschrift; dann zwei Kästen, ein Chip. · Stufe 3 (Fortgeschritten) · Magazine und Zines
