Why Zero-Coverage Gap Detection Fails for Multi-Column PDFs (And What to Do Instead)
TLDR
Naive column gutter detection builds a 1D pixel coverage array across page width and looks for zero-coverage gaps. This fails on real engineering documents because full-width elements (warning banners, spanning headers) fill the gutter zone. The solution requires filtering out wide Y-bands ($>55\%$ page width), counting distinct narrow Y-band presence per pixel rather than item counts, and thresholding band counts at $20\%$ of total narrow bands.| Gutter Detection Model | Coverage Metric | Spanning Line Vulnerability | Real-World Layout Accuracy |
|---|---|---|---|
| Pixel Item Coverage | Sum of items spanning coordinate X | Fails completely (Gutter fills with text) | Low (Fails on real manuals) |
| Narrow Band Counting | Distinct narrow Y-bands covering X | Immune (Wide bands filtered out) | High (100% split detection) |
Problem statement: the spanning line coverage trap
In PDF layout extraction, finding the vertical empty space (gutter) between columns is essential for splitting text into left and right columns prior to HTML assembly.
Naive implementations build a pixel coverage array:
// NAIVE IMPLEMENTATION: Counting item coverage per pixel
const coverage = new Float32Array(pageWidth);
textItems.forEach(item => {
const x1 = Math.floor(item.vx);
const x2 = Math.ceil(item.vx + item.vWidth);
for (let x = x1; x <= x2; x++) coverage[x]++;
});
// Look for zero-coverage gap...
On real documents, this fails because:
- Spanning Header Lines: Full-width headings or callout boxes span across column gutters, filling
coverage[x]above zero. - Dense vs. Sparse Disparity: A row with 8 items in the left column overpowers a row with 1 item in the right column, distorting valley detection.
Technical architecture: distinct narrow band counting
Step 1: filter wide Y-bands
Group text items into Y-bands (rows). Exclude bands whose horizontal span exceeds $55\%$ of viewport width:const WIDE_BAND_RATIO = 0.55;
const narrowBands = [];
const fullWidthIndices = new Set();
bands.forEach(band => { const minX = Math.min(...band.items.map(i => i.vx)); const maxX = Math.max(...band.items.map(i => i.vx + i.vWidth));
if (maxX - minX > pageWidth * WIDE_BAND_RATIO) { // Mark items as full-width zone dividers (columnIndex = -1) band.items.forEach(item => fullWidthIndices.add(item.idx)); } else { narrowBands.push(band); } });
Step 2: per-band binary coverage accumulation
Count distinct narrow Y-bands that cover each X pixel. A band with 10 items contributes the exact same vote ($1$) as a band with 1 item:export function calculateBandCoverage(narrowBands, pageWidth) {
const bandCounts = new Float32Array(pageWidth);
narrowBands.forEach(band => { const bandSeenMask = new Uint8Array(pageWidth);
// Mark pixels covered by this band band.items.forEach(item => { const x1 = Math.max(0, Math.floor(item.vx)); const x2 = Math.min(pageWidth - 1, Math.ceil(item.vx + item.vWidth)); for (let x = x1; x <= x2; x++) bandSeenMask[x] = 1; });
// Accumulate binary band presence for (let x = 0; x < pageWidth; x++) { bandCounts[x] += bandSeenMask[x]; } });
return bandCounts; }
Step 3: 20% band threshold gutter discovery
Locate continuous X coordinates wherebandCounts[x] < narrowBands.length * 0.20:
export function findColumnGutterMidpoint(bandCounts, narrowBandCount, minGutterWidthPx) {
const gutterThreshold = narrowBandCount * 0.20;
let bestGutter = null;
let currentStart = null;
for (let x = 0; x < bandCounts.length; x++) { if (bandCounts[x] < gutterThreshold) { if (currentStart === null) currentStart = x; } else { if (currentStart !== null) { const width = x - currentStart; if (width >= minGutterWidthPx) { if (!bestGutter || width > bestGutter.width) { bestGutter = { start: currentStart, end: x, width, midpoint: (currentStart + x) / 2 }; } } currentStart = null; } } }
return bestGutter?.midpoint || null; }
Rule of thumb: Exclude wide Y-bands ($>55\%$ width) and evaluate column gutters by thresholding distinct narrow Y-band presence at $20\%$ of narrow band counts.