Engineering Journal
Pdf Processor
Pdf Processor

Column boundaries are rendering artifacts: reconstructing text flow from spatial fragments

2026-07-04

TLDR: A paragraph that breaks at the bottom of a column continues at the top of the next one, but most extractors emit it as two paragraphs. Merging the fragments destroys spatial fidelity, so the right move is a link between them that semantic consumers can walk. The hard part is deciding when to link, and punctuation alone is not enough.

The problem class

Any document parser that works from positioned text runs into the same wall. The format stores where text was painted, not how it reads.

A two-column page is one text stream folded into two boxes. The fold point lands mid-sentence more often than not. If your output creates a new paragraph at every spatial gap, your semantic view lies about the text.

This matters for anything downstream that consumes the text as text: search indexing, summarization, screen readers, plain-text export. "The day is" and "brighter" as two paragraphs is corrupted data.

The naive approach

The obvious rule is punctuation: never start a new paragraph unless the previous one ended with a period. It captures the core intuition, and it fails in production within minutes.

Headings do not end with periods, so a trailing heading merges into the next column's body text. Captions end without punctuation. Abbreviations put periods mid-sentence. Numeric rows from a table your detector missed end in digits and chain into whatever follows them.

The punctuation signal is necessary. It is nowhere near sufficient.

The better model: a signal stack with vetoes

Treat each candidate seam (bottom of column N, top of column N+1) as a small classification problem. Require the punctuation signal plus geometric corroboration, and let hard vetoes run first.

// Blockers run before any merge signal is considered
if (LIST_MARKER_RE.test(nextFirstLine)) return null;
if (!isProse(prevLastLine) || !isProse(nextFirstLine)) return null;
if (digitHeavy(prevFragment) || digitHeavy(nextFragment)) return null;
if (/^[A-Z][a-z]+$/.test(nextFirstLine)) return null; // lone TitleCase = missed heading

The geometric signals are the interesting part, because the geometry is evidence the punctuation cannot fake:

Last line width. A finished paragraph almost always ends with a short last line. If the fragment's last line runs to the full width of the fragment, the paragraph was cut, not concluded. This is the strongest single signal and it costs one comparison against extents you already have.

First line indent. A true paragraph start usually carries a first-line indent. A continuation starts flush.

Font continuity. Same family, same size within half a point. Kills body-to-footnote merges without knowing what a footnote is.

let merge = false;
if (endsWithHyphen(prevLastLine)) {
  merge = true; // a trailing hyphen is conclusive on its own
} else if (!endsWithTerminalPunct(prevLastLine)) {
  merge = lastLineFullWidth || (notIndented && fontContinuity);
}

Note the asymmetry: the indent signal only counts alongside font continuity. A single-line fragment is trivially "not indented," so alone it proves nothing.

Hyphens deserve their own path

A fragment ending in a hyphen is the one case where the seam is self-announcing. But the join is not always the same operation.

const join = /^[a-z]/.test(nextFirstLine)
  ? 'dehyphenate'   // "bright-" + "er"  -> "brighter"
  : 'hyphen-keep';  // compound words, ranges: keep the hyphen, no space

Case of the continuation's first character decides it. Lowercase means the hyphen was a line-break artifact. Uppercase or digit means it was real.

Link, do not merge

Everything above decides whether two fragments are one paragraph. The tempting next step is to concatenate them. Do not.

The spatial view of the document, the one that reproduces the original design, needs each fragment exactly where the source put it. Concatenation destroys that. Instead, emit both fragments and record the relationship:

<div id="p3-f0" data-flow-next="p3-f1">The day is</div>
<div id="p3-f1" data-flow-prev="p3-f0" data-continuation
     data-flow-join="space">brighter than yesterday.</div>

Each consumer then chooses its truth. The design view renders as-is. The plain-text and Markdown exporters walk the chain head-first and join per mode:

for (const head of chainHeads(fragments)) {
  let cur = head;
  while (cur.next) {
    head.text = joinPer(cur.next.joinMode, head.text, cur.next.text);
    cur.next.absorbed = true;
    cur = cur.next;
  }
}

And the responsive collapse gets one CSS rule for free: when columns stack at small widths, reading order is restored physically, so fusing a continuation is just killing its top margin.

Evidence

On a dense two-column academic paper, this stack produced 75 links on one document, 25 of them dehyphenations, with zero list items or headings chained. The first version without the prose and digit vetoes chained table rows ("19 11 10 % 13 20 12") into prose. Every false positive traced to fragments a structural detector should have claimed, which is a useful audit signal in itself.

Tradeoffs

You carry link metadata through every surface, and every consumer must know to walk it. A wrong link corrupts the joined text locally, so the gates bias hard toward precision: a missed link just reproduces today's behavior, a false link creates new corruption.

The residual failures are upstream. When a table detector misses and its rows get fused into a text region, no seam heuristic can recover, because the fragment itself is already wrong. Fragment-level gates catch most of it, and the rest is a detector-coverage problem, not a linking problem.

Read this post in the full Engineering Journal →