How pdf-inspector Detects Columns in PDFs: A Deep Dive into the Layout Analysis Engine
pdf-inspector detects columns by analyzing the horizontal distribution of text items through a three-stage pipeline: histogram construction, valley detection for empty gutters, and validation with an XY-cut fallback for edge cases.
The open-source firecrawl/pdf-inspector project implements a sophisticated column detection system that converts multi-column PDFs—such as academic papers, newspapers, and magazines—into properly structured Markdown. This analysis examines the core algorithm in src/extractor/layout.rs, which combines statistical histogram analysis with geometric heuristics to identify column boundaries without relying on PDF metadata.
The Three-Stage Column Detection Pipeline
Stage 1: Building the Horizontal Occupancy Histogram
The algorithm begins by constructing a histogram representing where text appears horizontally across the page. In detect_columns (lines 61-71), each text item increments bins corresponding to its glyph positions, with a fixed bin width of 2 pt or a scaled width to ensure the histogram never exceeds 65,536 bins.
// Conceptual representation of histogram building
// Each text item contributes to bins it occupies
for item in text_items {
if item.width < page_width * 0.6 { // Skip very wide items (lines 163-167)
let start_bin = (item.x_min / bin_width) as usize;
let end_bin = (item.x_max / bin_width) as usize;
for bin in start_bin..=end_bin {
histogram[bin] += item.text_len; // Weighted by character count
}
}
}
Critical filtering rules applied during construction:
- Items wider than 60% of page width are excluded—they would otherwise fill any gutter (lines 163-167)
- Items outside the page bounding box or image placeholders are filtered out (lines 34-38)
- The bin count is clamped to
MAX_BINSto prevent memory allocation attacks (lines 140-144) - For unusually wide pages, detached clusters are trimmed to avoid histogram inflation (lines 82-135)
Stage 2: Finding Empty Valleys (Gutters)
With the histogram built, the algorithm identifies valleys—consecutive low-occupancy bins that indicate white space between columns. The valley detection loop (lines 94-104) marks bins as "empty" when their occupancy falls below NOISE_FRACTION × max_count, then groups consecutive empty bins into candidate valleys.
Valleys must satisfy geometric constraints enforced at lines 110-122:
| Constraint | Purpose |
|---|---|
Minimum width (MIN_GUTTER_WIDTH pts) |
Filters out narrow gaps from letter-spacing or justified text |
| Margin exclusion (5% of page edges) | Avoids false splits from page margins |
| Sufficient depth below surrounding peaks | Ensures genuine column separation, not minor dips |
Handling justified text masks: When text justification creates uniform histogram occupancy that hides the gutter, find_relative_valleys (lines 442-470) performs a relative analysis—identifying local minima that are at least 60% lower than surrounding peaks, even if their absolute value is above the noise threshold.
Stage 3: Validation and Fallback Mechanisms
Each candidate valley undergoes rigorous validation in validate_and_build_columns (lines 660-730):
// Validation criteria from the source
fn validate_and_build_columns(valley: &Valley, items: &[TextItem]) -> Option<ColumnRegion> {
// 1. Vertical overlap must exceed MIN_VERTICAL_SPAN_RATIO
// 2. Both sides need sufficient item counts
// 3. Smaller side cannot be a list-marker column (is_list_marker_column check)
// 4. Asymmetric layouts require ≥3 items on the smaller side
// ... returns ColumnRegion or rejects the valley
}
XY-cut fallback: When no valley passes validation—common with asymmetric sidebars or pages with sparse content—try_xy_cut_split (lines 145-210) computes the largest horizontal gap between any item's right edge and the next item's left edge. This geometric approach verifies that:
- Both resulting regions contain a reasonable number of items
- The regions share sufficient vertical overlap
- The gap width exceeds minimum thresholds
Calling the Column Detector
Command-Line Usage
The pdf2md CLI automatically runs column detection during PDF-to-Markdown conversion:
# Extract with automatic column detection
pdf2md research-paper.pdf --json > output.json
# Debug: view histogram and valley detection details
RUST_LOG=pdf_inspector::extractor::layout=debug cargo run --bin pdf2md -- paper.pdf
Debug output reveals the detector's internal decisions:
page 2: detect_columns: 317 items
page 2: valleys found … (center=145.3pt)
page 2: XY-cut split at x=312.7 (gap=22.1pt, left=12, right=14)
Library Integration (Rust)
While detect_columns is currently a private function, the public API through process_pdf_with_options provides full access:
use pdf_inspector::{process_pdf_with_options, ProcessOptions};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let opts = ProcessOptions::default();
let result = process_pdf_with_options("multi-column.pdf", &opts)?;
// Column detection runs automatically; results drive line-to-column assignment
println!("Extracted {} pages with detected column structure", result.pages.len());
Ok(())
}
The ProcessOptions struct allows customization of behavior, though column detection parameters (histogram bins, noise fractions, gutter widths) are currently compile-time constants optimized for general document layouts.
Architecture and Key Source Files
| File | Role in Column Detection |
|---|---|
src/extractor/layout.rs |
Core implementation: histogram building, valley detection, validation, XY-cut fallback |
src/extractor/mod.rs |
Orchestrates per-page extraction; invokes detect_columns for each page |
src/types.rs |
Defines TextItem (input) and ColumnRegion (output) data structures |
src/text_utils.rs |
Geometry utilities: effective_width, bounding box calculations |
src/bin/pdf2md.rs |
CLI entry point triggering the full pipeline |
The ColumnRegion structs produced by this system drive downstream processing: line-to-column assignment groups text items into lines within each column, and spanning-line detection identifies headers or captions that cross column boundaries.
Handling Edge Cases and Document Variants
The pdf-inspector column detector is engineered for robustness across diverse document types:
- Two-column academic papers: Standard histogram valley detection succeeds when gutters exceed ~12pt
- Newspaper layouts with 3+ columns: Multiple valleys are validated independently; overlapping or nested columns are rejected
- Mixed prose and tables: Wide table elements are filtered from histograms but processed separately to avoid column misdetection
- Sidebars and marginalia: Validation filters out narrow content regions that lack vertical continuity with main text
- Scanned/image-based PDFs: Pre-OCR image placeholders are excluded from histogram construction
When all strategies fail to find a valid column split, the page defaults to a single ColumnRegion spanning the full content width—a conservative fallback that preserves content integrity over structural accuracy.
Summary
- pdf-inspector detects columns through histogram analysis of horizontal text distribution, implemented in
src/extractor/layout.rs - Three complementary strategies work in sequence: occupancy histogram construction, absolute and relative valley detection for gutters, and geometric XY-cut fallback
- Validation enforces geometric constraints: minimum gutter widths, vertical overlap requirements, and filtering of list-marker false positives
- Production-ready for diverse documents: The system handles academic papers, newspapers, sidebars, and mixed layouts through adaptive parameter selection and conservative fallbacks
- Accessible via CLI and Rust API:
pdf2mdprovides zero-config usage, whileprocess_pdf_with_optionsenables programmatic integration
Frequently Asked Questions
What is the minimum gutter width pdf-inspector can detect?
The MIN_GUTTER_WIDTH constant sets the floor—typically around 12 points (4.2mm), though this scales with document dimensions. Narrower gaps, such as those in tightly-set magazines, may require the relative valley fallback which detects local minima rather than absolute empty space.
Why does pdf-inspector ignore very wide text items?
Items exceeding 60% of page width—usually headlines, table rows, or image captions—are excluded from histogram construction at lines 163-167. Without this filter, a spanning headline would populate all histogram bins and mask the true column gutters beneath it. These items are processed separately in the spanning-line detection phase.
How does pdf-inspector handle single-column documents?
When no valid valley passes geometric validation and the XY-cut finds no substantial gaps, detect_columns returns a single ColumnRegion spanning from x_min to x_max of the page content. This ensures single-column documents proceed safely through the pipeline without artificial splits.
Can I tune the column detection sensitivity?
Currently, sensitivity parameters (NOISE_FRACTION, MIN_GUTTER_WIDTH, MIN_VERTICAL_SPAN_RATIO) are compile-time constants in layout.rs. The debug logging (RUST_LOG=pdf_inspector::extractor::layout=debug) exposes internal decisions for diagnostic purposes, enabling you to identify why specific documents fail detection before modifying source constants.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →