# How pdf-inspector Detects Columns in PDFs: A Deep Dive into the Layout Analysis Engine

> Discover how pdf-inspector detects columns in PDFs. Learn about its layout analysis engine using histogram construction, valley detection, and XY-cut fallback for accurate results.

- Repository: [Firecrawl/pdf-inspector](https://github.com/firecrawl/pdf-inspector)
- Tags: deep-dive
- Published: 2026-09-01

---

**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`](https://github.com/firecrawl/pdf-inspector/blob/main/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**.

```rust
// 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):

```rust
// 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:

```bash

# 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:

```rust
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`](https://github.com/firecrawl/pdf-inspector/blob/main/src/extractor/layout.rs) | **Core implementation**: histogram building, valley detection, validation, XY-cut fallback |
| [`src/extractor/mod.rs`](https://github.com/firecrawl/pdf-inspector/blob/main/src/extractor/mod.rs) | Orchestrates per-page extraction; invokes `detect_columns` for each page |
| [`src/types.rs`](https://github.com/firecrawl/pdf-inspector/blob/main/src/types.rs) | Defines `TextItem` (input) and `ColumnRegion` (output) data structures |
| [`src/text_utils.rs`](https://github.com/firecrawl/pdf-inspector/blob/main/src/text_utils.rs) | Geometry utilities: `effective_width`, bounding box calculations |
| [`src/bin/pdf2md.rs`](https://github.com/firecrawl/pdf-inspector/blob/main/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`](https://github.com/firecrawl/pdf-inspector/blob/main/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`](https://github.com/firecrawl/pdf-inspector/blob/main/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.