Compare commits

..
Author SHA1 Message Date
Abimael Martell c19bb56c66 refactor(vision): finalize OCR API 2026-08-16 22:09:17 -07:00
Abimael Martell 8552efba5e fix(vision): address OCR hardening review 2026-08-16 22:09:08 -07:00
Abimael Martell 40672d6752 fix(vision): harden OCR recovery 2026-08-16 21:42:45 -07:00
Abimael Martell 6d4f044d67 fix(vision): preserve adaptive OCR fallbacks 2026-08-16 21:42:45 -07:00
Abimael Martell 7e7c85801f feat(vision): adaptively fuse native and OCR text 2026-08-16 21:42:45 -07:00
Abimael Martell c6df46e328 fix(vision): validate native recovery coverage 2026-08-16 21:42:45 -07:00
Abimael Martell 852a790aa5 feat(vision): recover credible PDFium text layers 2026-08-16 21:42:45 -07:00
Abimael Martell 634a29f04d fix(tables): preserve numbered reference notes 2026-08-16 21:42:45 -07:00
Abimael Martell 0e2e287c04 fix(vision): harden OCR engine caching 2026-08-16 21:42:45 -07:00
Abimael Martell 616f9b59fb perf(vision): reuse OCR runtime sessions 2026-08-16 21:42:45 -07:00
Abimael Martell 21a436ac1b fix(vision): normalize white bullet rows 2026-08-16 21:40:55 -07:00
Abimael Martell d2d8e35a7b fix(vision): harden OCR line recovery 2026-08-16 21:30:07 -07:00
Abimael Martell 30c9dbbc72 fix(vision): preserve OCR row boundaries 2026-08-16 21:30:07 -07:00
Abimael Martell 162c5cbf10 refactor(vision): expose OCR API 2026-08-16 21:30:07 -07:00
Abimael Martell 4e4cfdc74c fix(vision): harden OCR API 2026-08-16 21:30:07 -07:00
Abimael Martell 24e66245cb feat(vision): expose OCR pipeline 2026-08-16 21:30:07 -07:00
Abimael Martell cee7b6c381 refactor(vision): use OCR fusion terminology 2026-08-16 21:30:07 -07:00
Abimael Martell b5a63d7036 fix(vision): make OCR fusion conservative 2026-08-16 21:30:07 -07:00
Abimael Martell 6829397bce feat(vision): fuse OCR output 2026-08-16 21:30:07 -07:00
Abimael Martell fc16133a58 refactor(vision): name routed OCR results 2026-08-16 21:30:06 -07:00
Abimael Martell 41a6a67c03 fix(vision): serialize model acquisition 2026-08-16 21:30:06 -07:00
Abimael Martell 58fe5a0224 feat(vision): add OCR routing 2026-08-16 21:30:06 -07:00
Abimael Martell c4c969a562 refactor(vision): use OCR engine terminology 2026-08-16 21:30:06 -07:00
Abimael Martell bf0cd8decb fix(vision): harden OAR runtime loading 2026-08-16 21:30:06 -07:00
Abimael Martell 232b4cdef5 feat(vision): add OAR OCR engine 2026-08-16 21:30:06 -07:00
19 changed files with 5310 additions and 261 deletions
+13
View File
@@ -57,6 +57,14 @@ firecrawl-pdfium = { version = "0.1.0", optional = true }
dirs = { version = "6.0", optional = true }
fs2 = { version = "0.4", optional = true }
sha2 = { version = "0.11", optional = true }
# Optional CPU OCR backend. Models and ONNX Runtime stay external: the latter
# is loaded dynamically from ORT_DYLIB_PATH or the platform library search path.
image = { version = "0.25.6", default-features = false, optional = true }
oar-ocr = { version = "0.9.1", default-features = false, features = ["simd"], optional = true }
ort = { version = "=2.0.0-rc.13", default-features = false, features = ["load-dynamic"], optional = true }
# HTTPS-only streaming downloader for pinned model artifacts. Kept separate
# from model-cache so offline and package-managed deployments avoid HTTP/TLS.
ureq = { version = "3.4", default-features = false, features = ["rustls", "platform-verifier"], optional = true }
[target.'cfg(all(windows, not(target_arch = "wasm32")))'.dependencies]
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"], optional = true }
@@ -75,7 +83,12 @@ default = []
python = ["pyo3"]
vision = []
model-cache = ["vision", "dep:dirs", "dep:fs2", "dep:sha2", "dep:windows-sys"]
model-download = ["model-cache", "dep:ureq"]
ocr-oar = ["model-cache", "dep:image", "dep:oar-ocr", "dep:ort"]
render-pdfium = ["vision", "dep:firecrawl-pdfium"]
# Complete native OCR path. This remains opt-in so default library,
# renderer-only, and browser consumers do not inherit inference or HTTP/TLS.
ocr = ["render-pdfium", "ocr-oar", "model-download"]
[[bin]]
name = "pdf2md"
+16 -2
View File
@@ -5,7 +5,7 @@
[![PyPI](https://img.shields.io/pypi/v/pdf-inspector.svg)](https://pypi.org/project/pdf-inspector/)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
Fast Rust library for PDF classification and text extraction. Detects whether a PDF is text-based or scanned, extracts text with position awareness, and converts to clean Markdown — all without OCR. Includes bindings for [Python](docs/python.md), [Node.js](napi/README.md), and [browser WebAssembly](wasm/README.md).
Fast Rust library for PDF classification and text extraction. By default it detects whether a PDF is text-based or scanned, extracts text with position awareness, and converts to clean Markdown without OCR. Native Rust and CLI consumers can opt into selective OCR. Includes bindings for [Python](docs/python.md), [Node.js](napi/README.md), and [browser WebAssembly](wasm/README.md).
Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in under 200ms, skipping expensive OCR services for the ~54% of PDFs that don't need them.
@@ -18,9 +18,10 @@ Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in
- **CID font support** — ToUnicode CMap decoding for Type0/Identity-H fonts, UTF-16BE, UTF-8, and Latin-1 encodings.
- **Multi-column layout** — Automatic detection of newspaper-style columns, sequential reading order, and RTL text support.
- **Encoding issue detection** — Automatically flags broken font encodings so callers can fall back to OCR.
- **Optional OCR** — An opt-in Rust and CLI feature selectively renders only pages that need OCR, runs PP-OCRv6 Small locally, and preserves per-page provenance and hosted-fallback recommendations.
- **Single document load** — The document is parsed once and shared between detection and extraction, avoiding redundant I/O.
- **Browser WebAssembly** — Run the same Rust parser locally in browsers and Web Workers, with embedded CMaps and no server round trip.
- **Lightweight** — Pure Rust, no ML models, no external services. Single dependency on `lopdf` for PDF parsing.
- **Lightweight by default** — The default build is pure Rust with no ML models or external services. PDFium, ONNX Runtime, and OCR models are added only when the native `ocr` feature is selected and remain external runtime artifacts.
## Benchmark
@@ -160,6 +161,19 @@ detect-pdf document.pdf --json
detect-pdf document.pdf --analyze --json
```
OCR is a separate native CLI build and does not change the default package:
```bash
cargo install pdf-inspector --features ocr --bin pdf2md
PDFIUM_LIB_PATH=/path/to/libpdfium ORT_DYLIB_PATH=/path/to/libonnxruntime \
pdf2md scan.pdf --ocr auto --json
```
The OCR JSON envelope is versioned and reports routed pages, per-page source
and confidence, warnings, and pages recommended for the hosted document
pipeline. See the [Rust API guide](docs/rust-api.md#complete-ocr-api) for model
cache and offline configuration.
From a source checkout, use `cargo run --bin pdf2md -- document.pdf` or `cargo run --bin detect-pdf -- document.pdf` instead.
## Architecture
+250 -13
View File
@@ -1,6 +1,6 @@
# pdf-inspector
Fast PDF classification and text extraction. Detects whether a PDF is text-based or scanned, extracts text with position awareness, and converts to clean Markdown — all without OCR. The default build is pure Rust, has no ML models or external services, and uses [lopdf](https://crates.io/crates/lopdf) for PDF parsing. Also available for [Python](https://pypi.org/project/pdf-inspector/) and [Node.js](https://www.npmjs.com/package/@firecrawl/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](https://crates.io/crates/lopdf) for PDF parsing. Native Rust and CLI consumers can opt into selective OCR. Also available for [Python](https://pypi.org/project/pdf-inspector/) and [Node.js](https://www.npmjs.com/package/@firecrawl/pdf-inspector/).
Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in under 200ms, skipping expensive OCR services for the ~54% of PDFs that don't need them.
@@ -123,10 +123,10 @@ 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:
- `PageRenderer`, `OcrEngine`, and `LayoutEngine` traits;
- `PageRenderer` and `OcrEngine` traits;
- renderer-neutral owned page buffers and affine pixel↔PDF transforms;
- `OcrOptions` and opt-in `Off`/`Auto`/`Force` routing modes;
- positioned OCR/layout results and per-page provenance types; and
- 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.
@@ -135,13 +135,14 @@ separate `model-cache` feature adds pinned artifact management:
pdf-inspector = { version = "1", features = ["vision", "model-cache"] }
```
The OCR contracts preserve existing behavior by default: OCR is `Off`, learned
layout is disabled, and model resolution is never reached. `ModelStore` itself
does not access the network; a runtime integration can fetch a manifest's
canonical URL only when allowed and pass the stream to `ModelStore::install`.
Offline consumers set an explicit model directory and `ModelDownloadPolicy::Offline`.
Renderer-only consumers do not enable `model-cache` and therefore do not compile
its filesystem, locking, or hashing dependencies.
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.
```rust
use pdf_inspector::vision::{
@@ -170,9 +171,10 @@ and `PdfiumRenderer` implements the renderer-neutral `PageRenderer` trait.
pdf-inspector = { version = "1", features = ["render-pdfium"] }
```
PDFium is loaded at runtime. Set `PDFIUM_LIB_PATH`, place its shared library
next to the executable, or use another discovery route supported by
`firecrawl-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.
```rust
use pdf_inspector::vision::{PdfiumRenderer, RenderOptions};
@@ -197,6 +199,241 @@ 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.
```toml
[dependencies]
pdf-inspector = { version = "1", features = ["ocr-oar", "render-pdfium"] }
```
Direct engine invocation is intentionally separate from extraction routing and
native/OCR fusion:
```rust
use pdf_inspector::vision::{
ModelDownloadPolicy, ModelStore, OarOcrEngine, OcrEngine, OcrMode,
OcrOptions, PdfiumRenderer, RenderOptions, PP_OCR_V6_SMALL,
};
let options = OcrOptions::new()
.mode(OcrMode::Force)
.minimum_confidence(0.45)
.model_directory("/opt/firecrawl/models/pp-ocrv6-small")
.model_downloads(ModelDownloadPolicy::Offline);
let models = ModelStore::from_options(&options)?.resolve(&PP_OCR_V6_SMALL)?;
let engine = OarOcrEngine::from_models(&models)?;
let renderer = PdfiumRenderer::load()?;
let bytes = std::fs::read("scan.pdf")?;
let pages = renderer.render_pages(&bytes, &[1], None, &RenderOptions::new())?;
let ocr_pages = engine.recognize(&pages, &options)?;
for span in &ocr_pages[0].spans {
println!("{:.3}: {}", span.confidence, span.text);
}
```
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.
```toml
[dependencies]
pdf-inspector = { version = "1", features = [
"render-pdfium",
"ocr-oar",
"model-download",
] }
```
```rust
use pdf_inspector::vision::{
route_ocr_pages, run_ocr_pages, HttpModelDownloader, ModelStore,
OarOcrEngine, OcrMode, OcrOptions, PdfiumRenderer, RenderOptions,
PP_OCR_V6_SMALL,
};
let bytes = std::fs::read("scan.pdf")?;
let extraction = pdf_inspector::extract_pages_markdown_mem(&bytes, None)?;
let options = OcrOptions::new().mode(OcrMode::Auto);
let routed = route_ocr_pages(
options.mode,
extraction.pages.len() as u32,
&extraction.pages_needing_ocr,
None,
)?;
if !routed.is_empty() {
// No HTTP request or model initialization occurs before this point.
let store = ModelStore::from_options(&options)?;
let models = store.resolve_or_download(
&PP_OCR_V6_SMALL,
options.model_downloads,
&HttpModelDownloader::default(),
)?;
let run = run_ocr_pages(
&PdfiumRenderer::load()?,
&OarOcrEngine::from_models(&models)?,
&bytes,
&routed,
None,
&RenderOptions::new(),
&options,
)?;
println!("OCR processed {} pages", run.pages.len());
}
```
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.
```rust
use pdf_inspector::vision::{fuse_ocr_pages, OcrFusionOptions};
let fused = fuse_ocr_pages(
&extraction.pages,
&run,
extraction.pages.len() as u32,
&OcrFusionOptions::new().render_dpi(150.0),
)?;
for page in &fused.pages {
println!("{}", page.markdown);
if page.provenance.hosted_recommended {
eprintln!(
"page {} needs the hosted document pipeline",
page.page_number,
);
}
}
```
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.
```toml
[dependencies]
pdf-inspector = { version = "1", features = ["ocr"] }
```
```rust
use pdf_inspector::vision::{process_pdf_with_ocr, OcrPdfOptions};
let result = process_pdf_with_ocr(
"document.pdf",
OcrPdfOptions::auto().page_numbers([1, 2, 3]),
)?;
println!("{}", result.markdown);
println!("OCR pages: {:?}", result.pages_routed_to_ocr);
println!(
"Hosted fallback pages: {:?}",
result.pages_recommending_hosted,
);
```
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.
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:
```bash
cargo install pdf-inspector --features ocr --bin pdf2md
cargo build --release --features ocr --bin pdf2md
pdf2md document.pdf --ocr auto --raw
pdf2md document.pdf --ocr auto --json
pdf2md document.pdf --ocr auto --ocr-offline --ocr-model-dir /opt/models/pp-ocrv6-small
```
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):
+311 -6
View File
@@ -1,6 +1,11 @@
//! CLI tool for PDF to Markdown conversion
use pdf_inspector::extractor::ItemType;
#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
use pdf_inspector::vision::{
process_pdf_with_ocr, ModelDownloadPolicy, OcrMode, OcrOptions, OcrPdfOptions, OcrPdfResult,
PageContentSource, RenderOptions,
};
use pdf_inspector::{
extract_text_with_positions_pages_with_password, process_pdf_with_options, LayoutComplexity,
PdfOptions, PdfType, ProcessMode, TextItem,
@@ -103,6 +108,146 @@ fn format_items_json(items: &[TextItem]) -> String {
)
}
#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
fn optional_json_number(value: Option<f32>) -> String {
value
.filter(|value| value.is_finite())
.map(|value| format!("{value:.4}"))
.unwrap_or_else(|| "null".to_string())
}
#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
fn format_ocr_json(result: &OcrPdfResult) -> String {
let routed = result
.pages_routed_to_ocr
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(",");
let recommended = result
.pages_recommended_for_ocr
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(",");
let hosted = result
.pages_recommending_hosted
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(",");
let pages = result
.pages
.iter()
.map(|page| {
let provenance = &page.provenance;
let source = match provenance.source {
PageContentSource::Native => "native",
PageContentSource::Ocr => "ocr",
PageContentSource::Fused => "fused",
_ => "unknown",
};
let model = provenance
.ocr_model
.as_ref()
.map(|model| {
format!(
r#"{{"name":"{}","revision":"{}"}}"#,
json_escape(&model.name),
json_escape(&model.revision)
)
})
.unwrap_or_else(|| "null".to_string());
let warnings = provenance
.warnings
.iter()
.map(|warning| format!(r#""{}""#, json_escape(warning)))
.collect::<Vec<_>>()
.join(",");
format!(
r#"{{"page":{},"source":"{}","markdown":"{}","ocr_model":{},"render_dpi":{},"ocr_confidence":{},"hosted_recommended":{},"timings":{{"render_ms":{},"ocr_ms":{},"assembly_ms":{}}},"warnings":[{}]}}"#,
provenance.page_number,
source,
json_escape(&page.markdown),
model,
optional_json_number(provenance.render_dpi),
optional_json_number(provenance.ocr_confidence),
provenance.hosted_recommended,
provenance.timings.render_ms,
provenance.timings.ocr_ms,
provenance.timings.assembly_ms,
warnings,
)
})
.collect::<Vec<_>>()
.join(",");
let table_pages = result
.pages_with_tables
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(",");
let column_pages = result
.pages_with_columns
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(",");
let ocr_reasons = format_ocr_reasons_by_page(&result.ocr_reasons_by_page);
format!(
r#"{{"schema_version":1,"page_count":{},"processing_time_ms":{},"render_time_ms":{},"ocr_time_ms":{},"pages_recommended_for_ocr":[{}],"pages_routed_to_ocr":[{}],"pages_recommending_hosted":[{}],"ocr_reasons_by_page":[{}],"is_complex":{},"pages_with_tables":[{}],"pages_with_columns":[{}],"pages":[{}],"markdown":"{}"}}"#,
result.page_count,
result.processing_time_ms,
result.render_time_ms,
result.ocr_time_ms,
recommended,
routed,
hosted,
ocr_reasons,
result.is_complex,
table_pages,
column_pages,
pages,
json_escape(&result.markdown),
)
}
fn argument_value<'a>(args: &'a [String], name: &str) -> Result<Option<&'a str>, String> {
args.iter()
.position(|argument| argument == name)
.map(|index| {
args.get(index + 1)
.map(String::as_str)
.ok_or_else(|| format!("{name} requires a value"))
})
.transpose()
}
fn format_ocr_error_json(error: &str) -> String {
format!(r#"{{"schema_version":1,"error":"{}"}}"#, json_escape(error))
}
fn exit_ocr_error(error: &str, json_output: bool) -> ! {
if json_output {
println!("{}", format_ocr_error_json(error));
} else {
eprintln!("Error: {error}");
}
process::exit(1);
}
#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
fn float_argument(args: &[String], name: &str, default: f32) -> Result<f32, String> {
argument_value(args, name)?
.map(|value| {
value
.parse::<f32>()
.map_err(|_| format!("{name} requires a number, got {value:?}"))
})
.transpose()
.map(|value| value.unwrap_or(default))
}
fn extract_items_json(
pdf_path: &str,
page_filter: Option<&HashSet<u32>>,
@@ -114,7 +259,9 @@ fn extract_items_json(
#[cfg(test)]
mod tests {
use super::{extract_items_json, format_items_json};
use super::{extract_items_json, format_items_json, format_ocr_error_json};
#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
use super::{format_ocr_json, process_pdf_with_ocr, OcrPdfOptions};
use pdf_inspector::extractor::ItemType;
use pdf_inspector::TextItem;
@@ -164,6 +311,27 @@ mod tests {
"decrypted item JSON should contain fixture text, got {json}"
);
}
#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
#[test]
fn ocr_json_has_a_versioned_stable_envelope() {
let result =
process_pdf_with_ocr("tests/fixtures/thermo-freon12.pdf", OcrPdfOptions::new())
.unwrap();
let json = format_ocr_json(&result);
assert!(json.starts_with(r#"{"schema_version":1,"page_count":3,"#));
assert!(json.contains(r#""page":1,"source":"native""#));
assert!(!json.contains("layout_ms"));
}
#[test]
fn ocr_json_errors_use_the_same_versioned_envelope() {
assert_eq!(
format_ocr_error_json("bad \"value\""),
r#"{"schema_version":1,"error":"bad \"value\""}"#
);
}
}
/// Parse a page specification like "1,3,5-10,20" into a HashSet of page numbers.
@@ -242,6 +410,12 @@ fn main() {
eprintln!(" --password PW Password for an encrypted PDF");
eprintln!(" --detect-only Only detect PDF type (no extraction)");
eprintln!(" --analyze Detect + extract + layout analysis (no markdown)");
eprintln!(" --ocr MODE OCR mode: off, auto, or force (requires feature `ocr`)");
eprintln!(" --ocr-dpi N OCR render resolution (default: 150)");
eprintln!(" --ocr-min-confidence N Drop OCR spans below N (default: 0)");
eprintln!(" --ocr-hosted-threshold N Recommend hosted parsing below N (default: 0.5)");
eprintln!(" --ocr-model-dir DIR Use a package-managed local model directory");
eprintln!(" --ocr-offline Never download missing OCR models");
process::exit(1);
}
@@ -253,6 +427,10 @@ fn main() {
let page_numbers = args.iter().any(|a| a == "--pages");
let detect_only = args.iter().any(|a| a == "--detect-only");
let analyze = args.iter().any(|a| a == "--analyze");
let ocr_mode_argument = argument_value(&args, "--ocr").unwrap_or_else(|error| {
eprintln!("Error: {error}");
process::exit(1);
});
// Parse --password value
let password = args.iter().position(|a| a == "--password").map(|i| {
@@ -283,6 +461,138 @@ fn main() {
})
});
let output_file = args
.get(2)
.filter(|a| !a.starts_with("--"))
.map(|s| s.as_str());
let has_ocr_only_option = [
"--ocr-dpi",
"--ocr-min-confidence",
"--ocr-hosted-threshold",
"--ocr-model-dir",
"--ocr-offline",
]
.iter()
.any(|option| args.iter().any(|argument| argument == option));
if ocr_mode_argument.is_none() && has_ocr_only_option {
exit_ocr_error(
"OCR options require --ocr off, --ocr auto, or --ocr force",
json_output,
);
}
if let Some(mode) = ocr_mode_argument {
if items_json_output || detect_only || analyze {
exit_ocr_error(
"--ocr cannot be combined with --items-json, --detect-only, or --analyze",
json_output,
);
}
#[cfg(not(all(feature = "ocr", not(target_arch = "wasm32"))))]
{
let _ = mode;
exit_ocr_error(
"this pdf2md build does not include OCR; rebuild with --features ocr",
json_output,
);
}
#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
{
let mode = match mode {
"off" => OcrMode::Off,
"auto" => OcrMode::Auto,
"force" => OcrMode::Force,
value => {
exit_ocr_error(
&format!("invalid --ocr mode {value:?}; expected off, auto, or force"),
json_output,
);
}
};
let dpi = float_argument(&args, "--ocr-dpi", 150.0).unwrap_or_else(|error| {
exit_ocr_error(&error, json_output);
});
let minimum_confidence = float_argument(&args, "--ocr-min-confidence", 0.0)
.unwrap_or_else(|error| {
exit_ocr_error(&error, json_output);
});
let hosted_threshold = float_argument(&args, "--ocr-hosted-threshold", 0.5)
.unwrap_or_else(|error| {
exit_ocr_error(&error, json_output);
});
let model_directory =
argument_value(&args, "--ocr-model-dir").unwrap_or_else(|error| {
exit_ocr_error(&error, json_output);
});
let mut ocr = OcrOptions::new()
.mode(mode)
.minimum_confidence(minimum_confidence);
if let Some(directory) = model_directory {
ocr = ocr.model_directory(directory);
}
if args.iter().any(|argument| argument == "--ocr-offline") {
ocr = ocr.model_downloads(ModelDownloadPolicy::Offline);
}
let mut markdown = pdf_inspector::MarkdownOptions::default();
if compact_output {
markdown.profile = pdf_inspector::MarkdownProfile::Compact;
}
markdown.include_page_numbers = page_numbers;
let mut pdf_options = OcrPdfOptions::new()
.render(RenderOptions::new().dpi(dpi))
.ocr(ocr)
.markdown(markdown)
.hosted_recommendation_confidence(hosted_threshold);
if let Some(pages) = page_filter.clone() {
pdf_options = pdf_options.page_numbers(pages);
}
if let Some(password) = password.clone() {
pdf_options = pdf_options.password(password);
}
match process_pdf_with_ocr(pdf_path, pdf_options) {
Ok(result) => {
if json_output {
println!("{}", format_ocr_json(&result));
} else if raw_output {
print!("{}", result.markdown);
} else {
eprintln!("PDF to Markdown Conversion (OCR)");
eprintln!("======================================");
eprintln!("File: {pdf_path}");
eprintln!("Pages: {}", result.page_count);
eprintln!("Pages routed to OCR: {:?}", result.pages_routed_to_ocr);
if !result.pages_recommending_hosted.is_empty() {
eprintln!(
"Hosted parsing recommended for pages: {:?}",
result.pages_recommending_hosted
);
}
eprintln!("Processing time: {}ms", result.processing_time_ms);
if let Some(output) = output_file {
fs::write(output, &result.markdown)
.expect("Failed to write output file");
eprintln!("Markdown written to: {output}");
} else {
eprintln!();
eprintln!("--- Markdown Output ---");
eprintln!();
print!("{}", result.markdown);
}
}
}
Err(error) => {
exit_ocr_error(&error.to_string(), json_output);
}
}
return;
}
}
if items_json_output {
match extract_items_json(pdf_path, page_filter.as_ref(), password.as_deref()) {
Ok(json) => println!("{}", json),
@@ -294,11 +604,6 @@ fn main() {
return;
}
let output_file = args
.get(2)
.filter(|a| !a.starts_with("--"))
.map(|s| s.as_str());
let process_mode = if detect_only {
ProcessMode::DetectOnly
} else if analyze {
+179 -12
View File
@@ -459,8 +459,44 @@ pub fn extract_pages_markdown_mem(
buffer: &[u8],
pages: Option<&[u32]>,
) -> Result<PagesExtractionResult, PdfError> {
extract_pages_markdown_mem_impl(
buffer,
pages,
None,
&MarkdownOptions::default(),
false,
false,
)
.map(|(result, _)| result)
}
#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
pub(crate) fn extract_pages_markdown_mem_for_ocr(
buffer: &[u8],
pages: Option<&[u32]>,
password: Option<&str>,
markdown_options: &MarkdownOptions,
) -> Result<(PagesExtractionResult, u32), PdfError> {
extract_pages_markdown_mem_impl(
buffer,
pages,
password,
markdown_options,
markdown_options.strip_headers_footers,
true,
)
}
fn extract_pages_markdown_mem_impl(
buffer: &[u8],
pages: Option<&[u32]>,
password: Option<&str>,
markdown_options: &MarkdownOptions,
strip_repeated_headers_footers: bool,
preserve_ocr_candidates: bool,
) -> Result<(PagesExtractionResult, u32), PdfError> {
validate_pdf_bytes(buffer)?;
let (doc, page_count) = load_document_from_mem(buffer)?;
let (doc, page_count) = load_document_from_mem_with_password(buffer, password)?;
let font_cmaps = FontCMaps::from_doc(&doc);
// Extract ALL pages to get accurate, document-wide font stats. A malformed
@@ -503,6 +539,11 @@ pub fn extract_pages_markdown_mem(
// Compute font stats from full document (cross-page consistency).
let font_stats = markdown::analysis::calculate_font_stats_from_items(&filtered_items);
let repeated_header_footer_items = if strip_repeated_headers_footers {
repeated_header_footer_item_keys(&all_items, &page_thresholds, &chart_regions, page_count)
} else {
HashSet::new()
};
// When caller doesn't specify pages, return every page in document order.
let all_pages: Vec<u32>;
@@ -538,7 +579,10 @@ pub fn extract_pages_markdown_mem(
let (page_items, page_number_removal_mask): (Vec<TextItem>, Vec<bool>) = all_items
.iter()
.zip(&page_number_removal_mask)
.filter(|(item, _)| item.page == page_1idx)
.filter(|(item, _)| {
item.page == page_1idx
&& !repeated_header_footer_items.contains(&HeaderFooterItemKey::from(*item))
})
.map(|(item, remove)| (item.clone(), *remove))
.unzip();
@@ -575,7 +619,7 @@ pub fn extract_pages_markdown_mem(
base_font_size: Some(font_stats.most_common_size),
include_page_numbers: false,
strip_headers_footers: false,
..MarkdownOptions::default()
..markdown_options.clone()
};
let md = if has_text_quality_issue {
@@ -628,20 +672,143 @@ pub fn extract_pages_markdown_mem(
results.push(PageMarkdown {
page: page_0idx,
markdown: if needs_ocr { String::new() } else { md },
// The public native extractor continues to suppress unreliable
// text. The OCR orchestrator retains clean partial text
// internally so it can compare/fuse it with OCR before deciding
// what is safe to return.
markdown: if needs_ocr && !preserve_ocr_candidates {
String::new()
} else {
md
},
needs_ocr,
ocr_reason,
});
}
Ok(PagesExtractionResult {
pages: results,
pages_with_tables: complexity.pages_with_tables,
pages_with_columns: complexity.pages_with_columns,
pages_needing_ocr,
ocr_reasons_by_page: page_ocr_reasons_vec(ocr_reasons_by_page),
is_complex: complexity.is_complex,
})
Ok((
PagesExtractionResult {
pages: results,
pages_with_tables: complexity.pages_with_tables,
pages_with_columns: complexity.pages_with_columns,
pages_needing_ocr,
ocr_reasons_by_page: page_ocr_reasons_vec(ocr_reasons_by_page),
is_complex: complexity.is_complex,
},
page_count,
))
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct HeaderFooterItemKey {
page: u32,
x: u32,
y: u32,
text: String,
}
impl From<&TextItem> for HeaderFooterItemKey {
fn from(item: &TextItem) -> Self {
Self {
page: item.page,
x: item.x.to_bits(),
y: item.y.to_bits(),
text: item.text.clone(),
}
}
}
fn repeated_header_footer_item_keys(
items: &[TextItem],
page_thresholds: &HashMap<u32, f32>,
chart_regions: &HashMap<u32, Vec<(f32, f32, f32, f32)>>,
page_count: u32,
) -> HashSet<HeaderFooterItemKey> {
let candidates = items
.iter()
.filter(|item| {
matches!(
item.item_type,
types::ItemType::Text | types::ItemType::FormField
)
})
.cloned()
.collect();
let lines = extractor::group_prefiltered_items_into_lines_with_thresholds_and_charts(
candidates,
page_thresholds,
&HashSet::new(),
chart_regions,
);
let all_items: HashSet<_> = lines
.iter()
.flat_map(|line| line.items.iter().map(HeaderFooterItemKey::from))
.collect();
let kept = markdown::strip_repeated_header_footer_lines(lines, page_count);
let kept_items: HashSet<_> = kept
.iter()
.flat_map(|line| line.items.iter().map(HeaderFooterItemKey::from))
.collect();
all_items.difference(&kept_items).cloned().collect()
}
#[cfg(all(test, feature = "ocr", not(target_arch = "wasm32")))]
mod ocr_header_footer_tests {
use super::*;
fn item(page: u32, text: &str, y: f32) -> TextItem {
TextItem {
text: text.to_string(),
x: 10.0,
y,
width: 120.0,
height: 10.0,
font: "Test".to_string(),
font_size: 10.0,
page,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: types::ItemType::Text,
mcid: None,
}
}
#[test]
fn local_pipeline_prefilters_document_wide_repeated_headers() {
let mut items = Vec::new();
let mut thresholds = HashMap::new();
for page in 1..=3 {
items.push(item(page, "Repeated report header", 800.0));
for line in 0..12 {
items.push(item(
page,
&format!("Page {page} paragraph {line} unique content"),
700.0 - line as f32 * 40.0,
));
}
thresholds.insert(page, 0.1);
}
let removed = repeated_header_footer_item_keys(&items, &thresholds, &HashMap::new(), 3);
assert_eq!(removed.len(), 2);
for page in 1..=3 {
assert_eq!(
removed.contains(&HeaderFooterItemKey::from(&item(
page,
"Repeated report header",
800.0,
))),
page > 1,
);
assert!(!removed.contains(&HeaderFooterItemKey::from(&item(
page,
&format!("Page {page} paragraph 5 unique content"),
500.0,
))));
}
}
}
/// Path-based wrapper for [`extract_pages_markdown_mem`].
+8
View File
@@ -1144,6 +1144,14 @@ pub fn to_markdown(text: &str, options: MarkdownOptions) -> String {
output
}
/// Applies the document-wide repeated header/footer classifier to grouped lines.
pub(crate) fn strip_repeated_header_footer_lines(
lines: Vec<crate::types::TextLine>,
page_count: u32,
) -> Vec<crate::types::TextLine> {
preprocess::strip_repeated_lines(lines, page_count)
}
/// Convert positioned text items to markdown with structure detection
pub fn to_markdown_from_items(items: Vec<TextItem>, options: MarkdownOptions) -> String {
to_markdown_from_items_with_rects(items, options, &[])
+17
View File
@@ -442,6 +442,16 @@ fn clean_table_cells(cells: &[Vec<String>]) -> (Vec<Vec<String>>, Vec<String>) {
fn is_footnote_row(text: &str) -> bool {
let trimmed = text.trim();
// Japanese documents commonly use the reference mark followed by an
// ASCII or full-width number (for example `※1` / `※1`). These rows often
// sit immediately below a wide table and must not be merged into its last
// data row as wrapped first-column content.
if let Some(rest) = trimmed.strip_prefix('※') {
return rest.chars().next().is_some_and(|character| {
character.is_ascii_digit() || (''..='').contains(&character)
});
}
// Check for common footnote patterns
// (1), (2), etc.
if trimmed.starts_with('(') && trimmed.len() >= 2 {
@@ -503,6 +513,13 @@ mod tests {
assert!(is_footnote_row("NOTES: uppercase"));
}
#[test]
fn test_is_footnote_row_reference_mark_number() {
assert!(is_footnote_row("※1 explanation"));
assert!(is_footnote_row("※1 説明"));
assert!(!is_footnote_row("※ general marker"));
}
#[test]
fn test_is_footnote_row_plain_text_false() {
assert!(!is_footnote_row("Regular cell text"));
+2 -2
View File
@@ -604,7 +604,7 @@ mod tests {
let items: Vec<(usize, &TextItem)> = vec![];
assert_eq!(
find_column_boundaries(&items, TableDetectionMode::SmallFont),
vec![]
Vec::<f32>::new()
);
}
@@ -661,7 +661,7 @@ mod tests {
#[test]
fn test_find_row_boundaries_empty() {
let items: Vec<(usize, &TextItem)> = vec![];
assert_eq!(find_row_boundaries(&items), vec![]);
assert_eq!(find_row_boundaries(&items), Vec::<f32>::new());
}
#[test]
+3 -152
View File
@@ -1,4 +1,4 @@
//! Public contracts between rendering, OCR, layout, and orchestration.
//! Public contracts between rendering, OCR, and orchestration.
use std::error::Error;
use std::path::PathBuf;
@@ -18,19 +18,6 @@ pub enum OcrMode {
Force,
}
/// Resource/quality profile for the OCR engine.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum OcrProfile {
/// Lowest latency and memory footprint.
Edge,
/// OCR-oriented balance of quality and CPU cost.
#[default]
Balanced,
/// Highest quality within the lightweight model family.
Quality,
}
/// Controls whether missing model artifacts may be fetched.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
@@ -47,12 +34,8 @@ pub enum ModelDownloadPolicy {
pub struct OcrOptions {
/// Page-level routing behavior.
pub mode: OcrMode,
/// Local quality/resource profile.
pub profile: OcrProfile,
/// Drop recognition spans below this confidence threshold.
pub minimum_confidence: f32,
/// Optional language hints understood by the selected engine.
pub languages: Vec<String>,
/// Optional directory containing an offline model set.
pub model_directory: Option<PathBuf>,
/// Whether a missing pinned artifact may be downloaded.
@@ -63,9 +46,7 @@ impl Default for OcrOptions {
fn default() -> Self {
Self {
mode: OcrMode::Off,
profile: OcrProfile::Balanced,
minimum_confidence: 0.0,
languages: Vec::new(),
model_directory: None,
model_downloads: ModelDownloadPolicy::IfMissing,
}
@@ -84,24 +65,12 @@ impl OcrOptions {
self
}
/// Sets the local resource/quality profile.
pub fn profile(mut self, profile: OcrProfile) -> Self {
self.profile = profile;
self
}
/// Sets the minimum accepted recognition confidence.
pub fn minimum_confidence(mut self, minimum_confidence: f32) -> Self {
self.minimum_confidence = minimum_confidence;
self
}
/// Replaces the language hints passed to the OCR engine.
pub fn languages(mut self, languages: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.languages = languages.into_iter().map(Into::into).collect();
self
}
/// Uses an explicit model directory, suitable for offline packaging.
pub fn model_directory(mut self, directory: impl Into<PathBuf>) -> Self {
self.model_directory = Some(directory.into());
@@ -115,55 +84,6 @@ impl OcrOptions {
}
}
/// Configuration for an optional learned layout engine.
///
/// Layout inference is disabled by default. Existing deterministic layout,
/// table, and Markdown logic remains the assembly path when this is disabled.
#[derive(Debug, Clone, PartialEq)]
pub struct LayoutOptions {
/// Whether the learned layout extension may run.
pub enabled: bool,
/// Drop layout regions below this confidence threshold.
pub minimum_confidence: f32,
/// Optional directory containing an offline layout model set.
pub model_directory: Option<PathBuf>,
}
impl Default for LayoutOptions {
fn default() -> Self {
Self {
enabled: false,
minimum_confidence: 0.0,
model_directory: None,
}
}
}
impl LayoutOptions {
/// Creates layout options with learned layout disabled.
pub fn new() -> Self {
Self::default()
}
/// Enables or disables learned layout inference.
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
/// Sets the minimum accepted region confidence.
pub fn minimum_confidence(mut self, minimum_confidence: f32) -> Self {
self.minimum_confidence = minimum_confidence;
self
}
/// Uses an explicit layout model directory.
pub fn model_directory(mut self, directory: impl Into<PathBuf>) -> Self {
self.model_directory = Some(directory.into());
self
}
}
/// A point in bitmap space, measured from the top-left in pixels.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct ImagePoint {
@@ -230,7 +150,7 @@ pub struct OcrSpan {
#[derive(Debug, Clone, PartialEq)]
pub struct OcrPage {
/// 1-indexed PDF page number.
pub page: u32,
pub page_number: u32,
/// Positioned recognition spans.
pub spans: Vec<OcrSpan>,
/// Mean confidence across accepted spans, when available.
@@ -243,54 +163,6 @@ pub struct OcrPage {
pub warnings: Vec<String>,
}
/// Normalized semantic class emitted by a learned layout engine.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum LayoutRegionKind {
/// Body or other prose text.
Text,
/// Document heading or title.
Heading,
/// Table region.
Table,
/// Figure/image region.
Figure,
/// Figure or table caption.
Caption,
/// Header/footer/page furniture.
Furniture,
/// Model-specific class retained without changing the common taxonomy.
Other(String),
}
/// One learned layout region in bitmap coordinates.
#[derive(Debug, Clone, PartialEq)]
pub struct LayoutRegion {
/// Normalized semantic class.
pub kind: LayoutRegionKind,
/// Region polygon in the original rendered page's pixel space.
pub polygon: ImageQuad,
/// Model confidence in the inclusive range 01.
pub confidence: f32,
/// Optional model-provided reading-order position.
pub reading_order: Option<u32>,
}
/// Learned layout output for one 1-indexed page.
#[derive(Debug, Clone, PartialEq)]
pub struct LayoutPage {
/// 1-indexed PDF page number.
pub page: u32,
/// Semantic regions.
pub regions: Vec<LayoutRegion>,
/// Exact model identity used for this result.
pub model: ModelIdentity,
/// Layout inference wall time for this page.
pub processing_time_ms: u64,
/// Non-fatal engine warnings.
pub warnings: Vec<String>,
}
/// How final page content was sourced.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
@@ -310,8 +182,6 @@ pub struct VisionTimings {
pub render_ms: u64,
/// OCR wall time.
pub ocr_ms: u64,
/// Optional learned layout wall time.
pub layout_ms: u64,
/// Native/OCR fusion and assembly wall time.
pub assembly_ms: u64,
}
@@ -320,13 +190,11 @@ pub struct VisionTimings {
#[derive(Debug, Clone, PartialEq)]
pub struct PageProvenance {
/// 1-indexed PDF page number.
pub page: u32,
pub page_number: u32,
/// Final page-content source.
pub source: PageContentSource,
/// OCR model, when OCR ran.
pub ocr_model: Option<ModelIdentity>,
/// Learned layout model, when layout inference ran.
pub layout_model: Option<ModelIdentity>,
/// Render resolution used for local vision.
pub render_dpi: Option<f32>,
/// Mean accepted OCR confidence, when available.
@@ -371,23 +239,6 @@ pub trait OcrEngine: Send + Sync {
) -> Result<Vec<OcrPage>, Self::Error>;
}
/// Optional learned semantic layout extension.
pub trait LayoutEngine: Send + Sync {
/// Engine-specific failure type.
type Error: Error + Send + Sync + 'static;
/// Exact model identity used by this engine instance.
fn model(&self) -> &ModelIdentity;
/// Analyzes rendered pages, optionally using their OCR spans.
fn analyze(
&self,
pages: &[RenderedPage],
ocr: &[OcrPage],
options: &LayoutOptions,
) -> Result<Vec<LayoutPage>, Self::Error>;
}
#[cfg(test)]
mod tests {
use super::*;
+84
View File
@@ -0,0 +1,84 @@
//! HTTPS acquisition for pinned local model artifacts.
use std::fmt;
use std::io::Read;
use std::time::Duration;
use thiserror::Error;
use super::{ModelArtifact, ModelDownloader};
/// Default end-to-end timeout for one model artifact request.
pub const DEFAULT_MODEL_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(5 * 60);
/// Streaming HTTPS downloader used by lazy model resolution.
#[derive(Clone)]
pub struct HttpModelDownloader {
agent: ureq::Agent,
}
impl fmt::Debug for HttpModelDownloader {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("HttpModelDownloader")
.finish_non_exhaustive()
}
}
impl Default for HttpModelDownloader {
fn default() -> Self {
Self::new(DEFAULT_MODEL_DOWNLOAD_TIMEOUT)
}
}
impl HttpModelDownloader {
/// Creates an HTTPS-only downloader with an end-to-end request timeout.
pub fn new(timeout: Duration) -> Self {
let config = ureq::Agent::config_builder()
.https_only(true)
.timeout_global(Some(timeout))
.user_agent(concat!("pdf-inspector/", env!("CARGO_PKG_VERSION")))
.build();
Self {
agent: ureq::Agent::new_with_config(config),
}
}
}
impl ModelDownloader for HttpModelDownloader {
type Error = HttpModelDownloadError;
fn open(&self, artifact: &ModelArtifact) -> Result<Box<dyn Read + Send>, Self::Error> {
let response = self.agent.get(artifact.url).call()?;
if let Some(actual) = response.body().content_length() {
if actual != artifact.size {
return Err(HttpModelDownloadError::ContentLength {
expected: artifact.size,
actual,
});
}
}
// One extra byte lets ModelStore report an exact size mismatch while
// preventing a malicious or broken server from filling the disk.
let limit = artifact.size.saturating_add(1);
Ok(Box::new(response.into_body().into_reader().take(limit)))
}
}
/// Failures before a response stream reaches [`super::ModelStore`].
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum HttpModelDownloadError {
/// DNS, TLS, redirect, HTTP status, or response-stream setup failed.
#[error(transparent)]
Request(#[from] ureq::Error),
/// The server declared a size that disagrees with the pinned manifest.
#[error("server declared {actual} bytes; manifest requires {expected}")]
ContentLength {
/// Pinned artifact size.
expected: u64,
/// Server-declared size.
actual: u64,
},
}
+1440
View File
File diff suppressed because it is too large Load Diff
+36 -8
View File
@@ -3,34 +3,62 @@
//! The existing lopdf extractor remains the default path. Native page
//! rendering is available only with the `render-pdfium` feature. Engine
//! contracts are available with `vision`, while checksum-verified model
//! resolution is a separate `model-cache` feature. These remain separate so
//! browser WASM, text-only consumers, and renderer-only users take on no model
//! management dependencies.
//! resolution is a separate `model-cache` feature. The `ocr-oar` feature adds
//! a CPU PP-OCRv6 Small implementation of [`OcrEngine`]. These remain separate
//! so browser WASM, text-only consumers, and renderer-only users take on no
//! model-management or inference dependencies.
#[cfg(all(feature = "vision", not(target_arch = "wasm32")))]
mod contracts;
#[cfg(all(feature = "model-download", not(target_arch = "wasm32")))]
mod download;
#[cfg(all(feature = "vision", not(target_arch = "wasm32")))]
mod fusion;
#[cfg(all(feature = "model-cache", not(target_arch = "wasm32")))]
mod models;
#[cfg(all(feature = "ocr-oar", not(target_arch = "wasm32")))]
mod oar;
#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
mod pipeline;
#[cfg(all(feature = "vision", not(target_arch = "wasm32")))]
mod render;
#[cfg(all(feature = "vision", not(target_arch = "wasm32")))]
mod routing;
#[cfg(all(feature = "render-pdfium", not(target_arch = "wasm32")))]
mod pdfium;
#[cfg(all(feature = "vision", not(target_arch = "wasm32")))]
pub use contracts::{
ImagePoint, ImageQuad, LayoutEngine, LayoutOptions, LayoutPage, LayoutRegion, LayoutRegionKind,
ModelDownloadPolicy, ModelIdentity, OcrEngine, OcrMode, OcrOptions, OcrPage, OcrProfile,
OcrSpan, PageContentSource, PageProvenance, PageRenderer, VisionTimings,
ImagePoint, ImageQuad, ModelDownloadPolicy, ModelIdentity, OcrEngine, OcrMode, OcrOptions,
OcrPage, OcrSpan, PageContentSource, PageProvenance, PageRenderer, VisionTimings,
};
#[cfg(all(feature = "model-download", not(target_arch = "wasm32")))]
pub use download::{HttpModelDownloadError, HttpModelDownloader, DEFAULT_MODEL_DOWNLOAD_TIMEOUT};
#[cfg(all(feature = "vision", not(target_arch = "wasm32")))]
pub use fusion::{
fuse_ocr_pages, ocr_page_to_markdown, FusedPageMarkdown, FusedPages, OcrFusionError,
OcrFusionOptions,
};
#[cfg(all(feature = "model-cache", not(target_arch = "wasm32")))]
pub use models::{
ModelArtifact, ModelArtifactKind, ModelManifest, ModelPaths, ModelStore, ModelStoreError,
PP_OCR_V6_SMALL,
ModelAcquireError, ModelArtifact, ModelArtifactKind, ModelDownloader, ModelManifest,
ModelPaths, ModelStore, ModelStoreError, PP_OCR_V6_SMALL,
};
#[cfg(all(feature = "ocr-oar", not(target_arch = "wasm32")))]
pub use oar::{OarOcrEngine, OarOcrError, ONNX_RUNTIME_LIBRARY_ENV};
#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
pub use pipeline::{
process_pdf_with_ocr, process_pdf_with_ocr_mem, OcrPdfOptions, OcrPdfResult, OcrPipelineError,
};
#[cfg(all(feature = "vision", not(target_arch = "wasm32")))]
pub use render::{
PagePoint, PageTransform, RenderBufferError, RenderOptions, RenderPixelFormat, RenderedPage,
DEFAULT_RENDER_DPI,
};
#[cfg(all(feature = "vision", not(target_arch = "wasm32")))]
pub use routing::{
route_ocr_pages, run_ocr_pages, OcrRoutingError, OcrRun, OcrRunError, RoutedOcrPage,
};
#[cfg(all(feature = "render-pdfium", not(target_arch = "wasm32")))]
+298 -60
View File
@@ -12,7 +12,7 @@ use fs2::FileExt;
use sha2::{Digest, Sha256};
use thiserror::Error;
use super::OcrOptions;
use super::{ModelDownloadPolicy, OcrOptions};
/// Environment variable overriding the default local model cache.
pub const MODEL_CACHE_ENV: &str = "PDF_INSPECTOR_MODEL_CACHE";
@@ -138,6 +138,19 @@ pub struct ModelStore {
override_root: Option<PathBuf>,
}
/// Streaming source for a pinned model artifact.
///
/// Implementations may use HTTP, an object store, or an application-owned
/// package manager. [`ModelStore`] remains responsible for locking, atomic
/// installation, exact size validation, and SHA-256 verification.
pub trait ModelDownloader: Send + Sync {
/// Downloader-specific failure type.
type Error: std::error::Error + Send + Sync + 'static;
/// Opens a streaming reader for one manifest artifact.
fn open(&self, artifact: &ModelArtifact) -> Result<Box<dyn Read + Send>, Self::Error>;
}
impl ModelStore {
/// Creates a model store rooted at an explicit cache directory.
pub fn new(cache_root: impl Into<PathBuf>) -> Self {
@@ -177,16 +190,17 @@ impl ModelStore {
&self.cache_root
}
/// Effective directory containing one manifest's artifacts.
pub(crate) fn model_root(&self, manifest: &ModelManifest) -> PathBuf {
self.override_root
.clone()
.unwrap_or_else(|| self.manifest_cache_root(manifest))
}
/// Validates and resolves every required artifact.
pub fn resolve(&self, manifest: &ModelManifest) -> Result<ModelPaths, ModelStoreError> {
validate_manifest(manifest)?;
let managed_root;
let root = if let Some(root) = self.override_root.as_deref() {
root
} else {
managed_root = self.manifest_cache_root(manifest);
managed_root.as_path()
};
let root = self.model_root(manifest);
let mut artifacts = BTreeMap::new();
for artifact in manifest.artifacts {
@@ -201,6 +215,54 @@ impl ModelStore {
})
}
/// Resolves a complete model set, fetching only missing or invalid managed
/// cache artifacts when policy permits.
///
/// Explicit [`OcrOptions::model_directory`] overrides are never mutated or
/// supplemented from the network. This method also avoids all downloader
/// calls when the cache is already valid or downloads are offline.
pub fn resolve_or_download<D: ModelDownloader>(
&self,
manifest: &ModelManifest,
policy: ModelDownloadPolicy,
downloader: &D,
) -> Result<ModelPaths, ModelAcquireError<D::Error>> {
validate_manifest(manifest).map_err(ModelAcquireError::Store)?;
match self.resolve(manifest) {
Ok(paths) => return Ok(paths),
Err(source) if self.override_root.is_some() => {
return Err(ModelAcquireError::ExplicitDirectoryIncomplete { source });
}
Err(source) if !source.permits_download_recovery() => {
return Err(ModelAcquireError::Store(source));
}
Err(source) if policy == ModelDownloadPolicy::Offline => {
return Err(ModelAcquireError::DownloadsDisabled { source });
}
Err(_) => {}
}
let root = self.manifest_cache_root(manifest);
for artifact in manifest.artifacts {
let _lock = lock_artifact(&root, artifact).map_err(ModelAcquireError::Store)?;
match verify_artifact(&root.join(artifact.filename), artifact) {
Ok(()) => continue,
Err(source) if source.permits_download_recovery() => {}
Err(source) => return Err(ModelAcquireError::Store(source)),
}
let reader =
downloader
.open(artifact)
.map_err(|source| ModelAcquireError::Download {
kind: artifact.kind,
url: artifact.url,
source,
})?;
install_locked(&root, artifact, reader).map_err(ModelAcquireError::Store)?;
}
self.resolve(manifest).map_err(ModelAcquireError::Store)
}
/// Atomically installs one artifact from a reader after validating its
/// exact size and SHA-256 digest.
///
@@ -219,58 +281,8 @@ impl ModelStore {
.find(|artifact| artifact.kind == kind)
.ok_or(ModelStoreError::ArtifactNotInManifest { kind })?;
let root = self.manifest_cache_root(manifest);
fs::create_dir_all(&root).map_err(|source| ModelStoreError::Io {
path: root.clone(),
source,
})?;
let target = root.join(artifact.filename);
let lock_path = root.join(format!(".{}.lock", artifact.filename));
let lock = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&lock_path)
.map_err(|source| ModelStoreError::Io {
path: lock_path.clone(),
source,
})?;
FileExt::lock_exclusive(&lock).map_err(|source| ModelStoreError::Io {
path: lock_path,
source,
})?;
if verify_artifact(&target, artifact).is_ok() {
return Ok(target);
}
sweep_stale_install_files(&root, artifact.filename)?;
let (temporary, mut output) = create_temporary_file(&root, artifact.filename)?;
let result = (|| {
let mut limited = reader.by_ref().take(artifact.size.saturating_add(1));
let (size, digest) =
copy_and_hash(&mut limited, &mut output).map_err(|source| ModelStoreError::Io {
path: temporary.clone(),
source,
})?;
output.sync_all().map_err(|source| ModelStoreError::Io {
path: temporary.clone(),
source,
})?;
validate_size_and_hash(artifact, size, &digest, &temporary)?;
replace_file_atomic(&temporary, &target).map_err(|source| ModelStoreError::Io {
path: target.clone(),
source,
})?;
Ok(target.clone())
})();
if result.is_err() {
let _ = fs::remove_file(&temporary);
}
result
let _lock = lock_artifact(&root, artifact)?;
install_locked(&root, artifact, &mut reader)
}
fn manifest_cache_root(&self, manifest: &ModelManifest) -> PathBuf {
@@ -278,6 +290,43 @@ impl ModelStore {
}
}
/// Failures while resolving or lazily acquiring a model set.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ModelAcquireError<E>
where
E: std::error::Error + Send + Sync + 'static,
{
/// Manifest validation, cache verification, or installation failed.
#[error(transparent)]
Store(#[from] ModelStoreError),
/// A managed-cache artifact could not be downloaded.
#[error("failed to download {kind:?} from {url}: {source}")]
Download {
/// Artifact role.
kind: ModelArtifactKind,
/// Pinned source URL.
url: &'static str,
/// Downloader failure.
#[source]
source: E,
},
/// Offline policy prevented acquisition of an unavailable artifact.
#[error("model artifacts are unavailable and downloads are disabled: {source}")]
DownloadsDisabled {
/// Original verification failure.
#[source]
source: ModelStoreError,
},
/// An explicit package-managed directory was incomplete or invalid.
#[error("explicit model directory is incomplete or invalid: {source}")]
ExplicitDirectoryIncomplete {
/// Original verification failure.
#[source]
source: ModelStoreError,
},
}
/// Failures while validating or installing model artifacts.
#[derive(Debug, Error)]
#[non_exhaustive]
@@ -333,6 +382,17 @@ pub enum ModelStoreError {
},
}
impl ModelStoreError {
fn permits_download_recovery(&self) -> bool {
matches!(
self,
Self::MissingArtifact { .. }
| Self::SizeMismatch { .. }
| Self::ChecksumMismatch { .. }
)
}
}
fn validate_manifest(manifest: &ModelManifest) -> Result<(), ModelStoreError> {
if manifest.schema_version != 1 {
return Err(ModelStoreError::InvalidManifest(format!(
@@ -403,6 +463,67 @@ fn is_single_normal_path_component(value: &str) -> bool {
matches!(components.next(), Some(Component::Normal(_))) && components.next().is_none()
}
fn lock_artifact(root: &Path, artifact: &ModelArtifact) -> Result<File, ModelStoreError> {
fs::create_dir_all(root).map_err(|source| ModelStoreError::Io {
path: root.to_path_buf(),
source,
})?;
let lock_path = root.join(format!(".{}.lock", artifact.filename));
let lock = OpenOptions::new()
.create(true)
.read(true)
.write(true)
.truncate(false)
.open(&lock_path)
.map_err(|source| ModelStoreError::Io {
path: lock_path.clone(),
source,
})?;
FileExt::lock_exclusive(&lock).map_err(|source| ModelStoreError::Io {
path: lock_path,
source,
})?;
Ok(lock)
}
fn install_locked(
root: &Path,
artifact: &ModelArtifact,
mut reader: impl Read,
) -> Result<PathBuf, ModelStoreError> {
let target = root.join(artifact.filename);
if verify_artifact(&target, artifact).is_ok() {
return Ok(target);
}
sweep_stale_install_files(root, artifact.filename)?;
let (temporary, mut output) = create_temporary_file(root, artifact.filename)?;
let result = (|| {
let mut limited = reader.by_ref().take(artifact.size.saturating_add(1));
let (size, digest) =
copy_and_hash(&mut limited, &mut output).map_err(|source| ModelStoreError::Io {
path: temporary.clone(),
source,
})?;
output.sync_all().map_err(|source| ModelStoreError::Io {
path: temporary.clone(),
source,
})?;
validate_size_and_hash(artifact, size, &digest, &temporary)?;
replace_file_atomic(&temporary, &target).map_err(|source| ModelStoreError::Io {
path: target.clone(),
source,
})?;
Ok(target.clone())
})();
if result.is_err() {
let _ = fs::remove_file(&temporary);
}
result
}
fn sweep_stale_install_files(root: &Path, filename: &str) -> Result<(), ModelStoreError> {
let prefix = format!(".{filename}.");
for entry in fs::read_dir(root).map_err(|source| ModelStoreError::Io {
@@ -601,6 +722,34 @@ mod tests {
artifacts: TEST_ARTIFACTS,
};
#[derive(Debug)]
struct StaticDownloader {
bytes: &'static [u8],
requests: std::sync::Mutex<Vec<ModelArtifactKind>>,
}
impl StaticDownloader {
fn new(bytes: &'static [u8]) -> Self {
Self {
bytes,
requests: std::sync::Mutex::new(Vec::new()),
}
}
fn request_count(&self) -> usize {
self.requests.lock().unwrap().len()
}
}
impl ModelDownloader for StaticDownloader {
type Error = io::Error;
fn open(&self, artifact: &ModelArtifact) -> Result<Box<dyn Read + Send>, Self::Error> {
self.requests.lock().unwrap().push(artifact.kind);
Ok(Box::new(io::Cursor::new(self.bytes)))
}
}
#[test]
fn pinned_pp_ocr_manifest_is_well_formed() {
validate_manifest(&PP_OCR_V6_SMALL).unwrap();
@@ -764,4 +913,93 @@ mod tests {
assert_eq!(first, second);
assert_eq!(fs::read(first).unwrap(), b"hello");
}
#[test]
fn resolve_or_download_fetches_once_then_reuses_verified_cache() {
let temp = tempfile::tempdir().unwrap();
let store = ModelStore::new(temp.path());
let downloader = StaticDownloader::new(b"hello");
let first = store
.resolve_or_download(&TEST_MANIFEST, ModelDownloadPolicy::IfMissing, &downloader)
.unwrap();
let second = store
.resolve_or_download(&TEST_MANIFEST, ModelDownloadPolicy::IfMissing, &downloader)
.unwrap();
assert_eq!(first, second);
assert_eq!(downloader.request_count(), 1);
}
#[test]
fn concurrent_resolve_downloads_once_under_the_artifact_lock() {
let temp = tempfile::tempdir().unwrap();
let store = ModelStore::new(temp.path());
let downloader = std::sync::Arc::new(StaticDownloader::new(b"hello"));
let barrier = std::sync::Arc::new(std::sync::Barrier::new(3));
let handles: Vec<_> = (0..2)
.map(|_| {
let store = store.clone();
let downloader = std::sync::Arc::clone(&downloader);
let barrier = std::sync::Arc::clone(&barrier);
std::thread::spawn(move || {
barrier.wait();
store.resolve_or_download(
&TEST_MANIFEST,
ModelDownloadPolicy::IfMissing,
downloader.as_ref(),
)
})
})
.collect();
barrier.wait();
for handle in handles {
handle.join().unwrap().unwrap();
}
assert_eq!(downloader.request_count(), 1);
}
#[cfg(unix)]
#[test]
fn cache_io_failures_do_not_trigger_downloads() {
use std::os::unix::fs::symlink;
let temp = tempfile::tempdir().unwrap();
let store = ModelStore::new(temp.path());
let root = store.manifest_cache_root(&TEST_MANIFEST);
fs::create_dir_all(&root).unwrap();
symlink("hello.txt", root.join("hello.txt")).unwrap();
let downloader = StaticDownloader::new(b"hello");
assert!(matches!(
store.resolve_or_download(&TEST_MANIFEST, ModelDownloadPolicy::IfMissing, &downloader,),
Err(ModelAcquireError::Store(ModelStoreError::Io { .. }))
));
assert_eq!(downloader.request_count(), 0);
}
#[test]
fn resolve_or_download_honors_offline_and_explicit_directory_boundaries() {
let cache = tempfile::tempdir().unwrap();
let override_dir = tempfile::tempdir().unwrap();
let downloader = StaticDownloader::new(b"hello");
let offline = ModelStore::new(cache.path())
.resolve_or_download(&TEST_MANIFEST, ModelDownloadPolicy::Offline, &downloader)
.unwrap_err();
assert!(matches!(
offline,
ModelAcquireError::DownloadsDisabled { .. }
));
let explicit = ModelStore::new(cache.path())
.override_root(override_dir.path())
.resolve_or_download(&TEST_MANIFEST, ModelDownloadPolicy::IfMissing, &downloader)
.unwrap_err();
assert!(matches!(
explicit,
ModelAcquireError::ExplicitDirectoryIncomplete { .. }
));
assert_eq!(downloader.request_count(), 0);
}
}
+496
View File
@@ -0,0 +1,496 @@
//! PP-OCRv6 Small implementation backed by OAR and ONNX Runtime.
use std::path::PathBuf;
use std::time::Instant;
use image::RgbImage;
use oar_ocr::core::config::onnx::OrtSessionConfig;
use oar_ocr::oarocr::{OAROCRBuilder, OAROCR};
use oar_ocr::processors::BoundingBox;
use thiserror::Error;
use super::{
ImagePoint, ImageQuad, ModelArtifactKind, ModelIdentity, ModelPaths, OcrEngine, OcrMode,
OcrOptions, OcrPage, OcrSpan, RenderPixelFormat, RenderedPage,
};
/// Environment variable selecting the ONNX Runtime shared library.
pub const ONNX_RUNTIME_LIBRARY_ENV: &str = "ORT_DYLIB_PATH";
/// Failures while constructing or running the OAR OCR backend.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum OarOcrError {
/// A required file is missing from the resolved model set.
#[error("resolved OCR model set is missing {kind:?}")]
MissingModelArtifact {
/// Missing artifact role.
kind: ModelArtifactKind,
},
/// OCR was invoked while the caller explicitly disabled it.
#[error("OCR is disabled; select Auto or Force before invoking the engine")]
OcrDisabled,
/// Confidence thresholds must match the normalized engine output range.
#[error("minimum OCR confidence must be finite and between 0 and 1, got {value}")]
InvalidMinimumConfidence {
/// Invalid threshold.
value: f32,
},
/// Bitmap dimension arithmetic exceeded the host address space.
#[error("rendered page {page} bitmap dimensions overflow the host address space")]
ImageSizeOverflow {
/// 1-indexed page number.
page: u32,
},
/// A validated renderer buffer could not be represented as an RGB image.
#[error("rendered page {page} could not be converted to an RGB image")]
InvalidImageBuffer {
/// 1-indexed page number.
page: u32,
},
/// The external ONNX Runtime shared library could not be loaded.
#[error(
"failed to load ONNX Runtime from {path}; install a compatible ONNX Runtime shared library or set ORT_DYLIB_PATH to its path: {source}"
)]
OnnxRuntimeLoad {
/// Requested shared-library path or platform library name.
path: PathBuf,
/// Dynamic-loader failure.
#[source]
source: ort::LoadDynamicError,
},
/// OAR returned no result for a submitted page.
#[error("OAR returned no result for rendered page {page}")]
MissingPageResult {
/// 1-indexed page number.
page: u32,
},
/// OAR or ONNX Runtime rejected the models or failed during inference.
#[error(transparent)]
Backend(#[from] oar_ocr::core::OCRError),
}
/// CPU PP-OCRv6 Small engine using OAR's detection and recognition pipeline.
///
/// Construction accepts only [`ModelPaths`] that have already passed
/// pdf-inspector's manifest size and SHA-256 verification. OAR's independent
/// model auto-download feature is deliberately not enabled.
#[derive(Debug)]
pub struct OarOcrEngine {
pipeline: OAROCR,
model: ModelIdentity,
}
impl OarOcrEngine {
/// Loads PP-OCRv6 Small from a resolved, verified model set.
pub fn from_models(models: &ModelPaths) -> Result<Self, OarOcrError> {
load_onnx_runtime()?;
let detection = required_model(models, ModelArtifactKind::TextDetection)?;
let recognition = required_model(models, ModelArtifactKind::TextRecognition)?;
let dictionary = required_model(models, ModelArtifactKind::CharacterDictionary)?;
let pipeline = OAROCRBuilder::new(detection, recognition, dictionary)
.ort_session(ocr_session_config())
// Document line crops often have very different widths. Keeping
// CPU recognition batches at one avoids padding every crop to the
// widest line, reducing both inference work and peak memory.
.region_batch_size(1)
.build()?;
let model = ModelIdentity::new(models.manifest_id(), models.revision());
Ok(Self { pipeline, model })
}
fn recognize_page(
&self,
page: &RenderedPage,
options: &OcrOptions,
) -> Result<OcrPage, OarOcrError> {
let started = Instant::now();
let image = rendered_page_to_rgb(page)?;
let result = self
.pipeline
.predict(vec![image])?
.into_iter()
.next()
.ok_or(OarOcrError::MissingPageResult { page: page.page() })?;
let mut spans = Vec::with_capacity(result.text_regions.len());
let mut invalid_geometry = 0usize;
let mut missing_recognition = 0usize;
for region in result.text_regions {
let (Some(text), Some(confidence)) = (region.text, region.confidence) else {
missing_recognition += 1;
continue;
};
if text.trim().is_empty() || !confidence.is_finite() {
missing_recognition += 1;
continue;
}
let confidence = confidence.clamp(0.0, 1.0);
if confidence < options.minimum_confidence {
continue;
}
let polygon = region.dt_poly.as_ref().unwrap_or(&region.bounding_box);
let Some(polygon) = bounding_box_to_quad(polygon, page.width(), page.height()) else {
invalid_geometry += 1;
continue;
};
spans.push(OcrSpan {
text: text.to_string(),
polygon,
confidence,
orientation_degrees: region.orientation_angle,
});
}
let mut warnings = Vec::new();
if missing_recognition > 0 {
warnings.push(format!(
"discarded {missing_recognition} regions without usable recognition output"
));
}
if invalid_geometry > 0 {
warnings.push(format!(
"discarded {invalid_geometry} recognized regions with invalid geometry"
));
}
let mean_confidence = if spans.is_empty() {
None
} else {
Some(spans.iter().map(|span| span.confidence).sum::<f32>() / spans.len() as f32)
};
let processing_time_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
Ok(OcrPage {
page_number: page.page(),
spans,
mean_confidence,
model: self.model.clone(),
processing_time_ms,
warnings,
})
}
}
fn ocr_session_config() -> OrtSessionConfig {
let available = std::thread::available_parallelism()
.map(std::num::NonZeroUsize::get)
.unwrap_or(1);
OrtSessionConfig::new()
.with_intra_threads(available.min(4))
.with_inter_threads(1)
.with_parallel_execution(false)
}
fn load_onnx_runtime() -> Result<(), OarOcrError> {
let path = onnx_runtime_library_path();
drop(
ort::init_from(&path).map_err(|source| OarOcrError::OnnxRuntimeLoad {
path: path.clone(),
source,
})?,
);
Ok(())
}
pub(crate) fn onnx_runtime_library_path() -> PathBuf {
std::env::var_os(ONNX_RUNTIME_LIBRARY_ENV)
.filter(|path| !path.is_empty())
.map(PathBuf::from)
.unwrap_or_else(default_onnx_runtime_library)
}
fn default_onnx_runtime_library() -> PathBuf {
#[cfg(target_os = "windows")]
const NAME: &str = "onnxruntime.dll";
#[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))]
const NAME: &str = "libonnxruntime.so";
#[cfg(any(target_os = "macos", target_os = "ios"))]
const NAME: &str = "libonnxruntime.dylib";
PathBuf::from(NAME)
}
impl OcrEngine for OarOcrEngine {
type Error = OarOcrError;
fn model(&self) -> &ModelIdentity {
&self.model
}
fn recognize(
&self,
pages: &[RenderedPage],
options: &OcrOptions,
) -> Result<Vec<OcrPage>, Self::Error> {
validate_options(options)?;
pages
.iter()
.map(|page| self.recognize_page(page, options))
.collect()
}
}
fn validate_options(options: &OcrOptions) -> Result<(), OarOcrError> {
if options.mode == OcrMode::Off {
return Err(OarOcrError::OcrDisabled);
}
if !options.minimum_confidence.is_finite() || !(0.0..=1.0).contains(&options.minimum_confidence)
{
return Err(OarOcrError::InvalidMinimumConfidence {
value: options.minimum_confidence,
});
}
Ok(())
}
fn required_model(
models: &ModelPaths,
kind: ModelArtifactKind,
) -> Result<&std::path::Path, OarOcrError> {
models
.get(kind)
.ok_or(OarOcrError::MissingModelArtifact { kind })
}
fn rendered_page_to_rgb(page: &RenderedPage) -> Result<RgbImage, OarOcrError> {
let width = usize::try_from(page.width())
.map_err(|_| OarOcrError::ImageSizeOverflow { page: page.page() })?;
let height = usize::try_from(page.height())
.map_err(|_| OarOcrError::ImageSizeOverflow { page: page.page() })?;
let output_len = width
.checked_mul(height)
.and_then(|pixels| pixels.checked_mul(3))
.ok_or(OarOcrError::ImageSizeOverflow { page: page.page() })?;
let input_bpp = page.format().bytes_per_pixel();
let active_input_row = width
.checked_mul(input_bpp)
.ok_or(OarOcrError::ImageSizeOverflow { page: page.page() })?;
let output_row = width
.checked_mul(3)
.ok_or(OarOcrError::ImageSizeOverflow { page: page.page() })?;
let mut rgb = vec![0u8; output_len];
for row in 0..height {
let input_start = row * page.stride();
let input = &page.pixels()[input_start..input_start + active_input_row];
let output_start = row * output_row;
let output = &mut rgb[output_start..output_start + output_row];
match page.format() {
RenderPixelFormat::Rgb8 => output.copy_from_slice(input),
RenderPixelFormat::Rgba8 => {
for (rgba, rgb) in input.chunks_exact(4).zip(output.chunks_exact_mut(3)) {
rgb.copy_from_slice(&rgba[..3]);
}
}
RenderPixelFormat::Gray8 => {
for (&gray, rgb) in input.iter().zip(output.chunks_exact_mut(3)) {
rgb.fill(gray);
}
}
}
}
RgbImage::from_raw(page.width(), page.height(), rgb)
.ok_or(OarOcrError::InvalidImageBuffer { page: page.page() })
}
fn bounding_box_to_quad(bounding_box: &BoundingBox, width: u32, height: u32) -> Option<ImageQuad> {
let points: Vec<ImagePoint> = bounding_box
.points
.iter()
.filter(|point| point.x.is_finite() && point.y.is_finite())
.map(|point| {
ImagePoint::new(
point.x.clamp(0.0, width as f32),
point.y.clamp(0.0, height as f32),
)
})
.collect();
if bounding_box.points.len() == 4 && points.len() == 4 && is_ordered_convex_quad(&points) {
return Some(ImageQuad::new([points[0], points[1], points[2], points[3]]));
}
if points.len() < 3 {
return None;
}
let min_x = points
.iter()
.map(|point| point.x)
.fold(f32::INFINITY, f32::min);
let max_x = points
.iter()
.map(|point| point.x)
.fold(f32::NEG_INFINITY, f32::max);
let min_y = points
.iter()
.map(|point| point.y)
.fold(f32::INFINITY, f32::min);
let max_y = points
.iter()
.map(|point| point.y)
.fold(f32::NEG_INFINITY, f32::max);
if max_x <= min_x || max_y <= min_y {
return None;
}
Some(ImageQuad::new([
ImagePoint::new(min_x, min_y),
ImagePoint::new(max_x, min_y),
ImagePoint::new(max_x, max_y),
ImagePoint::new(min_x, max_y),
]))
}
fn is_ordered_convex_quad(points: &[ImagePoint]) -> bool {
if points.len() != 4 {
return false;
}
let mut orientation = 0.0_f32;
for index in 0..4 {
let first = points[index];
let second = points[(index + 1) % 4];
let third = points[(index + 2) % 4];
let cross = (second.x - first.x) * (third.y - second.y)
- (second.y - first.y) * (third.x - second.x);
if cross.abs() <= f32::EPSILON {
return false;
}
if orientation == 0.0 {
orientation = cross.signum();
} else if cross.signum() != orientation {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use oar_ocr::processors::Point;
use super::*;
use crate::vision::PageTransform;
#[test]
fn cpu_session_budget_is_bounded_for_small_ocr_models() {
let config = ocr_session_config();
assert!((1..=4).contains(&config.intra_threads.unwrap()));
assert_eq!(config.inter_threads, Some(1));
assert_eq!(config.parallel_execution, Some(false));
}
fn page(format: RenderPixelFormat, stride: usize, pixels: Vec<u8>) -> RenderedPage {
let transform =
PageTransform::from_corners(2, 2, (0.0, 2.0), (2.0, 2.0), (0.0, 0.0)).unwrap();
RenderedPage::new(1, 2.0, 2.0, 2, 2, stride, format, pixels, transform).unwrap()
}
#[test]
fn converts_padded_rgb_without_exposing_padding() {
let page = page(
RenderPixelFormat::Rgb8,
8,
vec![1, 2, 3, 4, 5, 6, 99, 99, 7, 8, 9, 10, 11, 12, 99, 99],
);
let image = rendered_page_to_rgb(&page).unwrap();
assert_eq!(image.as_raw(), &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
}
#[test]
fn converts_rgba_and_gray_to_rgb() {
let rgba = page(
RenderPixelFormat::Rgba8,
8,
vec![1, 2, 3, 44, 4, 5, 6, 55, 7, 8, 9, 66, 10, 11, 12, 77],
);
assert_eq!(
rendered_page_to_rgb(&rgba).unwrap().as_raw(),
&[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
);
let gray = page(RenderPixelFormat::Gray8, 2, vec![1, 2, 3, 4]);
assert_eq!(
rendered_page_to_rgb(&gray).unwrap().as_raw(),
&[1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4]
);
}
#[test]
fn preserves_quads_and_clamps_them_to_the_bitmap() {
let bbox = BoundingBox::new(vec![
Point::new(-1.0, 2.0),
Point::new(11.0, 2.0),
Point::new(11.0, 9.0),
Point::new(-1.0, 9.0),
]);
let quad = bounding_box_to_quad(&bbox, 10, 8).unwrap();
assert_eq!(quad.points[0], ImagePoint::new(0.0, 2.0));
assert_eq!(quad.points[2], ImagePoint::new(10.0, 8.0));
}
#[test]
fn reduces_polygons_to_a_stable_axis_aligned_quad() {
let bbox = BoundingBox::new(vec![
Point::new(2.0, 1.0),
Point::new(7.0, 2.0),
Point::new(8.0, 6.0),
Point::new(5.0, 9.0),
Point::new(1.0, 5.0),
]);
let quad = bounding_box_to_quad(&bbox, 10, 10).unwrap();
assert_eq!(quad.points[0], ImagePoint::new(1.0, 1.0));
assert_eq!(quad.points[2], ImagePoint::new(8.0, 9.0));
}
#[test]
fn normalizes_unordered_or_partially_invalid_quads() {
let unordered = BoundingBox::new(vec![
Point::new(1.0, 1.0),
Point::new(8.0, 8.0),
Point::new(8.0, 1.0),
Point::new(1.0, 8.0),
]);
let quad = bounding_box_to_quad(&unordered, 10, 10).unwrap();
assert_eq!(quad.points[0], ImagePoint::new(1.0, 1.0));
assert_eq!(quad.points[1], ImagePoint::new(8.0, 1.0));
assert_eq!(quad.points[2], ImagePoint::new(8.0, 8.0));
let partially_invalid = BoundingBox::new(vec![
Point::new(8.0, 8.0),
Point::new(f32::NAN, 4.0),
Point::new(1.0, 8.0),
Point::new(8.0, 1.0),
Point::new(1.0, 1.0),
]);
let quad = bounding_box_to_quad(&partially_invalid, 10, 10).unwrap();
assert_eq!(quad.points[0], ImagePoint::new(1.0, 1.0));
assert_eq!(quad.points[1], ImagePoint::new(8.0, 1.0));
assert_eq!(quad.points[2], ImagePoint::new(8.0, 8.0));
}
#[test]
fn refuses_disabled_or_invalid_options_before_inference() {
assert!(matches!(
validate_options(&OcrOptions::new()),
Err(OarOcrError::OcrDisabled)
));
for value in [-0.1, 1.1, f32::NAN, f32::INFINITY] {
let options = OcrOptions::new()
.mode(OcrMode::Force)
.minimum_confidence(value);
assert!(matches!(
validate_options(&options),
Err(OarOcrError::InvalidMinimumConfidence { .. })
));
}
assert!(validate_options(
&OcrOptions::new()
.mode(OcrMode::Auto)
.minimum_confidence(1.0)
)
.is_ok());
}
}
+215 -3
View File
@@ -2,9 +2,11 @@
use std::path::Path;
use firecrawl_pdfium::{Pdfium, PixelFormat, PixelPoint, RenderConfig};
use firecrawl_pdfium::{PageChar, Pdfium, PixelFormat, PixelPoint, RenderConfig};
use thiserror::Error;
use crate::types::{ItemType, TextItem};
use super::{
PageRenderer, PageTransform, RenderBufferError, RenderOptions, RenderPixelFormat, RenderedPage,
};
@@ -47,6 +49,15 @@ pub enum RenderError {
/// Number of pages in the document.
page_count: usize,
},
/// The PDFium shared library could not be discovered or loaded.
#[error(
"failed to load PDFium; install a compatible PDFium shared library or set PDFIUM_LIB_PATH to its path"
)]
PdfiumLoad {
/// Dynamic loading failure.
#[source]
source: firecrawl_pdfium::Error,
},
/// PDFium loading, document parsing, form setup, or rendering failed.
#[error(transparent)]
Pdfium(#[from] firecrawl_pdfium::Error),
@@ -65,18 +76,28 @@ pub struct PdfiumRenderer {
pdfium: Pdfium,
}
/// Positioned native text recovered from one selected PDF page.
#[derive(Debug)]
pub(crate) struct PdfiumTextPage {
pub(crate) page: u32,
pub(crate) page_width: f32,
pub(crate) page_height: f32,
pub(crate) items: Vec<TextItem>,
}
impl PdfiumRenderer {
/// Loads PDFium using `firecrawl-pdfium`'s documented discovery chain.
pub fn load() -> Result<Self, RenderError> {
Ok(Self {
pdfium: Pdfium::load()?,
pdfium: Pdfium::load().map_err(|source| RenderError::PdfiumLoad { source })?,
})
}
/// Loads PDFium from an explicit native library path.
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, RenderError> {
Ok(Self {
pdfium: Pdfium::load_from_path(path)?,
pdfium: Pdfium::load_from_path(path)
.map_err(|source| RenderError::PdfiumLoad { source })?,
})
}
@@ -100,6 +121,56 @@ impl PdfiumRenderer {
self.render_pages_impl(pdf_bytes, pages, password, options)
}
/// Extracts positioned native text from selected 1-indexed pages.
///
/// This is deliberately separate from rendering: callers can probe a
/// suspicious embedded text layer before paying for rasterization and
/// OCR. A page-level text failure is treated as an unavailable recovery
/// candidate so the caller can continue to its normal OCR fallback.
pub(crate) fn extract_text_pages(
&self,
pdf_bytes: &[u8],
pages: &[u32],
password: Option<&str>,
) -> Result<Vec<PdfiumTextPage>, RenderError> {
const MAX_TEXT_CHARS_PER_PAGE: usize = 250_000;
if pages.is_empty() {
return Ok(Vec::new());
}
if pages.contains(&0) {
return Err(RenderError::InvalidPageNumber);
}
let document = self.pdfium.load_document(pdf_bytes.to_vec(), password)?;
let page_count = document.page_count();
if let Some(&page) = pages.iter().find(|&&page| page as usize > page_count) {
return Err(RenderError::PageOutOfBounds { page, page_count });
}
let mut recovered = Vec::with_capacity(pages.len());
for &page_number in pages {
let page = document.page(page_number as usize - 1)?;
let page_size = page.size();
let text = match page.text_with_limit(MAX_TEXT_CHARS_PER_PAGE) {
Ok(text) => text,
Err(error) => {
log::debug!(
"page {page_number}: positioned native text recovery unavailable: {error}"
);
continue;
}
};
recovered.push(PdfiumTextPage {
page: page_number,
page_width: page_size.width,
page_height: page_size.height,
items: text_chars_to_items(text.chars(), page_number),
});
}
Ok(recovered)
}
fn render_pages_impl(
&self,
pdf_bytes: &[u8],
@@ -145,6 +216,106 @@ impl PdfiumRenderer {
}
}
fn text_chars_to_items(chars: &[PageChar], page: u32) -> Vec<TextItem> {
#[derive(Debug, Clone, Copy)]
struct Bounds {
left: f64,
bottom: f64,
right: f64,
top: f64,
}
fn flush(items: &mut Vec<TextItem>, text: &mut String, bounds: &mut Option<Bounds>, page: u32) {
let Some(bounds) = bounds.take() else {
text.clear();
return;
};
if text.is_empty() {
return;
}
let width = (bounds.right - bounds.left) as f32;
let height = (bounds.top - bounds.bottom) as f32;
let x = bounds.left as f32;
let y = bounds.bottom as f32;
if !x.is_finite()
|| !y.is_finite()
|| !width.is_finite()
|| !height.is_finite()
|| width <= 0.0
|| height <= 0.0
{
text.clear();
return;
}
items.push(TextItem {
text: std::mem::take(text),
x,
y,
width,
height,
font: "PDFium native text".to_string(),
font_size: height.max(1.0),
page,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: ItemType::Text,
mcid: None,
});
}
let mut items = Vec::new();
let mut text = String::new();
let mut bounds: Option<Bounds> = None;
for character in chars {
let Some(value) = character.unicode else {
flush(&mut items, &mut text, &mut bounds, page);
continue;
};
if value.is_whitespace() {
flush(&mut items, &mut text, &mut bounds, page);
continue;
}
let rect = character.loose_bounds.normalized();
if !rect.left.is_finite()
|| !rect.bottom.is_finite()
|| !rect.right.is_finite()
|| !rect.top.is_finite()
|| rect.width() <= 0.0
|| rect.height() <= 0.0
{
flush(&mut items, &mut text, &mut bounds, page);
continue;
}
text.push(value);
bounds = Some(match bounds {
Some(bounds) => Bounds {
left: bounds.left.min(rect.left),
bottom: bounds.bottom.min(rect.bottom),
right: bounds.right.max(rect.right),
top: bounds.top.max(rect.top),
},
None => Bounds {
left: rect.left,
bottom: rect.bottom,
right: rect.right,
top: rect.top,
},
});
}
flush(&mut items, &mut text, &mut bounds, page);
items.sort_by(|first, second| {
first
.page
.cmp(&second.page)
.then(second.y.total_cmp(&first.y))
.then(first.x.total_cmp(&second.x))
});
items
}
impl PageRenderer for PdfiumRenderer {
type Error = RenderError;
@@ -237,6 +408,17 @@ fn bgr_to_rgb_in_place(
#[cfg(test)]
mod tests {
use super::*;
use firecrawl_pdfium::{PagePoint, PageRect};
fn page_char(value: char, bounds: PageRect) -> PageChar {
PageChar {
unicode: Some(value),
code: value as u32,
bounds,
loose_bounds: bounds,
origin: PagePoint::new(bounds.left, bounds.bottom),
}
}
#[test]
fn bgr_pixels_are_converted_to_rgb_in_place() {
@@ -263,4 +445,34 @@ mod tests {
Err(RenderBufferError::InvalidBufferLength { .. })
));
}
#[test]
fn invalid_character_geometry_splits_text_runs() {
let chars = [
page_char('A', PageRect::new(0.0, 0.0, 8.0, 10.0)),
page_char('X', PageRect::new(10.0, 0.0, 10.0, 10.0)),
page_char('B', PageRect::new(20.0, 0.0, 28.0, 10.0)),
];
let items = text_chars_to_items(&chars, 1);
assert_eq!(
items
.iter()
.map(|item| item.text.as_str())
.collect::<Vec<_>>(),
["A", "B"]
);
}
#[test]
fn coordinates_that_overflow_f32_are_discarded() {
let left = f64::from(f32::MAX) * 2.0;
let chars = [page_char(
'A',
PageRect::new(left, 0.0, left + 1.0e30, 10.0),
)];
assert!(text_chars_to_items(&chars, 1).is_empty());
}
}
File diff suppressed because it is too large Load Diff
+384
View File
@@ -0,0 +1,384 @@
//! Page routing and renderer/OCR orchestration without Markdown fusion.
use std::collections::BTreeSet;
use std::error::Error;
use std::time::Instant;
use thiserror::Error;
use super::{OcrEngine, OcrMode, OcrOptions, OcrPage, PageRenderer, RenderOptions, RenderedPage};
/// A rendered page paired with OCR output in the same bitmap coordinate space.
#[derive(Debug)]
pub struct RoutedOcrPage {
/// Renderer-owned bitmap and pixel↔PDF transform.
pub rendered: RenderedPage,
/// Positioned OCR spans for the bitmap.
pub ocr: OcrPage,
}
/// Output of one selective OCR invocation.
#[derive(Debug)]
pub struct OcrRun {
/// Pages processed in ascending document order.
pub pages: Vec<RoutedOcrPage>,
/// Total page-rendering wall time.
pub render_time_ms: u64,
/// Total engine wall time.
pub ocr_time_ms: u64,
}
/// Selects 1-indexed pages for OCR.
///
/// `recommended_pages` comes from pdf-inspector's existing detector/text
/// quality signals. `selected_pages` is an optional user page filter. Results
/// are validated, deduplicated, and returned in document order.
pub fn route_ocr_pages(
mode: OcrMode,
page_count: u32,
recommended_pages: &[u32],
selected_pages: Option<&[u32]>,
) -> Result<Vec<u32>, OcrRoutingError> {
match mode {
OcrMode::Off => Ok(Vec::new()),
OcrMode::Auto => {
let mut routed = validated_page_set("recommended", recommended_pages, page_count)?;
if let Some(selected) = selected_pages {
let selected = validated_page_set("selected", selected, page_count)?;
routed.retain(|page| selected.contains(page));
}
Ok(routed.into_iter().collect())
}
OcrMode::Force => {
let routed = selected_pages
.map(|pages| validated_page_set("selected", pages, page_count))
.transpose()?
.unwrap_or_else(|| (1..=page_count).collect());
Ok(routed.into_iter().collect())
}
}
}
/// Renders and recognizes already-routed pages while retaining transforms for
/// the following fusion layer.
///
/// An empty page list returns without calling either dependency, which keeps
/// model resolution and inference lazy when Auto routing finds no OCR work.
#[allow(clippy::too_many_arguments)]
pub fn run_ocr_pages<R, O>(
renderer: &R,
engine: &O,
pdf_bytes: &[u8],
pages: &[u32],
password: Option<&str>,
render_options: &RenderOptions,
ocr_options: &OcrOptions,
) -> Result<OcrRun, OcrRunError>
where
R: PageRenderer,
O: OcrEngine,
{
if pages.is_empty() {
return Ok(OcrRun {
pages: Vec::new(),
render_time_ms: 0,
ocr_time_ms: 0,
});
}
if ocr_options.mode == OcrMode::Off {
return Err(OcrRunError::OcrDisabled);
}
let render_started = Instant::now();
let rendered = renderer
.render_pages(pdf_bytes, pages, password, render_options)
.map_err(|source| OcrRunError::Render {
source: Box::new(source),
})?;
let render_time_ms = elapsed_ms(render_started);
validate_page_order("renderer", pages, rendered.iter().map(RenderedPage::page))?;
let ocr_started = Instant::now();
let recognized =
engine
.recognize(&rendered, ocr_options)
.map_err(|source| OcrRunError::Ocr {
source: Box::new(source),
})?;
let ocr_time_ms = elapsed_ms(ocr_started);
validate_page_order(
"OCR engine",
pages,
recognized.iter().map(|page| page.page_number),
)?;
Ok(OcrRun {
pages: rendered
.into_iter()
.zip(recognized)
.map(|(rendered, ocr)| RoutedOcrPage { rendered, ocr })
.collect(),
render_time_ms,
ocr_time_ms,
})
}
/// Invalid page routing or renderer/engine contract output.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum OcrRoutingError {
/// A page list contained zero or a page beyond the document.
#[error("{source_name} OCR page {page} is outside the valid range 1..={page_count}")]
InvalidPage {
/// Page-list source.
source_name: &'static str,
/// Invalid 1-indexed page.
page: u32,
/// Document page count.
page_count: u32,
},
}
/// Failures while rendering and recognizing a routed page set.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum OcrRunError {
/// A non-empty route cannot execute with OCR disabled.
#[error("cannot process routed pages while OCR mode is Off")]
OcrDisabled,
/// Page rasterization failed.
#[error("page rendering failed: {source}")]
Render {
/// Renderer-specific failure.
#[source]
source: Box<dyn Error + Send + Sync>,
},
/// OCR inference failed.
#[error("OCR inference failed: {source}")]
Ocr {
/// Engine-specific failure.
#[source]
source: Box<dyn Error + Send + Sync>,
},
/// A dependency returned the wrong count or order.
#[error("{stage} returned pages {actual:?}; expected {expected:?}")]
PageOrderMismatch {
/// Dependency boundary that violated the contract.
stage: &'static str,
/// Requested 1-indexed pages.
expected: Vec<u32>,
/// Returned 1-indexed pages.
actual: Vec<u32>,
},
}
fn validated_page_set(
source_name: &'static str,
pages: &[u32],
page_count: u32,
) -> Result<BTreeSet<u32>, OcrRoutingError> {
let mut result = BTreeSet::new();
for &page in pages {
if page == 0 || page > page_count {
return Err(OcrRoutingError::InvalidPage {
source_name,
page,
page_count,
});
}
result.insert(page);
}
Ok(result)
}
fn validate_page_order(
stage: &'static str,
expected: &[u32],
actual: impl IntoIterator<Item = u32>,
) -> Result<(), OcrRunError> {
let actual: Vec<u32> = actual.into_iter().collect();
if actual == expected {
Ok(())
} else {
Err(OcrRunError::PageOrderMismatch {
stage,
expected: expected.to_vec(),
actual,
})
}
}
fn elapsed_ms(started: Instant) -> u64 {
u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vision::{
ImagePoint, ImageQuad, ModelIdentity, OcrSpan, PageTransform, RenderBufferError,
RenderPixelFormat,
};
#[derive(Debug, Error)]
#[error("fake failure")]
struct FakeError;
struct FakeRenderer;
impl PageRenderer for FakeRenderer {
type Error = FakeError;
fn render_pages(
&self,
_pdf_bytes: &[u8],
pages: &[u32],
_password: Option<&str>,
_options: &RenderOptions,
) -> Result<Vec<RenderedPage>, Self::Error> {
pages
.iter()
.copied()
.map(rendered_page)
.collect::<Result<_, _>>()
.map_err(|_| FakeError)
}
}
struct FakeEngine {
model: ModelIdentity,
}
impl FakeEngine {
fn new() -> Self {
Self {
model: ModelIdentity::new("fake", "v1"),
}
}
}
impl OcrEngine for FakeEngine {
type Error = FakeError;
fn model(&self) -> &ModelIdentity {
&self.model
}
fn recognize(
&self,
pages: &[RenderedPage],
_options: &OcrOptions,
) -> Result<Vec<OcrPage>, Self::Error> {
Ok(pages
.iter()
.map(|page| OcrPage {
page_number: page.page(),
spans: vec![OcrSpan {
text: format!("page {}", page.page()),
polygon: ImageQuad::new([
ImagePoint::new(0.0, 0.0),
ImagePoint::new(1.0, 0.0),
ImagePoint::new(1.0, 1.0),
ImagePoint::new(0.0, 1.0),
]),
confidence: 0.9,
orientation_degrees: None,
}],
mean_confidence: Some(0.9),
model: self.model.clone(),
processing_time_ms: 1,
warnings: Vec::new(),
})
.collect())
}
}
fn rendered_page(page: u32) -> Result<RenderedPage, RenderBufferError> {
let transform =
PageTransform::from_corners(1, 1, (0.0, 1.0), (1.0, 1.0), (0.0, 0.0)).unwrap();
RenderedPage::new(
page,
1.0,
1.0,
1,
1,
3,
RenderPixelFormat::Rgb8,
vec![255; 3],
transform,
)
}
#[test]
fn off_auto_and_force_route_expected_pages() {
assert_eq!(
route_ocr_pages(OcrMode::Off, 0, &[99], Some(&[0])).unwrap(),
Vec::<u32>::new()
);
assert_eq!(
route_ocr_pages(OcrMode::Auto, 5, &[5, 3, 3, 1], Some(&[2, 3, 5])).unwrap(),
vec![3, 5]
);
assert_eq!(
route_ocr_pages(OcrMode::Force, 4, &[], None).unwrap(),
vec![1, 2, 3, 4]
);
assert_eq!(
route_ocr_pages(OcrMode::Force, 4, &[], Some(&[4, 2, 2])).unwrap(),
vec![2, 4]
);
}
#[test]
fn routing_rejects_invalid_page_numbers() {
assert!(matches!(
route_ocr_pages(OcrMode::Auto, 2, &[0], None),
Err(OcrRoutingError::InvalidPage { page: 0, .. })
));
assert!(matches!(
route_ocr_pages(OcrMode::Force, 2, &[], Some(&[3])),
Err(OcrRoutingError::InvalidPage { page: 3, .. })
));
}
#[test]
fn run_retains_render_transforms_and_input_order() {
let options = OcrOptions::new().mode(OcrMode::Auto);
let run = run_ocr_pages(
&FakeRenderer,
&FakeEngine::new(),
b"pdf",
&[2, 4],
None,
&RenderOptions::new(),
&options,
)
.unwrap();
assert_eq!(
run.pages
.iter()
.map(|page| page.rendered.page())
.collect::<Vec<_>>(),
vec![2, 4]
);
assert_eq!(run.pages[1].ocr.spans[0].text, "page 4");
assert_eq!(run.pages[1].rendered.pixel_to_page(0.0, 0.0).y, 1.0);
}
#[test]
fn empty_route_is_a_noop_even_when_ocr_is_off() {
let run = run_ocr_pages(
&FakeRenderer,
&FakeEngine::new(),
b"pdf",
&[],
None,
&RenderOptions::new(),
&OcrOptions::new(),
)
.unwrap();
assert!(run.pages.is_empty());
assert_eq!(run.render_time_ms, 0);
assert_eq!(run.ocr_time_ms, 0);
}
}
+1 -3
View File
@@ -5,9 +5,7 @@ use pdf_inspector::vision::{PdfiumRenderer, RenderError, RenderOptions, RenderPi
fn load_renderer() -> Option<PdfiumRenderer> {
match PdfiumRenderer::load() {
Ok(renderer) => Some(renderer),
Err(RenderError::Pdfium(firecrawl_pdfium::Error::Load(
firecrawl_pdfium::LoadError::LibraryNotFound { .. },
))) => {
Err(RenderError::PdfiumLoad { .. }) => {
eprintln!("skipping PDFium runtime test because no native library is installed");
None
}
+214
View File
@@ -0,0 +1,214 @@
#![cfg(all(feature = "ocr-oar", not(target_arch = "wasm32")))]
#[cfg(feature = "ocr")]
use pdf_inspector::vision::{
process_pdf_with_ocr_mem, ModelDownloadPolicy, OcrPdfOptions, OcrPipelineError,
PageContentSource,
};
use pdf_inspector::vision::{
ModelStore, OarOcrEngine, OcrEngine, OcrMode, OcrOptions, PageTransform, RenderPixelFormat,
RenderedPage, PP_OCR_V6_SMALL,
};
#[cfg(feature = "render-pdfium")]
use pdf_inspector::vision::{PdfiumRenderer, RenderError, RenderOptions};
const MODEL_DIRECTORY_ENV: &str = "PDF_INSPECTOR_OCR_TEST_MODELS";
const IMAGE_ENV: &str = "PDF_INSPECTOR_OCR_TEST_IMAGE";
const EXPECTED_TEXT_ENV: &str = "PDF_INSPECTOR_OCR_TEST_EXPECTED";
#[cfg(feature = "render-pdfium")]
fn load_renderer() -> Option<PdfiumRenderer> {
match PdfiumRenderer::load() {
Ok(renderer) => Some(renderer),
Err(RenderError::PdfiumLoad { .. }) => {
eprintln!("skipping OCR runtime test because no native PDFium library is installed");
None
}
Err(error) => panic!("failed to load PDFium: {error}"),
}
}
#[test]
fn recognizes_an_rgb_image_with_verified_models() {
let Some(model_directory) = std::env::var_os(MODEL_DIRECTORY_ENV) else {
eprintln!("skipping OCR runtime test because {MODEL_DIRECTORY_ENV} is not set");
return;
};
let Some(image_path) = std::env::var_os(IMAGE_ENV) else {
eprintln!("skipping OCR runtime test because {IMAGE_ENV} is not set");
return;
};
let image = image::open(image_path).unwrap().into_rgb8();
let (width, height) = image.dimensions();
let transform = PageTransform::from_corners(
width,
height,
(0.0, f64::from(height)),
(f64::from(width), f64::from(height)),
(0.0, 0.0),
)
.unwrap();
let page = RenderedPage::new(
1,
width as f32,
height as f32,
width,
height,
width as usize * 3,
RenderPixelFormat::Rgb8,
image.into_raw(),
transform,
)
.unwrap();
let results = recognize(&model_directory, &[page]);
assert_usable_result(&results);
let text = results[0]
.spans
.iter()
.map(|span| span.text.as_str())
.collect::<Vec<_>>()
.join(" ");
eprintln!("recognized: {text}");
if let Ok(expected) = std::env::var(EXPECTED_TEXT_ENV) {
assert!(
text.to_lowercase().contains(&expected.to_lowercase()),
"expected OCR output to contain {expected:?}, got {text:?}"
);
}
}
#[cfg(feature = "render-pdfium")]
#[test]
fn recognizes_a_pdfium_rendered_fixture_with_verified_models() {
let Some(model_directory) = std::env::var_os(MODEL_DIRECTORY_ENV) else {
eprintln!("skipping OCR runtime test because {MODEL_DIRECTORY_ENV} is not set");
return;
};
let Some(renderer) = load_renderer() else {
return;
};
let bytes = std::fs::read("tests/fixtures/thermo-freon12.pdf").unwrap();
let pages = renderer
.render_pages(
&bytes,
&[1],
None,
&RenderOptions::new().dpi(150.0).form_fields(false),
)
.unwrap();
let results = recognize(&model_directory, &pages);
assert_usable_result(&results);
}
#[cfg(all(feature = "ocr", feature = "render-pdfium"))]
#[test]
fn complete_ocr_pipeline_routes_and_assembles_a_scanned_fixture() {
let Some(model_directory) = std::env::var_os(MODEL_DIRECTORY_ENV) else {
eprintln!("skipping OCR runtime test because {MODEL_DIRECTORY_ENV} is not set");
return;
};
let Some(_renderer) = load_renderer() else {
return;
};
let bytes = std::fs::read("tests/fixtures/scan_with_native_header_text.pdf").unwrap();
let ocr = OcrOptions::new()
.mode(OcrMode::Auto)
.minimum_confidence(0.3)
.model_directory(model_directory)
.model_downloads(ModelDownloadPolicy::Offline);
let options = OcrPdfOptions::new().ocr(ocr);
let result = process_pdf_with_ocr_mem(&bytes, options.clone()).unwrap();
let repeated = process_pdf_with_ocr_mem(&bytes, options).unwrap();
assert_eq!(result.pages_routed_to_ocr, vec![1]);
assert!(!result.markdown.trim().is_empty());
assert!(result
.markdown
.contains("Order Date Item Code Description Status Unit Cost\n\n03/14/2024"));
assert!(result.markdown.contains("$482,110.40\n\n05/02/2024"));
assert_eq!(result.pages[0].provenance.source, PageContentSource::Fused);
assert!(result.pages[0]
.provenance
.warnings
.iter()
.any(|warning| warning.contains("complementary OCR")));
assert_eq!(
result.pages[0].provenance.ocr_model.as_ref().unwrap().name,
PP_OCR_V6_SMALL.id
);
assert_eq!(repeated.markdown, result.markdown);
}
#[cfg(all(feature = "ocr", feature = "render-pdfium"))]
#[test]
fn auto_recovers_credible_native_text_before_loading_ocr_models() {
let Some(_renderer) = load_renderer() else {
return;
};
let bytes = std::fs::read("tests/fixtures/shinagawa_identity_h.pdf").unwrap();
let ocr = OcrOptions::new()
.mode(OcrMode::Auto)
.model_directory("/models/must-not-be-read")
.model_downloads(ModelDownloadPolicy::Offline);
let result = process_pdf_with_ocr_mem(&bytes, OcrPdfOptions::new().ocr(ocr)).unwrap();
assert_eq!(result.pages_recommended_for_ocr, vec![1]);
assert!(result.pages_routed_to_ocr.is_empty());
assert!(result.markdown.contains("羽田空港新飛行経路"));
assert!(result.markdown.contains("|4月30日|有|81.0|"));
assert!(result.markdown.contains("※1 最大騒音レベル"));
assert!(result.pages_with_tables.contains(&1));
assert_eq!(result.pages[0].provenance.source, PageContentSource::Native);
assert!(result.pages[0].provenance.ocr_model.is_none());
}
#[cfg(all(feature = "ocr", feature = "render-pdfium"))]
#[test]
fn auto_rejects_garbled_native_recovery_and_continues_to_ocr() {
let Some(_renderer) = load_renderer() else {
return;
};
let bytes = std::fs::read("tests/fixtures/shifted_cipher_tounicode.pdf").unwrap();
let ocr = OcrOptions::new()
.mode(OcrMode::Auto)
.model_directory("/models/must-not-be-read")
.model_downloads(ModelDownloadPolicy::Offline);
let error = process_pdf_with_ocr_mem(&bytes, OcrPdfOptions::new().ocr(ocr)).unwrap_err();
assert!(matches!(
error,
OcrPipelineError::ModelAcquire(_) | OcrPipelineError::ModelStore(_)
));
}
fn recognize(
model_directory: &std::ffi::OsStr,
pages: &[RenderedPage],
) -> Vec<pdf_inspector::vision::OcrPage> {
let store = ModelStore::new(model_directory).override_root(model_directory);
let models = store.resolve(&PP_OCR_V6_SMALL).unwrap();
let engine = OarOcrEngine::from_models(&models).unwrap();
engine
.recognize(
pages,
&OcrOptions::new()
.mode(OcrMode::Force)
.minimum_confidence(0.3),
)
.unwrap()
}
fn assert_usable_result(results: &[pdf_inspector::vision::OcrPage]) {
assert_eq!(results.len(), 1);
assert_eq!(results[0].page_number, 1);
assert_eq!(results[0].model.name, PP_OCR_V6_SMALL.id);
assert_eq!(results[0].model.revision, PP_OCR_V6_SMALL.revision);
assert!(!results[0].spans.is_empty());
assert!(results[0].spans.iter().all(|span| span.confidence >= 0.3));
}