Chapter 3.6 · Part II · The craft
Configuration: widows, orphans and balancing
The rules that keep lines and blocks together across a column break, and the levers that make every column end level
In short
This page covers how Postext decides where a column or a page ends. A single line of a paragraph should not be left alone at the top or the bottom of a column, and a heading should not be left without its text. These rules can leave a column shorter than the others. Postext then makes small changes, in a fixed order, so that every column ends at the same height. The page lists each rule and each change, and how to see which one was used.
#Orphans, widows, runts, and keep-together rules
See Hyphenation & Justification for the mechanics behind these demerits. This section is the reference for the bodyText keys that drive them.
Beyond hyphenation and spacing bounds, the body-text configuration exposes the soft rules that prevent structurally awkward paragraph breaks. All of these are fed into the Knuth-Plass line-breaking algorithm as demerits — they bias the layout toward clean breaks without ever forcing a hard rule. Set the *Penalty values to 0 to effectively disable any one of them.
| Property | Type | Default | Description |
|---|---|---|---|
avoidOrphans | boolean | true | Discourage a paragraph from ending with fewer than orphanMinLines lines at the top of the next column. On a character grid (cjk.grid) in the japan region the default is false. |
orphanMinLines | number | 2 | Minimum lines required at the top of the next column when a paragraph is split. Only active when avoidOrphans is true. |
orphanPenalty | number | 1000 | Demerit added when an orphan constraint is violated. Higher values bias the algorithm more strongly against orphans; 0 disables the penalty. |
avoidOrphansInLists | boolean | true | When true, list items also receive orphan protection (not just paragraphs). Only effective when avoidOrphans is true. |
avoidWidows | boolean | true | Discourage a paragraph from starting with fewer than widowMinLines lines at the bottom of the current column. On a character grid (cjk.grid) the default is false: the page holds the lines its grid counts. |
widowMinLines | number | 2 | Minimum lines required at the bottom of the current column when a paragraph is split. Only active when avoidWidows is true. |
widowPenalty | number | 1000 | Demerit added when a widow constraint is violated. 0 disables the penalty. |
avoidWidowsInLists | boolean | true | When true, list items also receive widow protection. Only effective when avoidWidows is true. |
shortListSplit | 'allow' | 'warn' | 'avoid' | 'allow' | A short list cut between two columns or pages: a list of up to shortListMaxItems items in the running text, such as one of three items set 1 + 2. avoidOrphansInLists and avoidWidowsInLists keep the lines of one item together; this rule keeps the items of one list together. 'allow' cuts such a list where the column ends, as any other. 'warn' lays it out the same and reports each list cut (a shortListSplit warning, see Warnings in the document). 'avoid' sets the list whole: when it does not fit under what the column holds it opens the next column, and the headings right above it and the colon line that introduces it (keepColonWithList) go with it. A list taller than an empty column is cut as any other, and reported. Lists inside a :::callout follow the box's own cut rules, and the entries of :::toc and :::index are not lists. Since postext 1.28. |
shortListMaxItems | number | 3 | The most items a list has to count as short for shortListSplit, the items of its nested lists included. A whole number, at least 1. Since postext 1.28. |
avoidRunts | boolean | true | Discourage paragraphs from ending with a very short last line — a runt, e.g. a single short word alone. Ragged text too, with optimalRagged. A Chinese, Japanese or Korean paragraph does not end on a line holding one character, alone or with its closing marks (孤字): the line above gives it its last character when that line can still be justified within the tracking cap. A line set left, right or centred gives the character up too and stands one character short; with cjk.grid.fullLines (manuscript paper) it keeps every square, and the paragraph may end on one character. |
runtMinCharacters | number | 20 | Threshold for the last line of a paragraph, counted in word spaces, not letters: the line is a runt when it is narrower than runtMinCharacters × normalSpaceWidth pixels. A word space is a quarter to a third of an em in most text faces, about half a lowercase letter, so the default 20 catches last lines under 4 to 7 em — roughly 8 to 12 letters. To catch the last lines under about N letters, set it near 2 × N. |
runtPenalty | number | 1000 | Equivalent-badness injected into the Knuth–Plass squared demerit formula (same scale as line badness, which saturates at 10000). 0 disables the penalty. |
gradedRuntPenalty | boolean | false | Scale the runt penalty by how short the last line falls: a last line of width w under the threshold t costs runtPenalty × (1 − w / t) instead of the whole penalty. A two-word ending then costs less than a one-word one, and the breaker pulls a word down when a line above can spare it ('…sallies of' / 'our minds.' rather than '…sallies of our' / 'minds.'). Off by default: every runt costs the same, and the breaker keeps the tighter lines above. |
avoidRuntsInLists | boolean | true | When true, list items also receive the runt penalty. Only effective when avoidRunts is true. |
tightenRunts | boolean | true | When the penalty could not avoid a runt, set the paragraph one line shorter instead: the word spaces tighten (never past minWordSpacing) and, if that alone does not carry the line, a little negative tracking joins in. A shorter setting is refused, and the runt stays, when it would stretch a justified line past maxWordSpacing or, when the paragraph already has a looser justified line, past that line, or when it would set more lines ragged (past 3× the normal space) than the paragraph had. The word spaces of ragged text keep their width, so there only the tracking takes part. Needs optimalLineBreaking and avoidRunts (and optimalRagged for ragged text). |
maxRuntTracking | number | 10 | Most tracking a runt fix may take, in thousandths of an em (the InDesign unit: 10 = 0.01 em per character), applied as a tightening. 0 leaves the fix to word spacing alone. |
slackWeight | number | 10 | Weight applied to the squared "unused column space" cost. Higher values make the layout prefer filling columns tightly; 0 disables the slack pressure entirely. |
keepColonWithList | boolean | true | When a paragraph ends with a colon that directly introduces a list, keep the colon-bearing last line joined to the list: if placing the paragraph would leave no room for the first list item to start in the same column/page, the last line (or the whole paragraph, if it is a single line) is moved to the next column together with the list. How much room counts as enough is colonListRoom. When this rule would push the whole paragraph and a run of headings immediately precedes it in the column, those headings are pulled forward too so headings.keepWithNext keeps holding. |
colonListRoom | 'item' | 'line' | 'item' | The room keepColonWithList asks for under the colon line. 'item': what the orphan and widow rules for lists would leave of the first item at the foot of the column, a line when it may split there, all of it when they keep it whole (a two-line item, say). 'line': one line, as up to postext 1.4; a first item those rules keep whole then goes on to the next column alone and leaves the colon line at the foot. A configuration stored before configVersion 6, in a book that introduces a list with a colon, reads with 'line' (see Bundles written by postext 1.4 or earlier). Any other value reads as 'item'. |
hyphenateAcrossColumns | boolean | 'withinPage' | true | Let a column or a page end on a hyphenated word (InDesign's Hyphenate Across Column). false breaks the paragraph that crosses the column break again, so that its last line in the column ends on a whole word; the word spaces of the lines above take up the difference, within maxWordSpacing and minWordSpacing. It is a preference: where no break within those limits avoids it, the hyphen stays. It tries every column break of a paragraph: the first, and the later ones that fall where a full column ends, in one re-break; and a later break that falls elsewhere (a widow kept, a band cut level) in another, from that column, which keeps the lines already set in the columns before at their breaks and breaks only the rest. 'withinPage' guards the lines that close a page and no other: a word may be divided between two columns of one page, and the last line of a page ends on a whole word wherever the limits allow it. It changes fewer lines than false, which matters in three or more columns, where two column ends in three stand inside the page; in a single column the two values give the same lines. A column closes its page when no later column of the page takes text: a side column kept for floats does not, nor does a column a table or a figure has filled. Box bodies are not affected. With optimalRagged, a ragged paragraph is broken again the same way: its word spaces keep their width, so only the ends of its lines move. Each re-break measures the paragraph once more, so a book with many column-end hyphens lays out a little slower. Needs optimalLineBreaking, and optimalRagged for ragged text. |
paragraphContainerSpacing | 'collapse' | 'add' | 'collapse' | The space under a :::paragraphs container that closes on a paragraph, between that paragraph and the block after it (see The :::paragraphs container). 'collapse': the larger of the style's spaceBetween and marginBottom and the paragraph spacing of the text around the container (a line with paragraphSpacing; in a box, the box's), merged with the space the next block keeps above itself, as between two paragraphs of running text: a heading under a bibliography sits its own marginTop below it, not that margin plus the entries' spacing. 'add': as up to postext 1.4, the style's space alone is set under the last line before the grid snap, the next block's own space above is added under it, and the paragraph spacing is left out, so the paragraph after the container could sit closer to it than to any other. A configuration stored before configVersion 8 that declares a paragraph style, in a book that holds such a container, reads with 'add' (see Bundles written by postext 1.4 or earlier). A negative marginBottom pulls the next block up either way. A paragraph style can set its own rule for its containers (containerSpacing, since postext 1.28). |
About runts. A runt is a paragraph whose last line is too short to feel like a proper line of text — typically one or two short words marooned at the end of a paragraph. Because the check is based on the pixel length of the line relative to the normal space width, runtMinCharacters adapts automatically to the current font size. A short word that is visually wider than runtMinCharacters × spaceWidth is fine; a word that is narrower than that (or truly alone) attracts the runt penalty. The threshold counts word spaces, which are about half as wide as letters: the default 20 is a last line of roughly 8 to 12 letters. Every runt costs the whole penalty, so among two endings under the threshold the breaker keeps the tighter lines above; gradedRuntPenalty prices each by its shortfall instead, and the longer ending wins. For the mathematically curious: at the default runtPenalty of 1000, avoiding a runt dominates any alternative break set requiring up to roughly r≈2.15 word-spacing stretch.
Soft, not hard. None of these rules can prevent a break — the engine will always produce a layout. They are demerits: the algorithm trades off badness, hyphenation cost, fitness-class smoothness, and these structural penalties in a single global optimisation and picks the break set with the lowest total cost. If you need a harder guarantee, raise the penalty; if a given document reads better with the penalty relaxed, lower it.
#Column balancing
Publishers expect every column to start at the top of the page and end flush with its bottom. Break rules (orphan/widow protection, headings kept with their text, unsplittable figures) naturally leave short columns — one or more empty baseline-grid lines at the bottom. When balancing is enabled, the engine does what a compositor would, applying its levers in editorial priority order:
- A box closing the column — a callout that ends a short column is pushed down by the exact room under its foot, so its bottom edge lands on the last grid slot of the page, level with the last line of the column beside it. It takes that room even when it is less than a line, as long as nothing moves into another column. By default it runs before every other lever and takes the whole gap, so a box that annotates the paragraph above it can end up several lines below it;
closingBox: 'last'lets the headings, the list ends and the other spacing levers take the whole lines first, and the box only what they leave, andclosingBox: 'off'never moves it. - Pictures with a safe area — a picture with a safe area (
Resource.safeArea, see Document format › Safe area) set inline in the short column, or floated at its head or its foot over that column alone, grows by whole grid lines, cropped within its safe area, until the column is full or the crop reaches the safe area. A taller picture leaves no hole on the page, so it runs before any space is added; a float at the head of a column on a page or band whose column heads stay level (see below) does not grow. Recorded asflexFigure. It has no setting of its own: only a picture whose resource marks a safe area can grow. - Headings — whole grid lines are added to the top margin of the headings inside the short column. When several lines are needed and the column holds several headings, the lines are distributed among them, always giving the largest share to the most important heading (an
h2receives more than anh3). Headings sitting at the very top of a column never receive extra space, so columns keep starting at the page top — except a heading right under a figure or table that heads its column on a page that flows on: the room goes above that heading, under the figure. A heading takes at mostmaxLinesBeforeLooselines this way (two by default) before the levers below are tried, and its further lines, up tomaxLinesPerHeading, only when they have nothing left to give. - List ends — when the headings cannot absorb the whole gap, a grid line is added where a list/enumeration ends (space after a list reads naturally), capped per list end.
- Under formulas, boxes and figures — then one grid line goes under a display formula or a callout box the text resumes after (
stretchAfterDisplays; it reads as the element's own margin), and under a figure or table that heads the column (stretchAfterFloats, capped bymaxLinesAfterFloat). - Loose paragraphs — as a last resort, one paragraph of the column is re-broken one line longer (TeX's
\looseness=+1), choosing the longest paragraph so the extra word spacing dilutes invisibly. The loose solution is only accepted when every line stays belowbodyText.maxWordSpacing— type colour never exceeds the limit you already configured. RequiresbodyText.optimalLineBreaking.
On a character grid (cjk.grid, see Chinese layout › The character grid) every character stands in a cell one em wide and every line on the grid's line pitch, which is also the baseline grid. Balancing is off there by default, as in vertical text: a page set line by line on a grid, the way GB/T 9704 counts 22 lines of 28 characters, leaves a short column short. A configuration that sets enabled: true gets levers that keep the grid: headings, list ends, displays and figures take whole grid lines, so the text below them moves by whole rows, and gridLines: 'off' keeps those out for a standard that has no empty row to give. A Chinese or Japanese paragraph runs a line long only when no gap between two of its characters spreads past maxTracking, and takes no tracking on its letters: a line a character short spreads an em over its gaps, far past the default 0.01 em, so on a grid of whole cells the loose lever usually finds nothing. The box closing a column and the pictures with a safe area are off the grid by design and work as anywhere.
The last column of a page is only balanced when the page flows naturally into the next one — a chapter's closing page legitimately ends short. Such a page, and a closing band cut level by trailing, also keeps its column heads level: no line is added under a figure or table heading one of its columns (the after-float lever), and a heading or a box that opens a column under such a figure stays at its head, whatever stretchAfterFloats says, as does the heading set right under a span: 'page' opener in the opener's own column, so a column never starts lower than the one beside it just to line the feet up.
| Property | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Whether to balance column bottoms. Off by default in vertical text (layout.) and on a character grid (cjk.grid.enabled, since postext 1.25); true turns it on there too. |
maxLinesPerHeading | number | 4 | Maximum extra grid lines that may be added above a single heading. |
maxLinesBeforeLoose | number | 2 | Grid lines a heading may take before the levers after it are tried: the list ends, the formulas and boxes, the figures and the loose paragraphs. A heading gets its further lines, up to maxLinesPerHeading, only when those have nothing left to give, so a page with one heading does not open four lines above it while its paragraphs could run a line long. A value at or above maxLinesPerHeading gives the headings all their lines first, as up to postext 1.27; 0 tries the other levers before any line goes above a heading. Since postext 1.28; documents stored earlier keep the old order (pinLegacyLooseOrder). |
stretchAfterLists | boolean | true | Allow extra grid lines where a list ends, when headings cannot absorb the whole gap. |
maxLinesAfterList | number | 1 | Maximum extra grid lines after a single list end. |
stretchAfterDisplays | boolean | true | Allow one extra grid line under a display formula, and under a callout box, in the short column, after the list-end lever: the block after it moves down a line. Set it to false to leave the space under formulas and boxes as their own margins set it; the gap goes on to the levers after this one, and the column ends short if they do not close it. Since postext 1.28. |
stretchAfterFloats | boolean | true | Allow extra grid lines under a figure or table that heads the short column (a top float), after the list-end lever, so the text below it moves down instead of the column ending short. Never on a page that does not flow on (a chapter's closing page) nor in a closing band cut level by trailing: there the column heads stay level and the last column may end a line short. The first block under the figure may be the rest of a paragraph begun on the page before: it moves down all the same, by the line the break rules left free at the column's foot (a line the widow rule kept empty, or a paragraph space with no room for text after it). Set it to false to keep the text right under the figure. Under a float over several columns the columns move together or not at all, see stretchAfterWideFloats. |
maxLinesAfterFloat | number | 1 | Maximum extra grid lines under a single top float. |
stretchAfterWideFloats | 'flush' | 'always' | 'flush' | Under a figure or table that stands over two or more text columns. With 'flush' a column moves down under it only when every text column under the float takes the same lines, so their heads stay level, or when the lines end that column flush; a line that would lower one column's head and still leave it short is not added, and the gap goes on to the loose paragraphs. 'always' stretches such a column as under any float, as up to postext 1.27. Any other value reads as 'flush'. Since postext 1.28; documents stored earlier keep 'always' (pinLegacyWideFloatStretch). |
looseParagraphs | boolean | true | Last resort: re-break paragraphs of a short column one line looser (one extra line each), within bodyText.maxWordSpacing. |
maxLooseParagraphs | number | 2 | How many paragraphs of one short column may run a line long, longest first. |
trackParagraphs | boolean | true | When word spacing alone cannot gain the line, a loose paragraph may also take the smallest positive tracking (letter spacing) that does. |
maxTracking | number | 10 | Upper limit for that tracking, in thousandths of an em per character (10 = 0.01 em). |
trailing | boolean | true | Level the closing band of a chapter and of the document: when the flow ends before the page is full (at a chapter opener, a :::part, a chapter-closing placement: 'fixed' box or the end of the document) with its columns uneven, they are cut level — a band cap of ceil(Σ used / N / grid) lines, resolved after the levers above have settled the earlier pages — so a short bibliography ends at the same height in every column instead of filling the first column and leaving the last one half empty. The cut keeps the rules an uncut band keeps: when a block that cannot split across it (a paragraph tail the orphan and widow minimums keep whole, a keep-together box) would run past it — and lose its last lines, since the renderers clip a column to its box — or a heading would close a column while its text opens the next (with headings.keepWithNext, the default), the cut is taken a line lower, up to three times, and otherwise dropped. A block that cannot start in the lines the cut leaves under a figure heading a column (a paragraph needs its orphan minimum there) goes on to the next column of the band instead, as it would from an uncut column, and the figure stands alone in its column. Columns whose feet differ by one grid line or less are left as they are: a closing band whose last column ends a line short is the usual finish, and cutting it would only move a line. The cut goes to the band it was measured in, and so does the cut a page-span box asks for mid-page: up to postext 1.4, when the first text of the closing page had first been offered to a band with no room for it (a page a float takes whole, the thin band a page-span box leaves at the foot of the page before), the cut was spent on that band and the closing page kept every line in its first column. Columns of different widths (a one-and-a-half layout with text in both) are cut by area, each column's height weighted by its width, since a line of the narrow column holds less of the text. Only active when enabled is true. |
trailingStep | 'lines' | 'grid' | 'lines' | The unit of the level cut trailing takes (and of the cut beforeSpan takes behind a box that moves on). 'lines': a band whose text is set on a leading of its own (an index, a list of references, a container of small type) is cut, and retried, by whole lines of that leading: the height most lines of the band share, headings and boxes aside. Its columns then end within a line of each other, as a band of body text does. A band of body text, and one that mixes two leadings in equal parts, is cut on the baseline grid as before. So is a band whose lines share a height and stand a fraction of a line apart (references with a gap between entries, index groups on a hand-set margin): four lines in five of each column must be a whole number of lines from one another. 'grid': every cut is a whole number of baseline-grid lines, as up to postext 1.27, where a cut on the grid let one line more into a column of an off-grid band and a failed cut was lowered by more than one of its lines (columns up to three lines apart). Any other value reads as 'lines'. Since postext 1.28; documents stored earlier keep 'grid' (pinLegacyTrailingStep). |
beforeSpan | boolean | true | Level the band a page-span block leaves behind: when a span: 'page' callout does not fit under the current columns even after a level cut, and has to move to the next page or split (calloutStyles[].), the columns it interrupts are cut level — the same trailing cap a closing band gets — instead of the first filling the page and the last ending short. The page is left as an explicit break so the levers above do not stretch its last column back to the page bottom; the box, or the part of it that fits, then sits under the levelled columns. Only active when enabled is true. |
closingBox | 'first' | 'last' | 'off' | 'first' | When a box closing a short column takes the room under its foot (lever 1, recorded as trailingCallout). 'first': before any other lever, as up to postext 1.4 — the box takes the whole gap, even several lines, and the headings above it get none. 'last': after the heading, list-end, display and float levers, which take whole lines first; the box moves down with the text above it and then takes only what they left, usually a fraction of a line, so its foot still meets the last grid slot and it stays close to the text it annotates. 'off': never; the box keeps the room under its foot, though the other levers can still move it down by whole lines when they add space above it, and the fraction of a line left under it stays. On a closing page, where the box is the only lever that lines its foot up with the column beside it, 'off' leaves it where the flow put it. Any other value reads as 'first'. A box closing the only text column of its band (a one-column page that ends early, its next block opening the next page) is never moved: there is no column beside it to line up with (since postext 1.19). |
gridLines | 'allow' | 'off' | 'allow' | On a character grid: whether the levers that add whole grid lines (above a heading, where a list ends, under a display or a box, under a figure heading the column) may run. They keep every character in its cell; 'off' suits a standard that counts the lines of a page, and leaves the column short instead. No effect off the grid. Any other value reads as 'allow'. Since postext 1.25. |
#Which lever fired
The layout records every lever it applied, on the block it applied to: block.balancing in the VDT that buildDocument returns. A proof sheet, a test or a report can say why a column ends flush — and which columns no lever could close. Blocks the balancing left alone carry no balancing, and a document built with enabled: false has none.
On a character grid the document says why a column may end short: doc.gridBalancing is { off: true } when balancing is off because the page is on a grid and the configuration does not turn it on, and { gridLines: 'off' } when it runs without the whole-line levers; a column still short carries column.gridRefused, the levers the grid kept from it ('looseParagraph' when a paragraph could gain its line only by spreading its characters off their cells, the whole-line levers under gridLines: 'off'). Both are absent off the grid.
interface VDTBalancing {
levers: BalanceLever[]; // usually one: a paragraph after a list can take the list-end line and run a line long too
spaceAbove: number; // px the spacing levers added above the block (0 when only looseParagraph or flexFigure fired)
bodyGrowth?: number; // flexFigure: px the picture grew, cropped within its safe area
extraLines?: number; // looseParagraph: lines the paragraph gained
tracking?: number; // looseParagraph: the tracking that gained them, in thousandths of an em (0 = word spacing alone)
}
type BalanceLever = 'trailingCallout' | 'flexFigure' | 'heading' | 'listEnd' | 'afterDisplay' | 'afterFloat' | 'looseParagraph';| Lever | Recorded on | What it did |
|---|---|---|
trailingCallout | the callout's frame block | A box closing the column moved down by the exact room under its foot (spaceAbove; a fraction of a line is fine). |
flexFigure | the picture's block (inline) or float | A picture with a safe area set bodyGrowth px taller, by whole grid lines, cropped within the safe area. The resource block's bodySource is the part shown and bodyFlex.delta the same growth. |
heading | the heading | Whole grid lines above the heading, up to maxLinesPerHeading. |
listEnd | the first block after the list | A grid line where a list ends, up to maxLinesAfterList. |
afterDisplay | the block after the formula or box | A grid line under a display formula or a callout box. |
afterFloat | the column's first block | A grid line between a float band heading the column and its text, up to maxLinesAfterFloat. |
looseParagraph | the paragraph | The paragraph re-broken extraLines longer, with the smallest tracking that did it (tracking; block.letterSpacing is the same value in px). |
The level cuts are recorded on the columns they cut: column.bandCapped is true on every column cut level (the band a page-span box leaves, a closing band), and column.trailingCap also marks the closing band of a chapter or of the document (trailing).
import { buildDocument } from 'postext';
const doc = buildDocument(content, config);
for (const page of doc.pages) {
page.columns.forEach((column, i) => {
const levers: string[] = column.blocks.flatMap((block) => block.balancing?.levers ?? []);
if (column.trailingCap) levers.push('closing band cut level');
else if (column.bandCapped) levers.push('band cut level');
if (levers.length > 0) console.log(`page ${page.index + 1}, column ${i + 1}: ${levers.join(', ')}`);
});
}
// page 3, column 1: heading, heading
// page 3, column 2: listEnd, looseParagraph