pdf-inspector
Fast PDF classification and text extraction. The default build detects whether a PDF is text-based or scanned, extracts text with position awareness, and converts to clean Markdown without OCR. It is pure Rust, has no ML models or external services, and uses lopdf for PDF parsing. Native Rust and CLI consumers can opt into selective OCR. Also available for Python and Node.js.
Built by Firecrawl to handle text-based PDFs locally in under 200ms, skipping expensive OCR services for the ~54% of PDFs that don't need them.
Features
- Smart classification — TextBased / Scanned / ImageBased / Mixed in ~10–50ms, with a confidence score and per-page OCR routing.
- Markdown conversion — headings, lists, code blocks, bold/italic, URL linking, and dual-mode table detection (PDF drawing ops + text-alignment heuristics).
- Layout-aware extraction — multi-column reading order, position and font info per text item, RTL support.
- Robust text decoding — CID/Type0 fonts via ToUnicode CMaps, plus automatic flagging of broken encodings so callers can fall back to OCR.
- Lightweight — pure Rust, no ML models, no external services; single PDF dependency (lopdf).
Benchmark
opendataloader-bench corpus (200 PDFs), local engines without model-based PDF parsing; OCR disabled. Scores 0–1, higher is better:
| Engine | Overall | Reading order | Tables (TEDS) | Headings | Speed |
|---|---|---|---|---|---|
| pdf-inspector | 0.875 | 0.915 | 0.814 | 0.788 | 0.470s |
| liteparse | 0.873 | 0.913 | 0.693 | 0.811 | 0.750s |
| opendataloader | 0.831 | 0.902 | 0.489 | 0.739 | 2.569s |
| pymupdf4llm | 0.735 | 0.886 | 0.401 | 0.424 | 17.117s |
| markitdown | 0.589 | 0.844 | 0.273 | 0.000 | 16.165s |
Refreshed July 31, 2026, on Apple M4 Pro; speed is the median of five complete corpus runs after an excluded warm-up. Full methodology and versions are in the repo README, with raw timings and artifacts in the results branch.
Install
For the latest unreleased changes, use the git dependency instead:
[]
= { = "https://github.com/firecrawl/pdf-inspector" }
The crate also ships CLI binaries — pdf2md (PDF → Markdown, with --json, --pages, --select-pages, and the opt-in token-saving --compact profile) and detect-pdf (classification, with --analyze --json):
Usage
Detect and extract in one call:
use process_pdf;
let result = process_pdf?;
println!; // TextBased, Scanned, ImageBased, Mixed
println!;
println!;
if let Some = &result.markdown
Fast metadata-only detection (no text extraction or markdown generation):
use detect_pdf;
let info = detect_pdf?;
match info.pdf_type
Customize processing with PdfOptions:
use ;
// Analyze layout without generating markdown
let result = process_pdf_with_options?;
// Full extraction with custom detection strategy
let result = process_pdf_with_options?;
// Process only specific pages
let result = process_pdf_with_options?;
Process from a byte buffer (no filesystem needed):
use process_pdf_mem;
let bytes = read?;
let result = process_pdf_mem?;
Vision extension contracts
The native-only vision feature exposes the stable seam used by OCR
integrations without selecting or embedding an inference runtime. The
separate model-cache feature adds pinned artifact management:
PageRendererandOcrEnginetraits;- renderer-neutral owned page buffers and affine pixel↔PDF transforms;
OcrOptionsand opt-inOff/Auto/Forcerouting modes;- positioned OCR results and per-page provenance types; and
- a versioned PP-OCRv6 Small manifest with checksum-verified, locked, atomic model-cache installation and explicit offline-directory overrides.
[]
= { = "1", = ["vision", "model-cache"] }
The OCR contracts preserve existing behavior by default: OCR is Off and
model resolution is never reached. ModelStore itself does not access the
network. The optional model-download feature provides an
HTTPS downloader that streams pinned artifacts into the checksum-verified
cache only after routing has selected OCR work. Offline consumers set an
explicit model directory and ModelDownloadPolicy::Offline. Renderer-only
consumers do not enable model-cache or model-download and therefore do not
compile their filesystem, hashing, or HTTP dependencies.
use ;
let ocr = new
.mode
.model_directory
.model_downloads;
// Verifies exact sizes and SHA-256 digests before an engine opens the files.
let models = from_options?.resolve?;
println!;
Optional native page rendering
The render-pdfium feature adds a native-only page renderer backed by
firecrawl-pdfium. It is the
rendering boundary for OCR pipelines; enabling it does not include an OCR
model or change the existing extraction functions. It implies vision,
and PdfiumRenderer implements the renderer-neutral PageRenderer trait.
[]
= { = "1", = ["render-pdfium"] }
PDFium is loaded at runtime and is not bundled into the crate. Set
PDFIUM_LIB_PATH to the platform shared library, place that library next to
the executable, or use another discovery route supported by
firecrawl-pdfium. A load failure reports this prerequisite directly.
use ;
let renderer = load?;
let bytes = read?;
let pages = renderer.render_pages?;
for page in pages
Browser WASM remains on the default text-only path and does not expose native PDFium rendering.
Optional OCR engine
The native-only ocr-oar feature adds a CPU PP-OCRv6 Small implementation of
OcrEngine backed by OAR and ONNX Runtime. It implies model-cache, but does
not enable model auto-download, ONNX Runtime download, or PDF rendering. Model
files remain external, must match the pinned manifest, and are opened only
after ModelStore verifies their exact size and SHA-256 digest. Install an
ONNX Runtime shared library separately and set ORT_DYLIB_PATH to its full
path when it is not available through the platform library search path. The
runtime is resolved only when an OCR engine is first constructed; clean
Auto requests do not require it. The feature currently requires Rust 1.95
or newer, matching OAR 0.9.1's MSRV.
[]
= { = "1", = ["ocr-oar", "render-pdfium"] }
Direct engine invocation is intentionally separate from extraction routing and native/OCR fusion:
use ;
let options = new
.mode
.minimum_confidence
.model_directory
.model_downloads;
let models = from_options?.resolve?;
let engine = from_models?;
let renderer = load?;
let bytes = read?;
let pages = renderer.render_pages?;
let ocr_pages = engine.recognize?;
for span in &ocr_pages.spans
The engine accepts renderer-neutral RGB, RGBA, and grayscale pages, preserves
OAR's positioned quadrilaterals in bitmap coordinates, filters spans using
minimum_confidence, and records the pinned model revision in every OcrPage.
OcrMode::Off is rejected at the engine boundary so default options cannot run
inference accidentally.
Selective routing and lazy model acquisition
route_ocr_pages applies the existing detector/text-quality recommendations to
the configured mode. Auto processes only recommended pages, Force processes
all pages (or an explicit page selection), and Off always returns an empty
route. run_ocr_pages renders only that route, checks that both dependencies
preserve its order, and retains each bitmap's PDF transform for fusion.
[]
= { = "1", = [
"render-pdfium",
"ocr-oar",
"model-download",
] }
use ;
let bytes = read?;
let extraction = extract_pages_markdown_mem?;
let options = new.mode;
let routed = route_ocr_pages?;
if !routed.is_empty
The downloader accepts HTTPS only, checks a declared content length, caps the
response stream to the pinned size plus one byte, and delegates final size and
SHA-256 verification to ModelStore. The store serializes installation across
processes and publishes completed artifacts atomically. Warm caches make no
network calls; offline mode and explicit model directories never download.
OCR Markdown assembly and native fusion
fuse_ocr_pages maps OCR polygons back into PDF coordinates and sends the
result through pdf-inspector's existing deterministic reading-order, table,
and Markdown pipeline. Pages whose native extraction was rejected use OCR
output. When Force runs on a clean native page, normalized duplicate OCR
blocks are removed and only additional image-backed text is retained.
use ;
let fused = fuse_ocr_pages?;
for page in &fused.pages
Each page carries Native, Ocr, or Fused provenance, the exact OCR model
revision, accepted-page confidence, local stage timings, and non-fatal
warnings. A page that required OCR recommends the hosted pipeline when local
OCR is missing, empty, or below the configurable page-confidence threshold.
This keeps the lightweight path explicit about cases it cannot finish well.
Complete OCR API
The ocr convenience feature enables the renderer, OCR engine, verified
model acquisition, routing, and fusion layers together. It is the intended
downstream application integration boundary; lower-level features remain
available for consumers that bring their own renderer, model package manager,
or engine.
[]
= { = "1", = ["ocr"] }
use ;
let result = process_pdf_with_ocr?;
println!;
println!;
println!;
Native extraction always runs first. In Auto, a clean PDF returns before
PDFium loading, model-cache access, HTTP, or OAR initialization. Model files
remain external and the default crate feature set remains unchanged. Off
provides the same native-only behavior through the OCR result/provenance
shape; Force renders every selected page. OCR uses the existing deterministic
table, column, reading-order, and Markdown assembly path; no learned layout
model is included.
The OCR runtime setup guide lists the pinned PDFium and ONNX Runtime builds, environment variables, model cache behavior, and the error boundary downstream hosted fallbacks should use.
For ambiguous mixed pages, Auto privately retains clean native fragments
instead of discarding them when OCR is selected. After recognition it compares
script-agnostic text quality, OCR confidence, character overlap, and material
new coverage. Exact native text wins over a duplicate or weak OCR hypothesis;
complementary image-backed text is fused; and pages where both candidates are
weak recommend the hosted document pipeline. A page routed because native
coverage appeared incomplete also recommends hosted processing when confident
OCR only duplicates the retained fragment: the agreement preserves trustworthy
text, but neither hypothesis proves full-page coverage. Public native-only
extraction continues to suppress pages marked unreliable, and clean text
documents pay no renderer or model-initialization cost.
In Auto, pages routed only for suspicious font encoding or vectorized text
first get a bounded positioned-text probe through PDFium. A credible recovered
text layer with sufficient geometric page coverage skips rasterization and
model loading for that page; garbled, partial, or insubstantial recovery
continues through OCR. Recovered tables are reflected in the same document
metadata as tables found by the primary extractor.
The one-call API keeps the most recently used verified OCR engine in process. Long-lived workers therefore verify the pinned artifacts and build the ONNX sessions once, then reuse those loaded sessions across documents. The cache is bounded to one model configuration and keyed by normalized model/runtime paths plus the pinned manifest revision and artifact digests; switching the model directory, runtime library, or compiled manifest replaces it. An active engine owns the model data it already verified, so mutating artifacts in place does not hot-reload a running process; restart the process when intentionally replacing files at the same paths. CPU inference uses at most four intra-op threads per ONNX session so a single small page does not oversubscribe larger hosts, and recognizes variable-width line crops individually to avoid padding-heavy CPU batches. The high-level pipeline renders and fuses at most four routed pages at a time, bounding bitmap memory on long documents.
Build the CLI with the same opt-in feature:
CLI controls include --ocr-dpi, --ocr-min-confidence,
--ocr-hosted-threshold, --select-pages, and the existing encrypted-PDF
--password option. JSON output has schema_version: 1 and includes per-page Markdown, source/model
provenance, confidence, timings, warnings, routed pages, and hosted-fallback
recommendations. Page numbers in OcrPdfResult and its per-page provenance
are 1-indexed, matching the PDF page numbers accepted by
OcrPdfOptions::page_numbers.
Extract per-page Markdown (one string per page, plus document-wide layout metadata):
use extract_pages_markdown;
// Pass `None` for every page in document order, or a slice of 0-indexed
// pages to restrict the output (caller-supplied order is preserved).
let result = extract_pages_markdown?;
for page in &result.pages
println!;
Extract structure-tree elements from tagged PDFs, and join them against
extract_text_with_positions to attach semantic roles (heading levels,
paragraphs, table cells) to extracted text:
use ;
use HashMap;
// One entry per marked-content reference, sorted by (page, mcid); empty for
// untagged PDFs. Pages are 1-indexed to match `TextItem::page`, so the
// (page, mcid) pair is a direct join key.
let elements = extract_structure_elements?;
let roles: = elements
.iter
.map
.collect;
for item in extract_text_with_positions?
Coordinate frame
Positioned items and region inputs share one reference box: PDF points relative
to the page's visible page box — CropBox ∩ MediaBox when the page has a
CropBox, else the MediaBox (a CropBox that does not overlap the MediaBox is
ignored, and a page without a MediaBox is measured against US Letter).
TextItem.x/y use the box's lower-left corner as origin with y growing
upward; region and crop bboxes (extract_text_in_regions_mem,
extract_tables_in_regions_mem, detect_vector_grid_in_region_mem,
TsrTableInput) use its top-left corner with y growing downward, the axis
convention of a rendered page image. Converting between the two only needs
the box height h: a positioned y becomes h - y. For text items y is the
baseline and height the font size, so [x, h - y - height, x + width, h - y]
covers the glyph band above the baseline (descenders fall below it); for image,
link and form-field items y is the rect bottom and that box is exact. Pages
whose CropBox equals the MediaBox with a (0, 0) origin are unaffected by this
convention. Pages whose text is drawn rotated by 90° are normalized into a
synthetic landscape frame before the shift, and /Rotate is not applied.
That is the sheet frame (PositionFrame::Sheet), the default of every
position and region API. Its coordinates coincide with a rendered page image
only for pages with /Rotate 0 whose text is not predominantly rotated; for
any other page, boxes taken from a rendered image belong in the display
frame (PositionFrame::Display) that the _in_frame variants offer: the
rendered page, i.e. the visible page box turned clockwise by the page's
inheritable /Rotate, with the same origin conventions — items from the
lower-left corner with y up, regions from the top-left corner with y down
— and with the synthetic turn of a predominantly rotated page undone first
(individual runs keep their own rotation). Items then sit where a renderer
draws them, rotation reads 0 for text that renders horizontally, and
region rects can be taken straight from a rendered page image. /Rotate is
read inheritably and snapped to a right angle (-90 is 270, 450 is 90).
Processing modes
| Mode | What it does | Returns |
|---|---|---|
ProcessMode::Full (default) |
Detect + extract + convert to Markdown | Everything populated |
ProcessMode::Analyze |
Detect + extract + layout analysis (no Markdown) | markdown is None, layout is populated |
ProcessMode::DetectOnly |
Classification only (fastest) | markdown is None, layout is default |
Functions
| Function | Description |
|---|---|
process_pdf(path) |
Full processing with defaults |
detect_pdf(path) |
Fast metadata-only detection (no extraction) |
process_pdf_with_options(path, options) |
Process with custom PdfOptions |
process_pdf_mem(bytes) |
Full processing from a byte buffer |
detect_pdf_mem(bytes) |
Fast detection from a byte buffer |
process_pdf_mem_with_options(bytes, options) |
Process from bytes with custom options |
extract_text(path) |
Plain text extraction |
extract_text_with_positions(path) |
Text with its axis-aligned box (visible-page-box frame, see above), rotation, and font info |
extract_text_with_positions_mem_in_frame(bytes, pages, frame) |
Positioned text from bytes, limited to 1-indexed pages, in the sheet or display frame (PositionFrame) |
extract_text_with_positions_mem_with_options(bytes, pages, options) |
The same with every option given as a PositionOptions (frame, bold_from_weight) |
extract_text_with_positions_and_rotations_mem(bytes) |
Positioned text plus the PageRotation of every page whose text was predominantly rotated |
extract_text_with_positions_and_rotations_mem_in_frame(bytes, pages, frame) |
The same with a page filter and a frame choice |
extract_text_with_positions_and_rotations_mem_with_options(bytes, pages, options) |
The same with a PositionOptions |
extract_text_in_regions_mem(bytes, page_regions) |
Text inside top-left region rects (sheet frame) |
extract_text_in_regions_mem_in_frame(bytes, page_regions, frame) |
The same with region rects read in the sheet or display frame |
extract_text_in_regions_mem_with_options(bytes, page_regions, options) |
The same with a PositionOptions |
extract_tables_in_regions_mem(bytes, page_regions) |
Markdown tables inside region rects (sheet frame) |
extract_tables_in_regions_mem_in_frame(bytes, page_regions, frame) |
The same with region rects read in the sheet or display frame |
extract_tables_in_regions_mem_with_options(bytes, page_regions, options) |
The same with a PositionOptions |
collect_text_in_region_in_frame(items, x1, y1, x2, y2, page_height, rotation) |
Region text with the page's coordinate frame given explicitly (page_height is the visible page box height) |
to_markdown(text, options) |
Convert plain text to Markdown |
to_markdown_from_items(items, options) |
Markdown from pre-extracted TextItems |
to_markdown_from_items_with_rects(items, options, rects) |
Markdown with rectangle-based table detection |
extract_pages_markdown(path, pages) |
Per-page Markdown + layout metadata (file) |
extract_pages_markdown_mem(bytes, pages) |
Per-page Markdown from bytes |
extract_structure_elements(path, pages) |
Structure-tree elements from tagged PDFs (page, mcid, role) |
extract_structure_elements_mem(bytes, pages) |
Structure-tree elements from bytes |
Low-level detection functions are also available via the detector module (detect_pdf_type, detect_pdf_type_with_config, etc.) for callers who need PdfTypeResult instead of PdfProcessResult.
Types
| Type | Description |
|---|---|
PdfOptions |
Builder for processing configuration (mode, detection, markdown, page filter) |
ProcessMode |
DetectOnly, Analyze, Full |
PdfType |
TextBased, Scanned, ImageBased, Mixed |
PdfProcessResult |
Full result: pdf_type, markdown, page_count, confidence, layout, has_encoding_issues, timing |
PdfTypeResult |
Low-level detection result: type, confidence, page count, pages needing OCR |
DetectionConfig |
Configuration for detection: scan strategy, thresholds |
ScanStrategy |
EarlyExit, Full, Sample(n), Pages(vec) |
LayoutComplexity |
Layout analysis: is_complex, pages_with_tables, pages_with_columns |
TextItem |
Text with its axis-aligned box (PDF points from the visible page box's lower-left corner), baseline rotation in degrees (a vertical run is tall and thin, never zero-width), advance_known (false when the font has no width metrics or an ActualText span's advance could not be recovered), baseline_shift (non-zero for super/subscript glyph runs; line_y() gives the body baseline), font info including font_weight (the 100..=900 weight class from the embedded font's OS/2 table, /FontWeight or a weight word in the name; None when unknown), page number, and optional structure-tree mcid |
PageRotation |
Upright, Ccw, Cw: how a predominantly rotated page's coordinate frame was turned so its text reads left-to-right |
PositionFrame |
Sheet (default): the visible page box as laid out in the content stream, /Rotate not applied, predominantly rotated pages turned; Display: the rendered page, the visible box turned clockwise by the inheritable /Rotate, with only the synthetic turn of a predominantly rotated page undone first while individual runs keep their own rotation (see Coordinate frame) |
PositionOptions |
Options of the _with_options position and region functions: frame (a PositionFrame) and bold_from_weight, which also reads TextItem::is_bold from a font_weight of 600 or more and keeps adjacent runs of different weight as separate items; off by default, where is_bold and merging are unchanged |
StructureElement |
Tagged-PDF structure reference: page (1-indexed), mcid, role ("H1".."H6", "P", …) |
MarkdownOptions |
Configuration for Markdown formatting (page numbers, etc.) |
PageMarkdown |
Per-page result: page (0-indexed), markdown, needs_ocr |
PagesExtractionResult |
Per-page output + 1-indexed pages_with_tables / pages_with_columns / pages_needing_ocr, is_complex |
PdfError |
Io, Parse, Encrypted, InvalidStructure, NotAPdf |