Skip to main content
Recipe number 177

Cookbook · Chapter 1 · Page & grid

Filling the column the widow rule left short

Ragged magazine columns end on the grid: fillShortColumns sets a paragraph of a short column a line longer or shorter instead of leaving a hole at its foot.

On this page
Output
Canvas
Postext
Tested with Postext 1.65.0
Needs ≥ 1.65.0
Licence
Updated 31 Oct 2026
Code MIT · Text CC BY 4.0

p. 1 · 4 of 6

  • Trim 225 × 297 mm
  • 2 columns, 7 mm gutter
  • Public Sans 9.4/13.2
  • Libre Caslon Display
  • Libre Caslon Text
  • 6 pages
  • Level
  • Postext 1.65.0
  • Laid out in 149 ms
  • 162 lines of code

In short

When a column of text stops one line above the bottom of the page because a paragraph cannot be split there, this setting rewraps one paragraph of that column so the column reaches the bottom.

What you'll build

Three pages of Littoral, a coastal magazine: a long read on the Fresnel lens, opened by a painting of a lit lens in its lantern. The text runs in two ragged columns of Public Sans with a blank grid line between paragraphs, and the widow and orphan rules keep at least two lines of a paragraph on each side of a break. The pen sets the feature twice. Without the setting, on the opener the left column ends a line above the right one: the next paragraph cannot start with a single line at the foot. With it, on the same page, the first paragraph wraps to one more line and both columns end on the last grid line. Overleaf, the setting also takes the extra air that balancing had put above two crossheads.

This recipe answers

  • Why does a column end a line or two short of the foot, and how do I fill it without breaking the widow and orphan rules?
  • How do I get flush column bottoms and a balanced last page (vertical justification)?
  • How do I avoid widows, orphans and one-word last lines (runts), and keep a heading with its text?

The short answer

script.js · lines 37–55in full code
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,
};

Ragged columns that end on the grid: fillShortColumns

Ingredients

Type
Public Sans, Libre Caslon Display, Libre Caslon Text (SIL OFL 1.1)
Assets
  • lens-1400.jpg
  • A lit Fresnel lens in a lighthouse lantern at dusk (Generated With Diffusion Models, original)

Method

#1 · Give a ragged column a lever of its own

script.js · lines 37–55in full code
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,
};

Column balancing fills a short column by adding grid lines above a heading, after a list, under a box or a figure, and as a last resort by setting a justified paragraph a line looser. A ragged column of running text has none of these between its crossheads, so the line the widow and orphan rules hold back stays empty. fillShortColumns lets a ragged paragraph of that column wrap a line longer within its own word spacing; when no paragraph can, it sets one a line shorter so that the paragraph after the break moves up and fills the line. The engine keeps the change only when fewer columns end short, so turning it on never makes a page worse. maxLinesBeforeLoose: 0 tries the paragraphs before the headings, so every crosshead keeps one grid line of air above it.

#2 · Set the feature twice and compare

script.js · lines 204–208in full code
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) };

Both documents come from one config and one Markdown string. The second build passes the config as written; the first passes a copy with the setting off, so the bundle that opens in the Sandbox carries fillShortColumns: true. The two buttons over the desk switch between the builds. Only the pages that flow on are filled to the foot: page 3 closes the feature, and its columns are cut level with each other, as they are in both builds.

#3 · Open on the painting

script.js · lines 59–85in full code
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) },
  ] },
};

The painting is an image element anchored to the bleed, 150 mm deep, so it reaches the trim on three sides, which no float does. The kicker, headline, standfirst and byline come from the attributes of the # line. The byline ends in a 6 mm padding that counts toward the opener's height, so the columns start on the first grid line below it.

#4 · Running heads off the opener

script.js · lines 89–106in full code
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

The verso names the magazine and the issue from the frontmatter, the recto the feature, and pages: 'body' keeps both off the opener, which has only a folio at its foot. The label sits 0.35 mm lower than the folio, so the two sizes share a baseline. The hairline under them is 197 mm long, the width of the text block.

The whole recipe

Sandbox
// ═══ 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 = {

The composed script.js runs as it is: paste it into any page’s module script, or open the recipe on CodePen. Recipe folder on GitHub ↗ (opens in a new tab)

Variations

#Let the crossheads take the slack first

With the default order, a heading takes up to two grid lines before a paragraph is set longer, and the crossheads overleaf open up again.

-  maxLinesBeforeLoose: 0,
+  maxLinesBeforeLoose: 2,

#Justify the text

A justified paragraph can already run a line looser without the setting, within maxWordSpacing. With fillShortColumns it may also run a line tighter, and it is never set looser than its own loosest line.

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

Pitfalls

  • The setting closes a gap of one line. A column two lines short, such as one that ends after a paragraph space with room for only one line of the next paragraph, may stay short: copy-fit it by a sentence.
  • A paragraph whose new setting would end on a runt is not used, so a column can stay short when its only paragraphs are short ones.

Credits

Text
Original prose, CC BY 4.0
Images
  • A lit Fresnel lens in a lighthouse lantern at dusk · Generated With Diffusion Models · original
Fonts
Public Sans (SIL OFL 1.1) · Libre Caslon Display (SIL OFL 1.1) · Libre Caslon Text (SIL OFL 1.1)
Sandbox