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_BINS to 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: pdf2md provides zero-config usage, while process_pdf_with_options enables 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:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →