Engineering Journal
Pdf Processor
Pdf Processor

Detecting a column split is not the same as locating it

2026-07-10

TLDR

A column-split detector can correctly answer "this page has two columns" and still report a coordinate that sits inside one of the columns. Every consumer that compares item positions against that coordinate then misclassifies ordinary body text. The fix that worked for us: keep the detector, add a bounded second pass that snaps the coordinate to the measured whitespace valley.

The problem class

Any document parser that reconstructs multi-column layout answers two questions. Does this page have a column split, and where is it. Most code treats these as one question with one answer.

They fail independently. On a two-column academic paper, our detector said "split" on all 16 pages, which was correct. The coordinate it reported was 566. The real gutter center was 598.

The naive approach

The obvious reaction to items overhanging a split is loosening the fit tolerance. If body lines stick out past the boundary by 15px, allow 20px of slop.

This seems reasonable because tolerances are how geometric code normally absorbs noise. And it works, for exactly one document.

Why it breaks

Tolerances patch the symptom of a wrong coordinate, and the tolerance you need grows until it starts eating real content. Centered author rows and email lines legitimately cross the gutter; a loose tolerance absorbs them into a column, where they read as garbage.

The underlying failure is that detection evidence and location evidence are different things. Our detector located the split relative to features that cluster well, like left edges of right-column lines. Ragged-right text put that cluster 32px away from the actual whitespace channel.

The nasty property of this bug is that no per-page summary catches it. Split count per page: correct. Which pages are two-column: correct. Stream and lattice table counts: unchanged. Every metric we had tracked the binary answer, and the binary answer was right. The coordinate only shows up when you render the reconstruction next to the source page and look. I measured the real channel directly once I knew to look for it:

p1: left col ends ~581, right col starts ~615, gutter center ~598
p2: left col ends ~581, right col starts ~615, gutter center ~598
p3: left col ends ~581, right col starts ~615, gutter center ~598

With the split at 566, a body line spanning 144 to 581 "straddles" it by 15px, more than any sane tolerance. Thirteen consecutive lines of an intro paragraph fell out of their column and rendered as full-width rows in the middle of a two-column page.

The better model

Separate the two questions. The detector keeps answering "is there a split." A relocation pass owns "where exactly":

function snapSplitToGutter(splitX, items, bodyFontPx) {
    const win = bodyFontPx * 3;
    const lo = splitX - win, hi = splitX + win;
    // how many items cover each x position in the window
    const counts = coverageHistogram(items, lo, hi, 2);
    const minC = Math.min(...counts);
    // the gutter is a valley, not an empty channel: a footer or
    // title crossing the page center adds a constant near the minimum
    const thr = minC + Math.max(2, Math.ceil(minC * 0.5));
    const run = widestRunBelow(counts, thr);
    if (run.widthPx < bodyFontPx * 0.75) return splitX;
    if (run.touchesWindowEdge) return splitX;
    return run.centerX;
}

Two details carry the design. First, the scan finds a valley, not emptiness. My first version searched for a zero-coverage gap and found nothing, because the page's footer line ran straight through the gutter. Items that span the whole window add a constant everywhere, so they cancel out of the argmin.

Second, the pass is bounded to a window of three body-font-heights around the detected split. It can correct a coordinate. It cannot invent a different layout, which caps the blast radius of any bug inside it.

Evidence

The raw coverage counts from the failing page, stepping 2px across the window around x=566. The left flank is column text, the valley is the gutter, the right flank is the other column:

counts around 581-615:  18,19,19,19,4,4,5,5,5,6,6,6,6,5,5,5,5,5,5,6,6,43

A pure zero-gap search returns a 4px-wide run here (too narrow to trust). The valley threshold finds the run from 582 to 616 and snaps the split to 599.

After the change, all 16 pages converged on 598 to 599, and the straddler set shrank to items that genuinely cross the gutter: the paper title, the author row, one email line. The 13-line intro paragraph moved back into its column.

Regression runs against four other locked documents (a financial report, a 76-page furnace manual, and two more papers) showed stream and lattice table counts byte-identical, with splits elsewhere moving 1 to 6px. That last number is the interesting one. On documents where detection was already close, the snap still moved the coordinate a few pixels toward the measured channel, which means the drift exists everywhere and only becomes visible when it crosses a tolerance boundary.

Tradeoffs

You now have two sources of truth for the split, and they must run in a fixed order. I accepted that because the alternative, making the detector itself coordinate-accurate, means teaching every scoring heuristic about ragged-right zones, drop caps, and hanging indents.

The slack threshold (minimum plus half) is a heuristic. A page with genuinely heavy center coverage keeps its original coordinate, which is the safe failure: no worse than before the pass existed.

The one thing to watch for

Reject valley runs that touch the scan-window edge. A run that ends at the window boundary is unbounded evidence; you have no idea how far it extends or where its true center is. My first draft snapped to the center of a clipped run and moved the split the wrong way on one page. Interior runs only.

Read this post in the full Engineering Journal →