Fast Rust library for PDF inspection, classification, and text extraction. Intelligently detects scanned vs text-based PDFs to enable smart routing decisions.
# pdf-inspector
Fast PDF text extraction to structured Markdown. CLI binary: `pdf2md`. Detection binary: `detect-pdf`.
## Build & Test
```bash
cargo fmt # format
cargo clippy -- -D warnings # lint (enforced, zero warnings)
cargo test # unit + integration tests (267+ unit, 73+ integration)
cargo build --release # release binary for benchmarks
```
All three must pass before committing.
## Binaries
- `pdf2md` β extract PDF β Markdown. Supports `--json` for structured output.
- `detect-pdf` β classify PDF type (TextBased/Scanned/Mixed/ImageBased). Supports `--analyze --json`.
## Architecture
```
src/
lib.rs β public API, process_pdf_with_options, encoding issue detection
detector.rs β PDF type classification, tiled-scan detection, page sampling
types.rs β TextItem, TextLine, PdfRect, PdfLine
tounicode.rs β CMap/ToUnicode parsing, CID decoding
text_utils.rs β CJK/RTL handling, Otsu threshold, ligature expansion, NFKC
extractor/
mod.rs β top-level extraction orchestrator
content_stream.rs β PDF operator state machine (Tj/TJ/Td/Tm/q/Q)
fonts.rs β font width/encoding, CMapDecisionCache, TrueType cmap fallback
layout.rs β column detection (histogram), newspaper/tabular classification,
spanning-line pre-masking, sidebar detection
tables/
detect_rects.rs β rect-based table detection (union-find clustering)
detect_heuristic.rs β heuristic table detection (gap-histogram, body-font tables)
detect_lines.rs β line-based table detection (H/V line grids)
grid.rs β column/row boundaries, cell assignment
format.rs β tableβMarkdown formatting, continuation row merging
markdown/
convert.rs β core lineβMarkdown loop, struct-tree role support
analysis.rs β font stats, heading tiers, paragraph thresholds
classify.rs β line classification (header, list, code, caption)
preprocess.rs β drop cap merging, heading line merging
postprocess.rs β dot leaders, hyphenation, page numbers, URL formatting
```
## Key design decisions
- **Primary audience is AI agents.** Output optimized for token efficiency and semantic quality, not visual formatting. No cosmetic padding.
- **Three table detection strategies** run in priority order: rect-based β line-based β heuristic. First valid result wins.
- **Column detection** uses horizontal projection histograms with valley detection. Multi-item spanning lines (titles, headers) are pre-masked using column-aware thresholds before column assignment.
- **Newspaper vs tabular** classification determines reading order: newspaper reads columns sequentially, tabular Y-interleaves them.
- **Tiled-scan detection** catches scanned PDFs with JBIG2/strip images where no single tile exceeds the template threshold but aggregate area does (β₯2M pixels).
- **Garbage text upgrade** reclassifies Mixed PDFs as Scanned when extracted text is <50% alphanumeric.
- **Tagged PDF support** uses structure tree roles (H1-H6, P, L, Code, BlockQuote) when available, falling back to font-size heuristics.
## Testing
- **Unit tests**: inline `#[cfg(test)] mod tests` in each module with synthetic data.
- **Integration tests**: `tests/integration_tests.rs` with fixture PDFs in `tests/fixtures/`.
- **Regression suite**: sibling repo `pdf-evals` with ~200 snapshot PDFs. Run `cargo build --release` then `bench.py test` in that repo before committing. While iterating, prefer a subset run (`bench.py test -q` for the quick set, or `-s <name>` for a named test set) and save the full `bench.py test` for the final pre-commit check.
- **Semantic quality**: run `bench.py score` in `pdf-evals` for the semantic verdict (TEDS + MHS + reading order + char/word + list preservation, composited). Character-level diff alone misclassifies structural improvements (e.g., column-detection rewrites) as regressions β `score` is the tie-breaker. See `pdf-evals/CLAUDE.md` "Semantic scoring".
## Debugging
```bash
RUST_LOG=pdf_inspector::extractor::layout=debug cargo run --bin pdf2md -- file.pdf
RUST_LOG=pdf_inspector::tables=debug cargo run --bin pdf2md -- file.pdf
RUST_LOG=pdf_inspector::detector=debug cargo run --release --bin detect-pdf -- file.pdf
```
## Conventions
- Clippy: use `is_some_and(...)` not `map_or(false, ...)`
- lopdf quirk: `ParseError` is private β match by string for `InvalidFileHeader`
- Column limit for tables: 25 (wide statistical tables)
- `propagate_merged_cells` skipped for >10 columns (spanning rects = background fills)