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 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 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 Spalte ein oder zwei Zeilen vor dem Fuß, und wie fülle ich sie, ohne die Regeln für Hurenkinder und Schusterjungen zu brechen?
- 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
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
- Funktionen
- Kurze Spalten füllenSpaltenausgleichWitwen, Waisen und kurze AusgangszeilenOptimaler Zeilenumbruch (Knuth-Plass)Gestaltete AuftakteBilder in SeitengestaltungenÜberschriftenattributeKolumnentitel und SeitenzahlenKopfzeilen nach SeitenrolleSemantische FarbpaletteAbsatzformate
- Schrift
- Public Sans, Libre Caslon Display, Libre Caslon Text (SIL OFL 1.1)
- Dateien
lens-1400.jpg- A lit Fresnel lens in a lighthouse lantern at dusk (Generated With Diffusion Models, eigener Text)
Zubereitung
#1 · Der Flattersatzspalte einen eigenen Hebel geben
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
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
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
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
// ═══ 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 = {
Das zusammengesetzte script.js läuft unverändert: Fügen Sie es in das Modulskript einer beliebigen Seite ein, oder öffnen Sie das Rezept auf CodePen. Ordner des Rezepts auf GitHub ↗ (öffnet sich in einem neuen Tab)
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.
- 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.
- textAlign: 'left', firstLineIndent: pt(0), paragraphSpacing: true, // a grid line between
+ textAlign: 'justify', firstLineIndent: pt(0), paragraphSpacing: true, // a grid line betweenHä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
- Text
- Eigener Text, CC BY 4.0
- Bilder
- A lit Fresnel lens in a lighthouse lantern at dusk · Generated With Diffusion Models · eigener Text
- Schriften
- Public Sans (SIL OFL 1.1) · Libre Caslon Display (SIL OFL 1.1) · Libre Caslon Text (SIL OFL 1.1)
- Code
- MIT, wie Postext
Diesen Text bearbeiten ↗ (öffnet sich in einem neuen Tab)Ordner des Rezepts auf GitHub ↗ (öffnet sich in einem neuen Tab)


