Detecting a column split is not the same as locating it
TLDR
Layout detectors frequently identify column split existence accurately while placing split coordinates off by $15\text{ to }32\text{px}$ due to ragged-right text clustering. Loosening spatial containment tolerances to absorb coordinate errors causes centered titles and email headers to be absorbed into columns. A bounded valley scan post-pass snaps detected split coordinates to true whitespace channels using coverage histograms without altering split counts.| Detector Step | Technical Responsibility | Coordinate Accuracy | Output Result |
|---|---|---|---|
| Pass 1: Split Detection | Confirms 2-column existence | Off by $32\text{px}$ ($X=566\text{px}$) | Body lines misclassified as full-width |
| Pass 2: Valley Scan Relocation | Snaps $X$ to local whitespace valley | Exact ($X=598\text{px}$) | Clean 2-column body reflow |
Technical problem analysis
In a 16-page two-column document, column detection reported a split at $X = 566\text{px}$. The actual physical gutter center was $X = 598\text{px}$.
Because $566\text{px}$ fell inside the left column text region, 13 body lines spanning $144\text{px}$ to $581\text{px}$ straddled the boundary by $15\text{px}$, causing them to fall out of their column and render as full-width blocks across the page center.
Technical solution: bounded valley scan post-pass
Perform a localized histogram scan bounded to 3 body-font heights around the initial split coordinate:
export function snapSplitToWhitespaceValley(detectedSplitX, textItems, bodyFontHeightPx = 12) {
const scanWindowPx = bodyFontHeightPx * 3; // Bounded search window radius
const lo = detectedSplitX - scanWindowPx;
const hi = detectedSplitX + scanWindowPx;
// Compute coverage histogram stepping 2px across search window const coverageHistogram = computeCoverageHistogram(textItems, lo, hi, 2); const minCoverage = Math.min(...coverageHistogram.counts);
// Valley threshold tolerates full-page footer lines crossing the center const valleyThreshold = minCoverage + Math.max(2, Math.ceil(minCoverage * 0.5)); const valleyRun = findWidestRunBelowThreshold(coverageHistogram, valleyThreshold);
// Safety Guards: Ignore runs touching window edges or narrower than 0.75x font size if (valleyRun.widthPx < bodyFontHeightPx * 0.75 || valleyRun.touchesWindowEdge) { return detectedSplitX; // Fail-safe: keep original coordinate }
return valleyRun.centerX; }
Verification results
- Before Valley Scan: Split coordinate $X = 566\text{px} \rightarrow 13$ intro paragraph lines rendered as full-width breaks.
- After Valley Scan: Split snapped to $X = 598.5\text{px} \rightarrow$ All body lines correctly assigned to column 1.
Rule of thumb: Separate column existence detection from coordinate localization, using bounded valley scans to snap split coordinates to physical whitespace channels.