Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d590b2f194 |
-27
@@ -49,25 +49,6 @@ ttf-parser = "0.25"
|
||||
lopdf = { version = "0.42.0", features = ["rayon"] }
|
||||
rayon = "1.10"
|
||||
env_logger = "0.11"
|
||||
# Optional native page rendering for OCR pipelines. PDFium is loaded at
|
||||
# runtime, so enabling this feature does not link or download a native library.
|
||||
firecrawl-pdfium = { version = "0.1.0", optional = true }
|
||||
# Small support crates used only by the opt-in model cache. Model files remain
|
||||
# external and are never embedded in pdf-inspector artifacts.
|
||||
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 }
|
||||
|
||||
# Browser builds use JavaScript randomness for encrypted PDFs and embed the
|
||||
# bundled CMaps because there is no filesystem at runtime.
|
||||
@@ -81,14 +62,6 @@ tempfile = "3.3"
|
||||
[features]
|
||||
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"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
[](https://pypi.org/project/pdf-inspector/)
|
||||
[](LICENSE)
|
||||
|
||||
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).
|
||||
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).
|
||||
|
||||
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,10 +18,9 @@ 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 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.
|
||||
- **Lightweight** — Pure Rust, no ML models, no external services. Single dependency on `lopdf` for PDF parsing.
|
||||
|
||||
## Benchmark
|
||||
|
||||
@@ -161,19 +160,6 @@ 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
|
||||
|
||||
+1
-318
@@ -1,6 +1,6 @@
|
||||
# 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/).
|
||||
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. Pure Rust, no ML models, no external services; the only PDF dependency is [lopdf](https://crates.io/crates/lopdf). 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.
|
||||
|
||||
@@ -117,323 +117,6 @@ let bytes = std::fs::read("document.pdf")?;
|
||||
let result = process_pdf_mem(&bytes)?;
|
||||
```
|
||||
|
||||
### Vision extension contracts
|
||||
|
||||
The native-only `vision` feature exposes the stable seam used by OCR
|
||||
integrations without selecting or embedding an inference runtime. The
|
||||
separate `model-cache` feature adds pinned artifact management:
|
||||
|
||||
- `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 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.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
pdf-inspector = { version = "1", features = ["vision", "model-cache"] }
|
||||
```
|
||||
|
||||
The OCR contracts preserve existing behavior by default: OCR is `Off` and
|
||||
model resolution is never reached. `ModelStore` itself does not access the
|
||||
network. The optional `model-download` feature provides an
|
||||
HTTPS downloader that streams pinned artifacts into the checksum-verified
|
||||
cache only after routing has selected OCR work. Offline consumers set an
|
||||
explicit model directory and `ModelDownloadPolicy::Offline`. Renderer-only
|
||||
consumers do not enable `model-cache` or `model-download` and therefore do not
|
||||
compile their filesystem, hashing, or HTTP dependencies.
|
||||
|
||||
```rust
|
||||
use pdf_inspector::vision::{
|
||||
ModelDownloadPolicy, ModelStore, OcrMode, OcrOptions, PP_OCR_V6_SMALL,
|
||||
};
|
||||
|
||||
let ocr = OcrOptions::new()
|
||||
.mode(OcrMode::Auto)
|
||||
.model_directory("/opt/firecrawl/models/pp-ocrv6-small")
|
||||
.model_downloads(ModelDownloadPolicy::Offline);
|
||||
// Verifies exact sizes and SHA-256 digests before an engine opens the files.
|
||||
let models = ModelStore::from_options(&ocr)?.resolve(&PP_OCR_V6_SMALL)?;
|
||||
println!("using {} at {}", models.manifest_id(), models.revision());
|
||||
```
|
||||
|
||||
### Optional native page rendering
|
||||
|
||||
The `render-pdfium` feature adds a native-only page renderer backed by
|
||||
[`firecrawl-pdfium`](https://crates.io/crates/firecrawl-pdfium). It is the
|
||||
rendering boundary for OCR pipelines; enabling it does not include an OCR
|
||||
model or change the existing extraction functions. It implies `vision`,
|
||||
and `PdfiumRenderer` implements the renderer-neutral `PageRenderer` trait.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
pdf-inspector = { version = "1", features = ["render-pdfium"] }
|
||||
```
|
||||
|
||||
PDFium is loaded at runtime and is not bundled into the crate. Set
|
||||
`PDFIUM_LIB_PATH` to the platform shared library, place that library next to
|
||||
the executable, or use another discovery route supported by
|
||||
`firecrawl-pdfium`. A load failure reports this prerequisite directly.
|
||||
|
||||
```rust
|
||||
use pdf_inspector::vision::{PdfiumRenderer, RenderOptions};
|
||||
|
||||
let renderer = PdfiumRenderer::load()?;
|
||||
let bytes = std::fs::read("document.pdf")?;
|
||||
let pages = renderer.render_pages(
|
||||
&bytes,
|
||||
&[1, 3], // 1-indexed, matching pages_needing_ocr
|
||||
None, // optional PDF password
|
||||
&RenderOptions::new().dpi(150.0),
|
||||
)?;
|
||||
|
||||
for page in pages {
|
||||
// Owned RGB pixels can leave the PDFium critical section and be sent to
|
||||
// an OCR worker. OCR pixel boxes can be mapped back to PDF coordinates.
|
||||
let rect = page.pixel_rect_to_pdf_rect(20.0, 30.0, 100.0, 24.0);
|
||||
println!("page {}: {}x{}, rect={rect:?}", page.page(), page.width(), page.height());
|
||||
}
|
||||
```
|
||||
|
||||
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):
|
||||
|
||||
|
||||
+6
-311
@@ -1,11 +1,6 @@
|
||||
//! 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,
|
||||
@@ -108,146 +103,6 @@ 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>>,
|
||||
@@ -259,9 +114,7 @@ fn extract_items_json(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
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 super::{extract_items_json, format_items_json};
|
||||
use pdf_inspector::extractor::ItemType;
|
||||
use pdf_inspector::TextItem;
|
||||
|
||||
@@ -311,27 +164,6 @@ 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.
|
||||
@@ -410,12 +242,6 @@ 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);
|
||||
}
|
||||
|
||||
@@ -427,10 +253,6 @@ 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| {
|
||||
@@ -461,138 +283,6 @@ 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),
|
||||
@@ -604,6 +294,11 @@ 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 {
|
||||
|
||||
+12
-180
@@ -43,7 +43,6 @@ mod text_quality;
|
||||
pub mod text_utils;
|
||||
pub mod tounicode;
|
||||
pub mod types;
|
||||
pub mod vision;
|
||||
|
||||
pub use detector::{
|
||||
detect_pdf_type, detect_pdf_type_mem, detect_pdf_type_mem_with_config,
|
||||
@@ -459,44 +458,8 @@ 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_with_password(buffer, password)?;
|
||||
let (doc, page_count) = load_document_from_mem(buffer)?;
|
||||
let font_cmaps = FontCMaps::from_doc(&doc);
|
||||
|
||||
// Extract ALL pages to get accurate, document-wide font stats. A malformed
|
||||
@@ -539,11 +502,6 @@ fn extract_pages_markdown_mem_impl(
|
||||
|
||||
// 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>;
|
||||
@@ -579,10 +537,7 @@ fn extract_pages_markdown_mem_impl(
|
||||
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
|
||||
&& !repeated_header_footer_items.contains(&HeaderFooterItemKey::from(*item))
|
||||
})
|
||||
.filter(|(item, _)| item.page == page_1idx)
|
||||
.map(|(item, remove)| (item.clone(), *remove))
|
||||
.unzip();
|
||||
|
||||
@@ -619,7 +574,7 @@ fn extract_pages_markdown_mem_impl(
|
||||
base_font_size: Some(font_stats.most_common_size),
|
||||
include_page_numbers: false,
|
||||
strip_headers_footers: false,
|
||||
..markdown_options.clone()
|
||||
..MarkdownOptions::default()
|
||||
};
|
||||
|
||||
let md = if has_text_quality_issue {
|
||||
@@ -672,143 +627,20 @@ fn extract_pages_markdown_mem_impl(
|
||||
|
||||
results.push(PageMarkdown {
|
||||
page: page_0idx,
|
||||
// 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
|
||||
},
|
||||
markdown: if needs_ocr { 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,
|
||||
},
|
||||
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,
|
||||
))));
|
||||
}
|
||||
}
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
/// Path-based wrapper for [`extract_pages_markdown_mem`].
|
||||
|
||||
@@ -453,110 +453,6 @@ fn merged_retry_skips_body_font(detected_columns: bool, has_chart_regions: bool)
|
||||
detected_columns && !has_chart_regions
|
||||
}
|
||||
|
||||
/// Identity of a piece of page furniture: the same trimmed text drawn at the
|
||||
/// same position (quantized to 0.5pt) — page numbers excluded by construction
|
||||
/// because their text differs per page.
|
||||
type FurnitureKey = (String, i32, i32);
|
||||
|
||||
fn furniture_key(item: &TextItem) -> FurnitureKey {
|
||||
(
|
||||
item.text.trim().to_string(),
|
||||
(item.x * 2.0).round() as i32,
|
||||
(item.y * 2.0).round() as i32,
|
||||
)
|
||||
}
|
||||
|
||||
/// Minimum distinct pages an identical (text, position) must appear on before
|
||||
/// it counts as a running header/footer rather than coincidence.
|
||||
const RUNNING_FURNITURE_MIN_PAGES: usize = 3;
|
||||
|
||||
/// Fraction of each page's vertical content extent, at the top and at the
|
||||
/// bottom, where running furniture may live. Repetition alone is not enough:
|
||||
/// a form template repeated per record carries identical labels at identical
|
||||
/// mid-page coordinates on every page, and those are real table cells. What
|
||||
/// makes a header/footer is repetition *at the page edge*.
|
||||
const RUNNING_FURNITURE_BAND: f32 = 0.2;
|
||||
|
||||
/// Collect the keys of items that repeat verbatim at the same position on at
|
||||
/// least [`RUNNING_FURNITURE_MIN_PAGES`] distinct pages, restricted to the
|
||||
/// top/bottom [`RUNNING_FURNITURE_BAND`] of each page's content extent —
|
||||
/// running headers and footers. Single- and two-page documents produce an
|
||||
/// empty set.
|
||||
fn running_furniture_keys(items: &[TextItem]) -> HashSet<FurnitureKey> {
|
||||
// Vertical content extent per page, so the edge bands adapt to the
|
||||
// document's real margins instead of assuming a media box.
|
||||
let mut page_extent: HashMap<u32, (f32, f32)> = HashMap::new();
|
||||
for item in items {
|
||||
if item.text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let entry = page_extent.entry(item.page).or_insert((item.y, item.y));
|
||||
entry.0 = entry.0.min(item.y);
|
||||
entry.1 = entry.1.max(item.y);
|
||||
}
|
||||
|
||||
let mut pages_by_key: HashMap<FurnitureKey, HashSet<u32>> = HashMap::new();
|
||||
for item in items {
|
||||
if item.text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(&(min_y, max_y)) = page_extent.get(&item.page) else {
|
||||
continue;
|
||||
};
|
||||
// A page whose text has no vertical span gives no evidence of where
|
||||
// its edges are — without this guard, a zero band would classify its
|
||||
// every item as edge furniture.
|
||||
let extent = max_y - min_y;
|
||||
if extent <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let band = extent * RUNNING_FURNITURE_BAND;
|
||||
if item.y > min_y + band && item.y < max_y - band {
|
||||
continue; // mid-page: never furniture, however often it repeats
|
||||
}
|
||||
pages_by_key
|
||||
.entry(furniture_key(item))
|
||||
.or_default()
|
||||
.insert(item.page);
|
||||
}
|
||||
pages_by_key
|
||||
.into_iter()
|
||||
.filter(|(_, pages)| pages.len() >= RUNNING_FURNITURE_MIN_PAGES)
|
||||
.map(|(key, _)| key)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Reject a heuristic table whose items are almost entirely running
|
||||
/// headers/footers. A wrapped document title repeated at the bottom of every
|
||||
/// page aligns well enough to read as a grid, but it is page furniture, not
|
||||
/// data — vetoing the table lets the text flow as prose instead. Real tables
|
||||
/// carry per-page content, so even a repeated *header row* stays under the
|
||||
/// threshold once its body rows differ.
|
||||
fn is_running_furniture_table(
|
||||
detection_items: &[TextItem],
|
||||
table: &crate::tables::Table,
|
||||
running: &HashSet<FurnitureKey>,
|
||||
) -> bool {
|
||||
if running.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut total = 0usize;
|
||||
let mut furniture = 0usize;
|
||||
for &idx in &table.item_indices {
|
||||
let Some(item) = detection_items.get(idx) else {
|
||||
continue;
|
||||
};
|
||||
if item.text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
total += 1;
|
||||
if running.contains(&furniture_key(item)) {
|
||||
furniture += 1;
|
||||
}
|
||||
}
|
||||
total > 0 && (furniture as f32) >= (total as f32) * 0.8
|
||||
}
|
||||
|
||||
/// Reject a heuristic table only when its cells are overwhelmingly parallel
|
||||
/// prose fragments. This is deliberately narrower than disabling body-font
|
||||
/// detection for the whole page: numeric, compact, headed, and otherwise
|
||||
@@ -1144,14 +1040,6 @@ 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, &[])
|
||||
@@ -1300,12 +1188,6 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
let mut table_items: HashSet<usize> = HashSet::new();
|
||||
let mut page_tables: HashMap<u32, Vec<PositionedMarkdown>> = HashMap::new();
|
||||
|
||||
// Running headers/footers repeat verbatim at the same position on many
|
||||
// pages. When such a block wraps a long title over aligned lines, the
|
||||
// heuristic detector reads it as a table. Knowing which items are page
|
||||
// furniture is a document-wide question, so answer it once here.
|
||||
let running_furniture = running_furniture_keys(&text_items);
|
||||
|
||||
// Pre-group items by page with their global indices (O(n) instead of O(pages*n))
|
||||
let mut page_groups: HashMap<u32, Vec<(usize, &TextItem)>> = HashMap::new();
|
||||
for (global_idx, item) in text_items.iter().enumerate() {
|
||||
@@ -1635,15 +1517,6 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if is_running_furniture_table(subset_items, &table, &running_furniture) {
|
||||
log::debug!(
|
||||
"page {}: rejected {}x{} running header/footer table hypothesis",
|
||||
page,
|
||||
table.rows.len(),
|
||||
table.columns.len()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for &idx in &table.item_indices {
|
||||
if let Some(&band_idx) = index_map.get(idx) {
|
||||
if let Some(&page_idx) = band_index_map.get(band_idx) {
|
||||
@@ -1830,15 +1703,6 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if is_running_furniture_table(&chart_free, table, &running_furniture) {
|
||||
log::debug!(
|
||||
"page {}: rejected {}x{} merged-band running header/footer table hypothesis",
|
||||
page,
|
||||
table.rows.len(),
|
||||
table.columns.len()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for &idx in &table.item_indices {
|
||||
if let Some(&page_idx) = chart_free_map
|
||||
.get(idx)
|
||||
@@ -2194,174 +2058,6 @@ mod tests {
|
||||
assert!(md.contains("- Second item"));
|
||||
}
|
||||
|
||||
fn furniture_item(text: &str, x: f32, y: f32, page: u32) -> TextItem {
|
||||
let mut it = make_item(x, y, page);
|
||||
it.text = text.into();
|
||||
it
|
||||
}
|
||||
|
||||
/// Items repeating verbatim at the same position on 3+ pages are running
|
||||
/// furniture; the same text on fewer pages, or at different positions, is
|
||||
/// not.
|
||||
#[test]
|
||||
fn running_furniture_requires_three_pages_at_same_position() {
|
||||
let mut items = Vec::new();
|
||||
for page in 1..=3 {
|
||||
// Body content so each page has a real vertical extent.
|
||||
items.push(furniture_item("body", 85.0, 700.0, page));
|
||||
items.push(furniture_item("TITULAR DEL", 85.0, 68.0, page));
|
||||
}
|
||||
// Same text but only two pages.
|
||||
for page in 1..=2 {
|
||||
items.push(furniture_item("SECRETARÍA", 200.0, 68.0, page));
|
||||
}
|
||||
// Same text on three pages but at drifting positions.
|
||||
for (page, x) in [(1, 300.0), (2, 320.0), (3, 340.0)] {
|
||||
items.push(furniture_item("MÉXICO", x, 68.0, page));
|
||||
}
|
||||
|
||||
let running = running_furniture_keys(&items);
|
||||
assert!(running.contains(&furniture_key(&furniture_item(
|
||||
"TITULAR DEL",
|
||||
85.0,
|
||||
68.0,
|
||||
1
|
||||
))));
|
||||
assert!(!running.contains(&furniture_key(&furniture_item(
|
||||
"SECRETARÍA",
|
||||
200.0,
|
||||
68.0,
|
||||
1
|
||||
))));
|
||||
assert!(!running.contains(&furniture_key(&furniture_item("MÉXICO", 300.0, 68.0, 1))));
|
||||
}
|
||||
|
||||
/// A table made of running-footer items is vetoed; a table whose body rows
|
||||
/// carry per-page content is kept even when its header row repeats.
|
||||
#[test]
|
||||
fn running_furniture_table_veto() {
|
||||
// The footer block, present identically on pages 1-3.
|
||||
let mut items = Vec::new();
|
||||
for page in 1..=3 {
|
||||
items.push(furniture_item("PROPOSICIÓN CON PUNTO", 85.0, 78.5, page));
|
||||
items.push(furniture_item("EL SENADO", 286.6, 78.5, page));
|
||||
items.push(furniture_item("TITULAR DEL", 85.0, 68.0, page));
|
||||
items.push(furniture_item("A TRAVÉS DE LA", 243.4, 68.0, page));
|
||||
}
|
||||
// A real table on page 1: repeated header row, per-page data rows.
|
||||
let header = [
|
||||
furniture_item("Year", 85.0, 500.0, 1),
|
||||
furniture_item("Total", 200.0, 500.0, 1),
|
||||
];
|
||||
let data = [
|
||||
furniture_item("2023", 85.0, 488.0, 1),
|
||||
furniture_item("1,204", 200.0, 488.0, 1),
|
||||
furniture_item("2024", 85.0, 476.0, 1),
|
||||
furniture_item("1,377", 200.0, 476.0, 1),
|
||||
];
|
||||
// Header repeats on every page (like a continued table's header).
|
||||
for page in 2..=3 {
|
||||
items.push(furniture_item("Year", 85.0, 500.0, page));
|
||||
items.push(furniture_item("Total", 200.0, 500.0, page));
|
||||
}
|
||||
items.extend(header.iter().cloned());
|
||||
items.extend(data.iter().cloned());
|
||||
|
||||
let running = running_furniture_keys(&items);
|
||||
|
||||
let table_of = |detection_items: &[TextItem]| crate::tables::Table {
|
||||
columns: vec![],
|
||||
rows: vec![],
|
||||
cells: vec![],
|
||||
item_indices: (0..detection_items.len()).collect(),
|
||||
kind: crate::tables::TableKind::Data,
|
||||
};
|
||||
|
||||
// Footer-only candidate: every item is furniture -> vetoed.
|
||||
let footer_items: Vec<TextItem> = (1..=1)
|
||||
.flat_map(|page| {
|
||||
vec![
|
||||
furniture_item("PROPOSICIÓN CON PUNTO", 85.0, 78.5, page),
|
||||
furniture_item("EL SENADO", 286.6, 78.5, page),
|
||||
furniture_item("TITULAR DEL", 85.0, 68.0, page),
|
||||
furniture_item("A TRAVÉS DE LA", 243.4, 68.0, page),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
assert!(is_running_furniture_table(
|
||||
&footer_items,
|
||||
&table_of(&footer_items),
|
||||
&running
|
||||
));
|
||||
|
||||
// Real table: header row repeats across pages, body rows do not ->
|
||||
// 2 furniture of 6 items (33%) stays under the 80% threshold.
|
||||
let real_items: Vec<TextItem> =
|
||||
header.iter().cloned().chain(data.iter().cloned()).collect();
|
||||
assert!(!is_running_furniture_table(
|
||||
&real_items,
|
||||
&table_of(&real_items),
|
||||
&running
|
||||
));
|
||||
}
|
||||
|
||||
/// A form template repeated per record carries identical labels at
|
||||
/// identical mid-page coordinates on every page — those are real table
|
||||
/// cells, not furniture. Only the page-edge bands qualify.
|
||||
#[test]
|
||||
fn mid_page_repetition_is_not_furniture() {
|
||||
let mut items = Vec::new();
|
||||
for page in 1..=4 {
|
||||
// Content spanning the page: y 60 (bottom) to 740 (top).
|
||||
items.push(furniture_item("body top", 85.0, 740.0, page));
|
||||
items.push(furniture_item("body bottom", 85.0, 60.0, page));
|
||||
// Form labels repeated dead centre on every page.
|
||||
items.push(furniture_item("Name of creditor", 85.0, 400.0, page));
|
||||
items.push(furniture_item("Amount of claim", 300.0, 400.0, page));
|
||||
// A genuine footer inside the bottom band.
|
||||
items.push(furniture_item("FORM 78 — page footer", 85.0, 70.0, page));
|
||||
}
|
||||
|
||||
let running = running_furniture_keys(&items);
|
||||
assert!(
|
||||
!running.contains(&furniture_key(&furniture_item(
|
||||
"Name of creditor",
|
||||
85.0,
|
||||
400.0,
|
||||
1
|
||||
))),
|
||||
"mid-page form labels must not be furniture"
|
||||
);
|
||||
assert!(running.contains(&furniture_key(&furniture_item(
|
||||
"FORM 78 — page footer",
|
||||
85.0,
|
||||
70.0,
|
||||
1
|
||||
))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_furniture_empty_on_short_documents() {
|
||||
let mut items = Vec::new();
|
||||
for page in 1..=2 {
|
||||
items.push(furniture_item("body", 85.0, 700.0, page));
|
||||
items.push(furniture_item("FOOTER", 85.0, 68.0, page));
|
||||
}
|
||||
assert!(running_furniture_keys(&items).is_empty());
|
||||
}
|
||||
|
||||
/// A page whose text has no vertical span (a single line) gives no
|
||||
/// evidence of where its edges are; its items never become furniture.
|
||||
#[test]
|
||||
fn zero_span_page_contributes_no_furniture() {
|
||||
let mut items = Vec::new();
|
||||
for page in 1..=4 {
|
||||
items.push(furniture_item("ROW LABEL", 85.0, 400.0, page));
|
||||
items.push(furniture_item("ROW VALUE", 300.0, 400.0, page));
|
||||
}
|
||||
assert!(running_furniture_keys(&items).is_empty());
|
||||
}
|
||||
|
||||
fn make_item(x: f32, y: f32, page: u32) -> TextItem {
|
||||
TextItem {
|
||||
text: "A".into(),
|
||||
|
||||
+2
-386
@@ -13,11 +13,7 @@ pub(crate) fn clean_markdown(mut text: String, options: &MarkdownOptions) -> Str
|
||||
text = collapse_dot_leaders(&text);
|
||||
}
|
||||
|
||||
// Collapse runs of spaces first: double-spaced breaks ("de- fendant")
|
||||
// must look like single-spaced ones before the hyphenation passes.
|
||||
collapse_consecutive_spaces(&mut text);
|
||||
|
||||
// Fix hyphenation (before other processing)
|
||||
// Fix hyphenation first (before other processing)
|
||||
if options.fix_hyphenation {
|
||||
text = fix_hyphenation(&text);
|
||||
}
|
||||
@@ -147,213 +143,7 @@ fn fix_hyphenation(text: &str) -> String {
|
||||
})
|
||||
.to_string();
|
||||
|
||||
dehyphenate_line_breaks(&result)
|
||||
}
|
||||
|
||||
/// What a line-break hyphen pair should become. Policy output only — how the
|
||||
/// decision is rendered (plain text vs. inside split emphasis markers) is the
|
||||
/// caller's business.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Join {
|
||||
/// The fragments are one word: "de- fendant" -> "defendant".
|
||||
Plain,
|
||||
/// The fragments are a hyphenated compound: "six- month" -> "six-month".
|
||||
Hyphen,
|
||||
/// No evidence (or a suspended hyphen): leave the break as it is.
|
||||
Keep,
|
||||
}
|
||||
|
||||
/// Decide what a line-break hyphen pair becomes, given the document's
|
||||
/// vocabulary evidence. The whole dehyphenation policy lives here so it can
|
||||
/// be reasoned about (and tested) apart from the Markdown scanning around it;
|
||||
/// see [`dehyphenate_line_breaks`] for the rule rationale.
|
||||
fn join_decision(
|
||||
a: &str,
|
||||
b: &str,
|
||||
words: &std::collections::HashSet<String>,
|
||||
hyphenated: &std::collections::HashSet<String>,
|
||||
) -> Join {
|
||||
// Word-length sanity: syllable fragments are short, and even long German
|
||||
// compounds stay under this. Fragments beyond it are already fused
|
||||
// reading-order noise (interleaved columns); joining would compound the
|
||||
// damage.
|
||||
if a.chars().count() + b.chars().count() > 40 {
|
||||
return Join::Keep;
|
||||
}
|
||||
let key_plain = format!("{}{}", a.to_lowercase(), b.to_lowercase());
|
||||
let key_hyphen = format!("{}-{}", a.to_lowercase(), b.to_lowercase());
|
||||
if words.contains(&key_plain) {
|
||||
Join::Plain
|
||||
} else if hyphenated.contains(&key_hyphen) || b.chars().next().is_some_and(|c| c.is_uppercase())
|
||||
{
|
||||
Join::Hyphen
|
||||
} else if b.chars().count() >= 4
|
||||
&& words.contains(&a.to_lowercase())
|
||||
&& words.contains(&b.to_lowercase())
|
||||
{
|
||||
// No direct evidence, but both fragments are themselves words the
|
||||
// document uses ("commercial- type"): hyphenated compounds are made
|
||||
// of words, while syllable fragments ("evi", "judg", "mo") are not.
|
||||
//
|
||||
// The continuation must be at least four letters. Suspended hyphens
|
||||
// ("mid- and long-term", "klein- und mittelgroß") put a conjunction
|
||||
// after the hyphen, and conjunctions are near-universally one to
|
||||
// three letters in any language — the length floor keeps this rule
|
||||
// off them without a hard-coded conjunction list.
|
||||
Join::Hyphen
|
||||
} else {
|
||||
// No evidence at all: leave the break as it is. An unconditional join
|
||||
// here covered only ~1% more breaks on a vocabulary-rich document,
|
||||
// but it was the sole rule able to corrupt output — fusing
|
||||
// interleaved-column fragments ("com- real" -> "comreal") into
|
||||
// unrecoverable tokens. A visible break is honest; a silent fusion
|
||||
// is not.
|
||||
Join::Keep
|
||||
}
|
||||
}
|
||||
|
||||
/// Rejoin words hyphenated at the original line breaks.
|
||||
///
|
||||
/// Justified print breaks words at syllables; after paragraph lines are
|
||||
/// joined with spaces those breaks survive as "de- fendant" (and, when an
|
||||
/// emphasis span was split with the word, "Bap-** **tist"). Whether the
|
||||
/// hyphen itself belongs in the word cannot be decided locally — "de-
|
||||
/// fendant" is one word but "Third- Party" is a hyphenated compound — so the
|
||||
/// document is its own dictionary:
|
||||
///
|
||||
/// 1. fragments appear elsewhere joined plain ("defendant") — join plain;
|
||||
/// 2. appear elsewhere hyphenated ("six-month"), or the continuation is
|
||||
/// capitalized ("Hinds- Radix", "Third- Party") — keep the hyphen;
|
||||
/// 3. both fragments are words the document uses and the continuation
|
||||
/// has four or more letters ("commercial- type" where "commercial"
|
||||
/// and "type" appear elsewhere) — a compound, keep the hyphen. The
|
||||
/// length floor keeps this rule off suspended hyphens ("mid- and
|
||||
/// long-term", "klein- und mittelgroß"): conjunctions are one to
|
||||
/// three letters in essentially every language, so no conjunction
|
||||
/// list is needed;
|
||||
/// 4. no evidence — leave the break untouched. Evidence covers ~99% of
|
||||
/// breaks on vocabulary-rich documents, and an unconditional join was
|
||||
/// the one rule able to corrupt output (fusing interleaved-column
|
||||
/// fragments into unrecoverable tokens).
|
||||
///
|
||||
/// Every rule is either document evidence or script-agnostic typography;
|
||||
/// deliberately no hard-coded word lists beyond the three suspension
|
||||
/// conjunctions (a curated suffix list was tried and removed — it was
|
||||
/// English-only, its membership was unfalsifiable, and it could invent
|
||||
/// hyphens: "proto- type" -> "proto-type").
|
||||
///
|
||||
/// Table rows and fenced code blocks are left untouched.
|
||||
fn dehyphenate_line_breaks(text: &str) -> String {
|
||||
use once_cell::sync::Lazy;
|
||||
use std::collections::HashSet;
|
||||
|
||||
const WORD: &str = r"\p{L}";
|
||||
|
||||
// "de- fendant"
|
||||
static BREAK_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(&format!("({WORD}{{2,}})- ({WORD}{{2,}})")).unwrap());
|
||||
// "Bap-** **tist" — an emphasis span split together with the word. The
|
||||
// regex crate has no backreferences, so both markers are captured and
|
||||
// compared in the replacement closure.
|
||||
static BREAK_EMPH_RE: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"({WORD}{{2,}})-(\*{{1,2}}) (\*{{1,2}})({WORD}{{2,}})"
|
||||
))
|
||||
.unwrap()
|
||||
});
|
||||
static PLAIN_WORD_RE: Lazy<Regex> = Lazy::new(|| Regex::new(&format!("{WORD}{{3,}}")).unwrap());
|
||||
static HYPHENATED_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(&format!("({WORD}{{2,}})-({WORD}{{2,}})")).unwrap());
|
||||
|
||||
// The document is its own dictionary: whole words and hyphenated
|
||||
// compounds as they appear away from line breaks. Two exclusions keep the
|
||||
// evidence sound:
|
||||
// - the break pairs themselves are scrubbed first, otherwise every
|
||||
// broken word donates its own fragments ("evi", "dence") and the
|
||||
// compound rule would see them as words;
|
||||
// - fenced code blocks are skipped: identifiers are a different
|
||||
// language, and one like `comreal` must not justify fusing prose.
|
||||
// Table rows stay — cells carry genuine document vocabulary.
|
||||
let mut prose = String::with_capacity(text.len());
|
||||
let mut in_code = false;
|
||||
for line in text.split('\n') {
|
||||
if line.trim_start().starts_with("```") {
|
||||
in_code = !in_code;
|
||||
continue;
|
||||
}
|
||||
if !in_code {
|
||||
prose.push_str(line);
|
||||
prose.push('\n');
|
||||
}
|
||||
}
|
||||
let scrubbed = BREAK_EMPH_RE.replace_all(&prose, " ");
|
||||
let scrubbed = BREAK_RE.replace_all(&scrubbed, " ");
|
||||
let mut words: HashSet<String> = HashSet::new();
|
||||
let mut hyphenated: HashSet<String> = HashSet::new();
|
||||
for m in PLAIN_WORD_RE.find_iter(&scrubbed) {
|
||||
words.insert(m.as_str().to_lowercase());
|
||||
}
|
||||
for caps in HYPHENATED_RE.captures_iter(&scrubbed) {
|
||||
hyphenated.insert(format!(
|
||||
"{}-{}",
|
||||
caps[1].to_lowercase(),
|
||||
caps[2].to_lowercase()
|
||||
));
|
||||
}
|
||||
|
||||
let join = |a: &str, b: &str| join_decision(a, b, &words, &hyphenated);
|
||||
|
||||
let mut in_code_block = false;
|
||||
let mut out = String::with_capacity(text.len());
|
||||
for (i, line) in text.split('\n').enumerate() {
|
||||
if i > 0 {
|
||||
out.push('\n');
|
||||
}
|
||||
if line.trim_start().starts_with("```") {
|
||||
in_code_block = !in_code_block;
|
||||
}
|
||||
if in_code_block || line.trim_start().starts_with('|') {
|
||||
out.push_str(line);
|
||||
continue;
|
||||
}
|
||||
// A break can chain ("unconsti- tu- tional"); each pass joins one
|
||||
// junction, and every successful join removes a break, so running
|
||||
// until the line is stable is bounded by the number of breaks.
|
||||
let mut current = line.to_string();
|
||||
loop {
|
||||
let next = BREAK_EMPH_RE
|
||||
.replace_all(¤t, |caps: ®ex::Captures| {
|
||||
// Mismatched markers aren't a split span; a Keep decision
|
||||
// preserves the original spacing and markers.
|
||||
if caps[2] != caps[3] {
|
||||
return caps[0].to_string();
|
||||
}
|
||||
let (a, b) = (&caps[1], &caps[4]);
|
||||
match join(a, b) {
|
||||
Join::Plain => format!("{a}{b}"),
|
||||
Join::Hyphen => format!("{a}-{b}"),
|
||||
Join::Keep => caps[0].to_string(),
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
let next = BREAK_RE
|
||||
.replace_all(&next, |caps: ®ex::Captures| {
|
||||
let (a, b) = (&caps[1], &caps[2]);
|
||||
match join(a, b) {
|
||||
Join::Plain => format!("{a}{b}"),
|
||||
Join::Hyphen => format!("{a}-{b}"),
|
||||
Join::Keep => caps[0].to_string(),
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
if next == current {
|
||||
break;
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
out.push_str(¤t);
|
||||
}
|
||||
out
|
||||
result
|
||||
}
|
||||
|
||||
/// Remove isolated page-number expressions from Markdown.
|
||||
@@ -594,180 +384,6 @@ mod tests {
|
||||
assert_eq!(t, "version 3 .14 released");
|
||||
}
|
||||
|
||||
// --- dehyphenate_line_breaks ---
|
||||
|
||||
#[test]
|
||||
fn line_break_joins_plain_on_vocabulary_evidence() {
|
||||
// "defendant" appears whole elsewhere, so the broken form joins plain.
|
||||
let text = "The defendant appeared. The de- fendant argued.";
|
||||
assert_eq!(
|
||||
dehyphenate_line_breaks(text),
|
||||
"The defendant appeared. The defendant argued."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_break_keeps_hyphen_on_hyphenated_evidence() {
|
||||
// "six-month" appears hyphenated elsewhere, so the broken form keeps it.
|
||||
let text = "A six-month term. After a six- month delay.";
|
||||
assert_eq!(
|
||||
dehyphenate_line_breaks(text),
|
||||
"A six-month term. After a six-month delay."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_evidence_leaves_the_break_untouched() {
|
||||
// Without document evidence a join cannot be distinguished from
|
||||
// interleaved-column noise; the visible break is kept.
|
||||
let text = "The evi- dence was clear.";
|
||||
assert_eq!(dehyphenate_line_breaks(text), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_break_keeps_hyphen_before_capitalized_continuation() {
|
||||
// Broken compounds: "Third-Party", "Hinds-Radix".
|
||||
let text = "The Third- Party complaint by Hinds- Radix.";
|
||||
assert_eq!(
|
||||
dehyphenate_line_breaks(text),
|
||||
"The Third-Party complaint by Hinds-Radix."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyrillic_words_join_on_evidence() {
|
||||
// The word class is Unicode-wide, not a hard-coded Latin subset.
|
||||
let text = "Это решение важно. Это реше- ние суда.";
|
||||
assert_eq!(
|
||||
dehyphenate_line_breaks(text),
|
||||
"Это решение важно. Это решение суда."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_spaced_breaks_join_through_clean_markdown() {
|
||||
// Space collapsing runs before hyphenation, so a break that arrives
|
||||
// with two spaces ("de- fendant") still rejoins.
|
||||
let options = MarkdownOptions::default();
|
||||
let out = clean_markdown(
|
||||
"The defendant appeared. The de- fendant argued.".to_string(),
|
||||
&options,
|
||||
);
|
||||
assert_eq!(
|
||||
out.trim_end(),
|
||||
"The defendant appeared. The defendant argued."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_block_identifiers_are_not_vocabulary_evidence() {
|
||||
// A fused identifier in code must not justify fusing unrelated prose.
|
||||
let text = "```\nlet comreal = 1;\n```\nThe com- real estate story.";
|
||||
assert_eq!(dehyphenate_line_breaks(text), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fused_column_noise_is_not_joined() {
|
||||
// Interleaved-column garbage arrives already fused; joining across
|
||||
// its breaks would compound the damage. Real syllable fragments are
|
||||
// short; fragments this long are left exactly as they are.
|
||||
let text = "spreadswerenegativeintheearlytomid- seriouslyflawedduetoappraisallags";
|
||||
assert_eq!(dehyphenate_line_breaks(text), text);
|
||||
// Long German compounds stay under the length gate and join on
|
||||
// vocabulary evidence.
|
||||
let german =
|
||||
"Das Bundesausbildungsförderungsgesetz. Das Bundesausbildungsförderungs- gesetz gilt.";
|
||||
assert_eq!(
|
||||
dehyphenate_line_breaks(german),
|
||||
"Das Bundesausbildungsförderungsgesetz. Das Bundesausbildungsförderungsgesetz gilt."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_evidence_compounds_stay_visibly_broken() {
|
||||
// No hard-coded suffix list: without document evidence even a likely
|
||||
// compound keeps its visible break. A curated list was tried and
|
||||
// removed — English-only, unfalsifiable membership, and able to
|
||||
// invent hyphens ("proto- type" -> "proto-type").
|
||||
let text = "Their world- class support and proto- type systems.";
|
||||
assert_eq!(dehyphenate_line_breaks(text), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_fragments_being_words_keeps_the_hyphen() {
|
||||
// "commercial-type insurance": no evidence either way, but both
|
||||
// fragments are words the document uses, so this is a compound.
|
||||
let text = "Any commercial firm of this type offering commercial- type insurance.";
|
||||
assert_eq!(
|
||||
dehyphenate_line_breaks(text),
|
||||
"Any commercial firm of this type offering commercial-type insurance."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suspended_hyphen_is_preserved() {
|
||||
// "mid- to long-term": joining would fuse unrelated words. No
|
||||
// conjunction list is involved — conjunctions are 1-3 letters in
|
||||
// essentially every language, and the compound rule requires a
|
||||
// 4-letter continuation, so suspended hyphens fall through to Keep.
|
||||
let text = "Planned over the mid- to long-term horizon, in- and out-of-possession.";
|
||||
assert_eq!(dehyphenate_line_breaks(text), text);
|
||||
// Same construction in German, which a hard-coded English list
|
||||
// would have missed. "klein" appears standalone so it IS in the
|
||||
// vocabulary — only the length floor (continuation "und" has three
|
||||
// letters) keeps the compound rule from fusing "klein-und".
|
||||
let german = "Das klein geschriebene Wort und die klein- und mittelgroßen Betriebe.";
|
||||
assert_eq!(dehyphenate_line_breaks(german), german);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_emphasis_span_joins_inside_markers() {
|
||||
// Vocabulary evidence ("Baptist", "Consolidated" elsewhere) drives
|
||||
// the join; the split emphasis markers collapse with it.
|
||||
let text = "The Baptist and Consolidated cases. By **Bap-** **tist** pastors and *Consoli-* *dated* Edison.";
|
||||
assert_eq!(
|
||||
dehyphenate_line_breaks(text),
|
||||
"The Baptist and Consolidated cases. By **Baptist** pastors and *Consolidated* Edison."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_emphasis_markers_are_left_alone() {
|
||||
let text = "Odd **Bap-** *tist* markers.";
|
||||
assert_eq!(dehyphenate_line_breaks(text), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chained_breaks_join_stepwise_with_evidence() {
|
||||
// A word broken twice joins across passes when each junction has
|
||||
// vocabulary evidence for its intermediate form.
|
||||
let text = "The word unconstitutional, and unconstitu appears too: unconsti- tu- tional.";
|
||||
assert_eq!(
|
||||
dehyphenate_line_breaks(text),
|
||||
"The word unconstitutional, and unconstitu appears too: unconstitutional."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_rows_and_code_blocks_are_untouched() {
|
||||
let text =
|
||||
"The defendant.\n|de- fendant|value|\n```\nlet x = de- fendant;\n```\nThe de- fendant won.";
|
||||
assert_eq!(
|
||||
dehyphenate_line_breaks(text),
|
||||
"The defendant.\n|de- fendant|value|\n```\nlet x = de- fendant;\n```\nThe defendant won."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accented_words_join() {
|
||||
// Spanish syllable break with accented continuation, evidence-backed.
|
||||
let text = "Una resolución firme. La resolu- ción fue clara.";
|
||||
assert_eq!(
|
||||
dehyphenate_line_breaks(text),
|
||||
"Una resolución firme. La resolución fue clara."
|
||||
);
|
||||
}
|
||||
|
||||
// --- fix_hyphenation ---
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -442,16 +442,6 @@ 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() || ('0'..='9').contains(&character)
|
||||
});
|
||||
}
|
||||
|
||||
// Check for common footnote patterns
|
||||
// (1), (2), etc.
|
||||
if trimmed.starts_with('(') && trimmed.len() >= 2 {
|
||||
@@ -513,13 +503,6 @@ 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
@@ -604,7 +604,7 @@ mod tests {
|
||||
let items: Vec<(usize, &TextItem)> = vec![];
|
||||
assert_eq!(
|
||||
find_column_boundaries(&items, TableDetectionMode::SmallFont),
|
||||
Vec::<f32>::new()
|
||||
vec![]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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::<f32>::new());
|
||||
assert_eq!(find_row_boundaries(&items), vec![]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
//! Public contracts between rendering, OCR, and orchestration.
|
||||
|
||||
use std::error::Error;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::{RenderOptions, RenderedPage};
|
||||
|
||||
/// Selects when OCR may run.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum OcrMode {
|
||||
/// Never run OCR. This is the default and preserves existing behavior.
|
||||
#[default]
|
||||
Off,
|
||||
/// Run OCR only on pages selected by pdf-inspector's OCR routing signals.
|
||||
Auto,
|
||||
/// Run OCR on every selected page, including pages with native text.
|
||||
Force,
|
||||
}
|
||||
|
||||
/// Controls whether missing model artifacts may be fetched.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum ModelDownloadPolicy {
|
||||
/// Fetch a pinned artifact only after OCR has actually been selected.
|
||||
#[default]
|
||||
IfMissing,
|
||||
/// Never access the network; require an override or a warm model cache.
|
||||
Offline,
|
||||
}
|
||||
|
||||
/// OCR engine configuration independent of a particular runtime.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct OcrOptions {
|
||||
/// Page-level routing behavior.
|
||||
pub mode: OcrMode,
|
||||
/// Drop recognition spans below this confidence threshold.
|
||||
pub minimum_confidence: f32,
|
||||
/// Optional directory containing an offline model set.
|
||||
pub model_directory: Option<PathBuf>,
|
||||
/// Whether a missing pinned artifact may be downloaded.
|
||||
pub model_downloads: ModelDownloadPolicy,
|
||||
}
|
||||
|
||||
impl Default for OcrOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: OcrMode::Off,
|
||||
minimum_confidence: 0.0,
|
||||
model_directory: None,
|
||||
model_downloads: ModelDownloadPolicy::IfMissing,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OcrOptions {
|
||||
/// Creates OCR options with OCR disabled.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Sets page-level OCR routing.
|
||||
pub fn mode(mut self, mode: OcrMode) -> Self {
|
||||
self.mode = mode;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the minimum accepted recognition confidence.
|
||||
pub fn minimum_confidence(mut self, minimum_confidence: f32) -> Self {
|
||||
self.minimum_confidence = minimum_confidence;
|
||||
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());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the missing-model download policy.
|
||||
pub fn model_downloads(mut self, policy: ModelDownloadPolicy) -> Self {
|
||||
self.model_downloads = policy;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A point in bitmap space, measured from the top-left in pixels.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct ImagePoint {
|
||||
/// Horizontal pixel coordinate.
|
||||
pub x: f32,
|
||||
/// Vertical pixel coordinate, increasing downward.
|
||||
pub y: f32,
|
||||
}
|
||||
|
||||
impl ImagePoint {
|
||||
/// Creates a bitmap-space point.
|
||||
pub fn new(x: f32, y: f32) -> Self {
|
||||
Self { x, y }
|
||||
}
|
||||
}
|
||||
|
||||
/// Four-point polygon in bitmap coordinates.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct ImageQuad {
|
||||
/// Polygon points in engine-provided order.
|
||||
pub points: [ImagePoint; 4],
|
||||
}
|
||||
|
||||
impl ImageQuad {
|
||||
/// Creates a four-point bitmap polygon.
|
||||
pub fn new(points: [ImagePoint; 4]) -> Self {
|
||||
Self { points }
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable identity for an inference model used in output provenance.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ModelIdentity {
|
||||
/// Model family/name, for example `pp-ocrv6-small`.
|
||||
pub name: String,
|
||||
/// Immutable model or artifact-set revision.
|
||||
pub revision: String,
|
||||
}
|
||||
|
||||
impl ModelIdentity {
|
||||
/// Creates a model identity.
|
||||
pub fn new(name: impl Into<String>, revision: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
revision: revision.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One positioned OCR recognition result in bitmap coordinates.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct OcrSpan {
|
||||
/// Recognized text.
|
||||
pub text: String,
|
||||
/// Detection polygon in the original rendered page's pixel space.
|
||||
pub polygon: ImageQuad,
|
||||
/// Recognition confidence in the inclusive range 0–1.
|
||||
pub confidence: f32,
|
||||
/// Optional text-line orientation in clockwise degrees.
|
||||
pub orientation_degrees: Option<f32>,
|
||||
}
|
||||
|
||||
/// OCR output for one 1-indexed page.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct OcrPage {
|
||||
/// 1-indexed PDF page number.
|
||||
pub page_number: u32,
|
||||
/// Positioned recognition spans.
|
||||
pub spans: Vec<OcrSpan>,
|
||||
/// Mean confidence across accepted spans, when available.
|
||||
pub mean_confidence: Option<f32>,
|
||||
/// Exact model identity used for this result.
|
||||
pub model: ModelIdentity,
|
||||
/// OCR 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]
|
||||
pub enum PageContentSource {
|
||||
/// Trusted native PDF text only.
|
||||
Native,
|
||||
/// OCR output only.
|
||||
Ocr,
|
||||
/// Native and OCR spans were fused.
|
||||
Fused,
|
||||
}
|
||||
|
||||
/// Per-page local processing timings.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct VisionTimings {
|
||||
/// Rasterization wall time.
|
||||
pub render_ms: u64,
|
||||
/// OCR wall time.
|
||||
pub ocr_ms: u64,
|
||||
/// Native/OCR fusion and assembly wall time.
|
||||
pub assembly_ms: u64,
|
||||
}
|
||||
|
||||
/// Source and model metadata retained for one processed page.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PageProvenance {
|
||||
/// 1-indexed PDF page number.
|
||||
pub page_number: u32,
|
||||
/// Final page-content source.
|
||||
pub source: PageContentSource,
|
||||
/// OCR model, when OCR ran.
|
||||
pub ocr_model: Option<ModelIdentity>,
|
||||
/// Render resolution used for local vision.
|
||||
pub render_dpi: Option<f32>,
|
||||
/// Mean accepted OCR confidence, when available.
|
||||
pub ocr_confidence: Option<f32>,
|
||||
/// Stage timings.
|
||||
pub timings: VisionTimings,
|
||||
/// Non-fatal warnings surfaced to downstream users.
|
||||
pub warnings: Vec<String>,
|
||||
/// True when this lightweight local path detected a case better suited to
|
||||
/// Firecrawl's hosted document pipeline.
|
||||
pub hosted_recommended: bool,
|
||||
}
|
||||
|
||||
/// Converts selected PDF pages into renderer-neutral owned bitmaps.
|
||||
pub trait PageRenderer: Send + Sync {
|
||||
/// Renderer-specific failure type.
|
||||
type Error: Error + Send + Sync + 'static;
|
||||
|
||||
/// Renders selected 1-indexed pages in the same order as `pages`.
|
||||
fn render_pages(
|
||||
&self,
|
||||
pdf_bytes: &[u8],
|
||||
pages: &[u32],
|
||||
password: Option<&str>,
|
||||
options: &RenderOptions,
|
||||
) -> Result<Vec<RenderedPage>, Self::Error>;
|
||||
}
|
||||
|
||||
/// Recognizes positioned text from rendered pages.
|
||||
pub trait OcrEngine: Send + Sync {
|
||||
/// Engine-specific failure type.
|
||||
type Error: Error + Send + Sync + 'static;
|
||||
|
||||
/// Exact model identity used by this engine instance.
|
||||
fn model(&self) -> &ModelIdentity;
|
||||
|
||||
/// Recognizes pages in batch and returns results in input order.
|
||||
fn recognize(
|
||||
&self,
|
||||
pages: &[RenderedPage],
|
||||
options: &OcrOptions,
|
||||
) -> Result<Vec<OcrPage>, Self::Error>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ocr_defaults_never_enable_recognition() {
|
||||
let options = OcrOptions::default();
|
||||
assert_eq!(options.mode, OcrMode::Off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_model_override_is_explicit() {
|
||||
let options = OcrOptions::new()
|
||||
.mode(OcrMode::Auto)
|
||||
.model_directory("/models/pp-ocr")
|
||||
.model_downloads(ModelDownloadPolicy::Offline);
|
||||
assert_eq!(options.mode, OcrMode::Auto);
|
||||
assert_eq!(options.model_downloads, ModelDownloadPolicy::Offline);
|
||||
assert_eq!(
|
||||
options.model_directory,
|
||||
Some(PathBuf::from("/models/pp-ocr"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
//! 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,
|
||||
},
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,65 +0,0 @@
|
||||
//! Optional native vision primitives used by OCR pipelines.
|
||||
//!
|
||||
//! 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. 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, 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::{
|
||||
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")))]
|
||||
pub use pdfium::{PdfiumRenderer, RenderError};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,496 +0,0 @@
|
||||
//! 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(®ion.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());
|
||||
}
|
||||
}
|
||||
@@ -1,478 +0,0 @@
|
||||
//! PDFium-backed implementation of the renderer-neutral page contract.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use firecrawl_pdfium::{PageChar, Pdfium, PixelFormat, PixelPoint, RenderConfig};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::types::{ItemType, TextItem};
|
||||
|
||||
use super::{
|
||||
PageRenderer, PageTransform, RenderBufferError, RenderOptions, RenderPixelFormat, RenderedPage,
|
||||
};
|
||||
|
||||
impl RenderPixelFormat {
|
||||
fn pdfium_format(self) -> PixelFormat {
|
||||
match self {
|
||||
// PDFium produces BGR directly; `rendered_page_from_pdfium`
|
||||
// swaps the red and blue channels in place.
|
||||
Self::Rgb8 => PixelFormat::Bgr8,
|
||||
Self::Rgba8 => PixelFormat::Rgba8,
|
||||
Self::Gray8 => PixelFormat::Gray8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOptions {
|
||||
fn pdfium_config(&self) -> RenderConfig {
|
||||
RenderConfig::new()
|
||||
.dpi(self.dpi)
|
||||
.pixel_format(self.pixel_format.pdfium_format())
|
||||
.annotations(self.annotations)
|
||||
.form_fields(self.form_fields)
|
||||
.max_output_bytes(self.max_output_bytes_per_page)
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors produced by the optional local renderer.
|
||||
#[derive(Debug, Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum RenderError {
|
||||
/// Page numbers in pdf-inspector APIs are 1-indexed, so zero is invalid.
|
||||
#[error("page numbers are 1-indexed; page 0 is invalid")]
|
||||
InvalidPageNumber,
|
||||
/// The requested 1-indexed page is not present in the document.
|
||||
#[error("page {page} is out of bounds for a {page_count}-page document")]
|
||||
PageOutOfBounds {
|
||||
/// Requested 1-indexed page.
|
||||
page: u32,
|
||||
/// 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),
|
||||
/// PDFium returned an internally inconsistent bitmap or transform.
|
||||
#[error(transparent)]
|
||||
Buffer(#[from] RenderBufferError),
|
||||
}
|
||||
|
||||
/// Loaded PDFium renderer used to prepare pages for OCR.
|
||||
///
|
||||
/// PDFium calls are safe from concurrent threads but serialize inside the
|
||||
/// underlying binding. Returned [`RenderedPage`] values are ordinary owned
|
||||
/// data and can be processed concurrently after rendering.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
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().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)
|
||||
.map_err(|source| RenderError::PdfiumLoad { source })?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Path of the active PDFium library, if it was loaded from a concrete
|
||||
/// file rather than through the system loader.
|
||||
pub fn loaded_from(&self) -> Option<&Path> {
|
||||
self.pdfium.loaded_from()
|
||||
}
|
||||
|
||||
/// Renders selected 1-indexed pages in the same order as `pages`.
|
||||
///
|
||||
/// This inherent method mirrors [`PageRenderer`] so existing callers do
|
||||
/// not need to import the trait.
|
||||
pub fn render_pages(
|
||||
&self,
|
||||
pdf_bytes: &[u8],
|
||||
pages: &[u32],
|
||||
password: Option<&str>,
|
||||
options: &RenderOptions,
|
||||
) -> Result<Vec<RenderedPage>, RenderError> {
|
||||
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],
|
||||
pages: &[u32],
|
||||
password: Option<&str>,
|
||||
options: &RenderOptions,
|
||||
) -> Result<Vec<RenderedPage>, RenderError> {
|
||||
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 });
|
||||
}
|
||||
|
||||
if options.form_fields {
|
||||
document.enable_form_rendering()?;
|
||||
}
|
||||
|
||||
let config = options.pdfium_config();
|
||||
let mut rendered_pages = Vec::with_capacity(pages.len());
|
||||
for &page_number in pages {
|
||||
let page = document.page(page_number as usize - 1)?;
|
||||
let size = page.size();
|
||||
let rendered = page.render(&config)?;
|
||||
rendered_pages.push(rendered_page_from_pdfium(
|
||||
page_number,
|
||||
size.width,
|
||||
size.height,
|
||||
options.pixel_format,
|
||||
rendered,
|
||||
)?);
|
||||
}
|
||||
|
||||
Ok(rendered_pages)
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
fn render_pages(
|
||||
&self,
|
||||
pdf_bytes: &[u8],
|
||||
pages: &[u32],
|
||||
password: Option<&str>,
|
||||
options: &RenderOptions,
|
||||
) -> Result<Vec<RenderedPage>, Self::Error> {
|
||||
self.render_pages_impl(pdf_bytes, pages, password, options)
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered_page_from_pdfium(
|
||||
page: u32,
|
||||
page_width: f32,
|
||||
page_height: f32,
|
||||
format: RenderPixelFormat,
|
||||
rendered: firecrawl_pdfium::RenderedPage,
|
||||
) -> Result<RenderedPage, RenderBufferError> {
|
||||
let width = rendered.width();
|
||||
let height = rendered.height();
|
||||
let stride = rendered.stride();
|
||||
let pdfium_transform = *rendered.transform();
|
||||
let corner = |x, y| {
|
||||
let point = pdfium_transform.pixel_to_page(PixelPoint::new(x, y));
|
||||
(point.x, point.y)
|
||||
};
|
||||
let transform = PageTransform::from_corners(
|
||||
width,
|
||||
height,
|
||||
corner(0.0, 0.0),
|
||||
corner(f64::from(width), 0.0),
|
||||
corner(0.0, f64::from(height)),
|
||||
)
|
||||
.ok_or(RenderBufferError::InvalidTransform)?;
|
||||
let mut pixels = rendered.into_pixels();
|
||||
|
||||
if format == RenderPixelFormat::Rgb8 {
|
||||
bgr_to_rgb_in_place(&mut pixels, width, height, stride)?;
|
||||
}
|
||||
|
||||
RenderedPage::new(
|
||||
page,
|
||||
page_width,
|
||||
page_height,
|
||||
width,
|
||||
height,
|
||||
stride,
|
||||
format,
|
||||
pixels,
|
||||
transform,
|
||||
)
|
||||
}
|
||||
|
||||
fn bgr_to_rgb_in_place(
|
||||
pixels: &mut [u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
stride: usize,
|
||||
) -> Result<(), RenderBufferError> {
|
||||
let row_bytes = (width as usize)
|
||||
.checked_mul(RenderPixelFormat::Rgb8.bytes_per_pixel())
|
||||
.ok_or(RenderBufferError::SizeOverflow)?;
|
||||
if stride < row_bytes {
|
||||
return Err(RenderBufferError::InvalidStride {
|
||||
stride,
|
||||
minimum: row_bytes,
|
||||
});
|
||||
}
|
||||
let expected = stride
|
||||
.checked_mul(height as usize)
|
||||
.ok_or(RenderBufferError::SizeOverflow)?;
|
||||
if pixels.len() != expected {
|
||||
return Err(RenderBufferError::InvalidBufferLength {
|
||||
actual: pixels.len(),
|
||||
expected,
|
||||
});
|
||||
}
|
||||
|
||||
for row in pixels.chunks_exact_mut(stride) {
|
||||
for pixel in row[..row_bytes].chunks_exact_mut(3) {
|
||||
pixel.swap(0, 2);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[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() {
|
||||
let mut pixels = vec![1, 2, 3, 4, 5, 6];
|
||||
bgr_to_rgb_in_place(&mut pixels, 2, 1, 6).unwrap();
|
||||
assert_eq!(pixels, [3, 2, 1, 6, 5, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bgr_conversion_skips_row_padding() {
|
||||
let mut pixels = vec![1, 2, 3, 9, 7, 8, 9, 6];
|
||||
bgr_to_rgb_in_place(&mut pixels, 1, 2, 4).unwrap();
|
||||
assert_eq!(pixels, [3, 2, 1, 9, 9, 8, 7, 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_bgr_buffers_return_errors() {
|
||||
assert!(matches!(
|
||||
bgr_to_rgb_in_place(&mut [0; 6], 2, 1, 5),
|
||||
Err(RenderBufferError::InvalidStride { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
bgr_to_rgb_in_place(&mut [0; 5], 1, 2, 3),
|
||||
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
@@ -1,552 +0,0 @@
|
||||
//! Renderer-neutral page bitmap and coordinate types.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::PdfRect;
|
||||
|
||||
/// Default rendering resolution for OCR.
|
||||
pub const DEFAULT_RENDER_DPI: f32 = 150.0;
|
||||
|
||||
/// Default maximum size of one rendered page: 256 MiB.
|
||||
pub const DEFAULT_MAX_OUTPUT_BYTES: u64 = 256 * 1024 * 1024;
|
||||
|
||||
/// Pixel layout returned by [`RenderedPage`].
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum RenderPixelFormat {
|
||||
/// Three bytes per pixel in red, green, blue order. This is the default
|
||||
/// because OCR preprocessors conventionally consume RGB images.
|
||||
#[default]
|
||||
Rgb8,
|
||||
/// Four bytes per pixel in red, green, blue, alpha order.
|
||||
Rgba8,
|
||||
/// One luminance byte per pixel.
|
||||
Gray8,
|
||||
}
|
||||
|
||||
impl RenderPixelFormat {
|
||||
/// Number of bytes used by one pixel.
|
||||
pub fn bytes_per_pixel(self) -> usize {
|
||||
match self {
|
||||
Self::Rgb8 => 3,
|
||||
Self::Rgba8 => 4,
|
||||
Self::Gray8 => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for pages rendered as input to a local vision pipeline.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RenderOptions {
|
||||
/// Output resolution. Defaults to 150 DPI.
|
||||
pub dpi: f32,
|
||||
/// Pixel layout. Defaults to three-channel RGB.
|
||||
pub pixel_format: RenderPixelFormat,
|
||||
/// Include PDF annotations in the rendered bitmap.
|
||||
pub annotations: bool,
|
||||
/// Include visible static AcroForm field appearances.
|
||||
pub form_fields: bool,
|
||||
/// Maximum allocation for each rendered page.
|
||||
pub max_output_bytes_per_page: u64,
|
||||
}
|
||||
|
||||
impl Default for RenderOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dpi: DEFAULT_RENDER_DPI,
|
||||
pixel_format: RenderPixelFormat::Rgb8,
|
||||
annotations: true,
|
||||
form_fields: true,
|
||||
max_output_bytes_per_page: DEFAULT_MAX_OUTPUT_BYTES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOptions {
|
||||
/// Creates local-rendering options with OCR-oriented defaults.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Sets the output resolution in dots per inch.
|
||||
pub fn dpi(mut self, dpi: f32) -> Self {
|
||||
self.dpi = dpi;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the output pixel layout.
|
||||
pub fn pixel_format(mut self, pixel_format: RenderPixelFormat) -> Self {
|
||||
self.pixel_format = pixel_format;
|
||||
self
|
||||
}
|
||||
|
||||
/// Toggles annotation rendering.
|
||||
pub fn annotations(mut self, annotations: bool) -> Self {
|
||||
self.annotations = annotations;
|
||||
self
|
||||
}
|
||||
|
||||
/// Toggles visible static form-field rendering.
|
||||
pub fn form_fields(mut self, form_fields: bool) -> Self {
|
||||
self.form_fields = form_fields;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the maximum allocation for each rendered page.
|
||||
pub fn max_output_bytes_per_page(mut self, bytes: u64) -> Self {
|
||||
self.max_output_bytes_per_page = bytes;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A point in PDF page space, measured in points from the bottom-left.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PagePoint {
|
||||
/// Horizontal position in PDF points.
|
||||
pub x: f32,
|
||||
/// Vertical position in PDF points, increasing upward.
|
||||
pub y: f32,
|
||||
}
|
||||
|
||||
/// Affine transform between top-left pixel space and PDF page space.
|
||||
///
|
||||
/// Renderers create this from the page-space images of the bitmap corners.
|
||||
/// Keeping the coefficients in pdf-inspector makes [`RenderedPage`] neutral
|
||||
/// to the renderer implementation that produced it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PageTransform {
|
||||
forward: [f64; 6],
|
||||
inverse: [f64; 6],
|
||||
pixel_width: u32,
|
||||
pixel_height: u32,
|
||||
}
|
||||
|
||||
impl PageTransform {
|
||||
/// Builds a transform from the PDF-space images of device corners
|
||||
/// `(0, 0)`, `(pixel_width, 0)`, and `(0, pixel_height)`.
|
||||
pub fn from_corners(
|
||||
pixel_width: u32,
|
||||
pixel_height: u32,
|
||||
origin: (f64, f64),
|
||||
x_axis: (f64, f64),
|
||||
y_axis: (f64, f64),
|
||||
) -> Option<Self> {
|
||||
if pixel_width == 0 || pixel_height == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let values = [origin.0, origin.1, x_axis.0, x_axis.1, y_axis.0, y_axis.1];
|
||||
if values.iter().any(|value| !value.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let width = f64::from(pixel_width);
|
||||
let height = f64::from(pixel_height);
|
||||
let a = (x_axis.0 - origin.0) / width;
|
||||
let c = (x_axis.1 - origin.1) / width;
|
||||
let b = (y_axis.0 - origin.0) / height;
|
||||
let d = (y_axis.1 - origin.1) / height;
|
||||
let (e, f) = origin;
|
||||
let forward = [a, b, c, d, e, f];
|
||||
if forward.iter().any(|coefficient| !coefficient.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let determinant = a * d - b * c;
|
||||
if determinant == 0.0 || !determinant.is_finite() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let inverse_a = d / determinant;
|
||||
let inverse_b = -b / determinant;
|
||||
let inverse_c = -c / determinant;
|
||||
let inverse_d = a / determinant;
|
||||
let inverse_e = -(inverse_a * e + inverse_b * f);
|
||||
let inverse_f = -(inverse_c * e + inverse_d * f);
|
||||
let inverse = [
|
||||
inverse_a, inverse_b, inverse_c, inverse_d, inverse_e, inverse_f,
|
||||
];
|
||||
if inverse.iter().any(|coefficient| !coefficient.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
forward,
|
||||
inverse,
|
||||
pixel_width,
|
||||
pixel_height,
|
||||
})
|
||||
}
|
||||
|
||||
/// Width of the bitmap this transform describes.
|
||||
pub fn pixel_width(&self) -> u32 {
|
||||
self.pixel_width
|
||||
}
|
||||
|
||||
/// Height of the bitmap this transform describes.
|
||||
pub fn pixel_height(&self) -> u32 {
|
||||
self.pixel_height
|
||||
}
|
||||
|
||||
/// Converts a bitmap point to PDF page space.
|
||||
pub fn pixel_to_page(&self, x: f64, y: f64) -> PagePoint {
|
||||
let [a, b, c, d, e, f] = self.forward;
|
||||
PagePoint {
|
||||
x: (a * x + b * y + e) as f32,
|
||||
y: (c * x + d * y + f) as f32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a PDF page-space point to bitmap coordinates.
|
||||
pub fn page_to_pixel(&self, x: f64, y: f64) -> (f64, f64) {
|
||||
let [a, b, c, d, e, f] = self.inverse;
|
||||
(a * x + b * y + e, c * x + d * y + f)
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalid renderer output rejected by [`RenderedPage::new`].
|
||||
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum RenderBufferError {
|
||||
/// Page numbers are 1-indexed.
|
||||
#[error("rendered page number must be at least 1")]
|
||||
InvalidPageNumber,
|
||||
/// Bitmap dimensions must be non-zero.
|
||||
#[error("rendered bitmap dimensions must be non-zero")]
|
||||
InvalidDimensions,
|
||||
/// Page dimensions must be positive finite numbers.
|
||||
#[error("rendered PDF page dimensions must be positive and finite")]
|
||||
InvalidPageDimensions,
|
||||
/// Transform dimensions must match the bitmap dimensions.
|
||||
#[error("coordinate transform dimensions do not match the rendered bitmap")]
|
||||
TransformDimensions,
|
||||
/// Renderer did not provide an invertible finite coordinate transform.
|
||||
#[error("renderer returned an invalid coordinate transform")]
|
||||
InvalidTransform,
|
||||
/// The stride cannot hold one active row of pixels.
|
||||
#[error("pixel stride {stride} is shorter than the active row size {minimum}")]
|
||||
InvalidStride {
|
||||
/// Supplied bytes per row.
|
||||
stride: usize,
|
||||
/// Minimum bytes required for one row.
|
||||
minimum: usize,
|
||||
},
|
||||
/// Pixel buffer size is inconsistent with height and stride.
|
||||
#[error("pixel buffer has {actual} bytes; expected {expected}")]
|
||||
InvalidBufferLength {
|
||||
/// Actual byte count.
|
||||
actual: usize,
|
||||
/// Required byte count.
|
||||
expected: usize,
|
||||
},
|
||||
/// Dimension arithmetic overflowed the host address space.
|
||||
#[error("rendered bitmap dimensions overflow the host address space")]
|
||||
SizeOverflow,
|
||||
}
|
||||
|
||||
/// One rendered page with owned pixels and its pixel-to-PDF transform.
|
||||
///
|
||||
/// The value contains no live renderer, page, or document handles. It can be
|
||||
/// moved to an OCR worker and retained after rendering returns.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RenderedPage {
|
||||
page: u32,
|
||||
page_width: f32,
|
||||
page_height: f32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
stride: usize,
|
||||
format: RenderPixelFormat,
|
||||
pixels: Vec<u8>,
|
||||
transform: PageTransform,
|
||||
}
|
||||
|
||||
impl RenderedPage {
|
||||
/// Creates a renderer-neutral owned page after validating its buffer.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
page: u32,
|
||||
page_width: f32,
|
||||
page_height: f32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
stride: usize,
|
||||
format: RenderPixelFormat,
|
||||
pixels: Vec<u8>,
|
||||
transform: PageTransform,
|
||||
) -> Result<Self, RenderBufferError> {
|
||||
if page == 0 {
|
||||
return Err(RenderBufferError::InvalidPageNumber);
|
||||
}
|
||||
if width == 0 || height == 0 {
|
||||
return Err(RenderBufferError::InvalidDimensions);
|
||||
}
|
||||
if page_width <= 0.0
|
||||
|| page_height <= 0.0
|
||||
|| !page_width.is_finite()
|
||||
|| !page_height.is_finite()
|
||||
{
|
||||
return Err(RenderBufferError::InvalidPageDimensions);
|
||||
}
|
||||
if transform.pixel_width() != width || transform.pixel_height() != height {
|
||||
return Err(RenderBufferError::TransformDimensions);
|
||||
}
|
||||
|
||||
let row_bytes = (width as usize)
|
||||
.checked_mul(format.bytes_per_pixel())
|
||||
.ok_or(RenderBufferError::SizeOverflow)?;
|
||||
if stride < row_bytes {
|
||||
return Err(RenderBufferError::InvalidStride {
|
||||
stride,
|
||||
minimum: row_bytes,
|
||||
});
|
||||
}
|
||||
let expected = stride
|
||||
.checked_mul(height as usize)
|
||||
.ok_or(RenderBufferError::SizeOverflow)?;
|
||||
if pixels.len() != expected {
|
||||
return Err(RenderBufferError::InvalidBufferLength {
|
||||
actual: pixels.len(),
|
||||
expected,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
page,
|
||||
page_width,
|
||||
page_height,
|
||||
width,
|
||||
height,
|
||||
stride,
|
||||
format,
|
||||
pixels,
|
||||
transform,
|
||||
})
|
||||
}
|
||||
|
||||
/// 1-indexed page number.
|
||||
pub fn page(&self) -> u32 {
|
||||
self.page
|
||||
}
|
||||
|
||||
/// Page width in PDF points after applying the page's rotation.
|
||||
pub fn page_width(&self) -> f32 {
|
||||
self.page_width
|
||||
}
|
||||
|
||||
/// Page height in PDF points after applying the page's rotation.
|
||||
pub fn page_height(&self) -> f32 {
|
||||
self.page_height
|
||||
}
|
||||
|
||||
/// Bitmap width in pixels.
|
||||
pub fn width(&self) -> u32 {
|
||||
self.width
|
||||
}
|
||||
|
||||
/// Bitmap height in pixels.
|
||||
pub fn height(&self) -> u32 {
|
||||
self.height
|
||||
}
|
||||
|
||||
/// Number of bytes between adjacent bitmap rows.
|
||||
pub fn stride(&self) -> usize {
|
||||
self.stride
|
||||
}
|
||||
|
||||
/// Pixel layout of [`pixels`](Self::pixels).
|
||||
pub fn format(&self) -> RenderPixelFormat {
|
||||
self.format
|
||||
}
|
||||
|
||||
/// Owned bitmap bytes, with rows ordered top-to-bottom.
|
||||
pub fn pixels(&self) -> &[u8] {
|
||||
&self.pixels
|
||||
}
|
||||
|
||||
/// Consumes the page and returns its pixel buffer.
|
||||
pub fn into_pixels(self) -> Vec<u8> {
|
||||
self.pixels
|
||||
}
|
||||
|
||||
/// Coordinate transform associated with the rendered page.
|
||||
pub fn transform(&self) -> PageTransform {
|
||||
self.transform
|
||||
}
|
||||
|
||||
/// Converts a bitmap point (top-left origin, y-down) to PDF page space
|
||||
/// (bottom-left origin, y-up).
|
||||
pub fn pixel_to_page(&self, x: f64, y: f64) -> PagePoint {
|
||||
self.transform.pixel_to_page(x, y)
|
||||
}
|
||||
|
||||
/// Converts a bitmap rectangle to the repository's existing PDF-space
|
||||
/// rectangle type. The returned page number remains 1-indexed.
|
||||
pub fn pixel_rect_to_pdf_rect(&self, x: f64, y: f64, width: f64, height: f64) -> PdfRect {
|
||||
let points = [
|
||||
self.transform.pixel_to_page(x, y),
|
||||
self.transform.pixel_to_page(x + width, y),
|
||||
self.transform.pixel_to_page(x, y + height),
|
||||
self.transform.pixel_to_page(x + width, y + height),
|
||||
];
|
||||
let left = points
|
||||
.iter()
|
||||
.map(|point| point.x)
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
let right = points
|
||||
.iter()
|
||||
.map(|point| point.x)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
let bottom = points
|
||||
.iter()
|
||||
.map(|point| point.y)
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
let top = points
|
||||
.iter()
|
||||
.map(|point| point.y)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
PdfRect {
|
||||
x: left,
|
||||
y: bottom,
|
||||
width: right - left,
|
||||
height: top - bottom,
|
||||
page: self.page,
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a PDF-space rectangle to bitmap coordinates
|
||||
/// `(x, y, width, height)` with a top-left origin.
|
||||
pub fn pdf_rect_to_pixel(&self, rect: &PdfRect) -> (f64, f64, f64, f64) {
|
||||
let left = f64::from(rect.x);
|
||||
let right = f64::from(rect.x + rect.width);
|
||||
let bottom = f64::from(rect.y);
|
||||
let top = f64::from(rect.y + rect.height);
|
||||
let points = [
|
||||
self.transform.page_to_pixel(left, bottom),
|
||||
self.transform.page_to_pixel(right, bottom),
|
||||
self.transform.page_to_pixel(left, top),
|
||||
self.transform.page_to_pixel(right, top),
|
||||
];
|
||||
let min_x = points
|
||||
.iter()
|
||||
.map(|point| point.0)
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
let max_x = points
|
||||
.iter()
|
||||
.map(|point| point.0)
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
let min_y = points
|
||||
.iter()
|
||||
.map(|point| point.1)
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
let max_y = points
|
||||
.iter()
|
||||
.map(|point| point.1)
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
(min_x, min_y, max_x - min_x, max_y - min_y)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn transform() -> PageTransform {
|
||||
PageTransform::from_corners(400, 200, (0.0, 100.0), (200.0, 100.0), (0.0, 0.0)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_maps_both_directions_at_non_identity_scale() {
|
||||
let transform = transform();
|
||||
let point = transform.pixel_to_page(100.0, 50.0);
|
||||
assert!((point.x - 50.0).abs() < 1e-6);
|
||||
assert!((point.y - 75.0).abs() < 1e-6);
|
||||
let pixel = transform.page_to_pixel(f64::from(point.x), f64::from(point.y));
|
||||
assert!((pixel.0 - 100.0).abs() < 1e-6);
|
||||
assert!((pixel.1 - 50.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendered_page_accepts_padding_and_validates_length() {
|
||||
let page = RenderedPage::new(
|
||||
1,
|
||||
200.0,
|
||||
100.0,
|
||||
400,
|
||||
200,
|
||||
1_204,
|
||||
RenderPixelFormat::Rgb8,
|
||||
vec![0; 1_204 * 200],
|
||||
transform(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(page.stride(), 1_204);
|
||||
|
||||
assert!(matches!(
|
||||
RenderedPage::new(
|
||||
1,
|
||||
200.0,
|
||||
100.0,
|
||||
400,
|
||||
200,
|
||||
1_204,
|
||||
RenderPixelFormat::Rgb8,
|
||||
vec![0; 5],
|
||||
transform(),
|
||||
),
|
||||
Err(RenderBufferError::InvalidBufferLength { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_transform_round_trips_rectangles() {
|
||||
let transform =
|
||||
PageTransform::from_corners(100, 200, (0.0, 0.0), (0.0, 100.0), (200.0, 0.0)).unwrap();
|
||||
let page = RenderedPage::new(
|
||||
1,
|
||||
200.0,
|
||||
100.0,
|
||||
100,
|
||||
200,
|
||||
300,
|
||||
RenderPixelFormat::Rgb8,
|
||||
vec![0; 300 * 200],
|
||||
transform,
|
||||
)
|
||||
.unwrap();
|
||||
let pdf = page.pixel_rect_to_pdf_rect(10.0, 20.0, 30.0, 40.0);
|
||||
let pixel = page.pdf_rect_to_pixel(&pdf);
|
||||
assert!((pixel.0 - 10.0).abs() < 1e-5);
|
||||
assert!((pixel.1 - 20.0).abs() < 1e-5);
|
||||
assert!((pixel.2 - 30.0).abs() < 1e-5);
|
||||
assert!((pixel.3 - 40.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skewed_transform_bounds_all_rectangle_corners() {
|
||||
let transform =
|
||||
PageTransform::from_corners(100, 100, (0.0, 100.0), (100.0, 125.0), (25.0, 0.0))
|
||||
.unwrap();
|
||||
let page = RenderedPage::new(
|
||||
1,
|
||||
125.0,
|
||||
125.0,
|
||||
100,
|
||||
100,
|
||||
300,
|
||||
RenderPixelFormat::Rgb8,
|
||||
vec![0; 30_000],
|
||||
transform,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pdf = page.pixel_rect_to_pdf_rect(10.0, 20.0, 30.0, 40.0);
|
||||
assert!((pdf.x - 15.0).abs() < 1e-5);
|
||||
assert!((pdf.y - 42.5).abs() < 1e-5);
|
||||
assert!((pdf.width - 40.0).abs() < 1e-5);
|
||||
assert!((pdf.height - 47.5).abs() < 1e-5);
|
||||
|
||||
let pixels = page.pdf_rect_to_pixel(&pdf);
|
||||
assert!(pixels.0 <= 10.0 && pixels.1 <= 20.0);
|
||||
assert!(pixels.0 + pixels.2 >= 40.0 && pixels.1 + pixels.3 >= 60.0);
|
||||
}
|
||||
}
|
||||
@@ -1,384 +0,0 @@
|
||||
//! 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,63 +0,0 @@
|
||||
#![cfg(all(feature = "render-pdfium", not(target_arch = "wasm32")))]
|
||||
|
||||
use pdf_inspector::vision::{PdfiumRenderer, RenderError, RenderOptions, RenderPixelFormat};
|
||||
|
||||
fn load_renderer() -> Option<PdfiumRenderer> {
|
||||
match PdfiumRenderer::load() {
|
||||
Ok(renderer) => Some(renderer),
|
||||
Err(RenderError::PdfiumLoad { .. }) => {
|
||||
eprintln!("skipping PDFium runtime test because no native library is installed");
|
||||
None
|
||||
}
|
||||
Err(error) => panic!("failed to load PDFium: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_owned_rgb_page_and_round_trips_coordinates() {
|
||||
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();
|
||||
|
||||
assert_eq!(pages.len(), 1);
|
||||
let page = &pages[0];
|
||||
assert_eq!(page.page(), 1);
|
||||
assert_eq!(page.format(), RenderPixelFormat::Rgb8);
|
||||
assert_eq!(page.stride(), page.width() as usize * 3);
|
||||
assert_eq!(page.pixels().len(), page.stride() * page.height() as usize);
|
||||
assert!((page.width() as f32 - page.page_width()).abs() > 1.0);
|
||||
|
||||
let pdf_rect = page.pixel_rect_to_pdf_rect(10.0, 10.0, 20.0, 12.0);
|
||||
let pixel_rect = page.pdf_rect_to_pixel(&pdf_rect);
|
||||
assert!((pixel_rect.0 - 10.0).abs() < 0.01);
|
||||
assert!((pixel_rect.1 - 10.0).abs() < 0.01);
|
||||
assert!((pixel_rect.2 - 20.0).abs() < 0.01);
|
||||
assert!((pixel_rect.3 - 12.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_and_out_of_range_page_numbers() {
|
||||
let Some(renderer) = load_renderer() else {
|
||||
return;
|
||||
};
|
||||
let bytes = std::fs::read("tests/fixtures/thermo-freon12.pdf").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
renderer.render_pages(&bytes, &[0], None, &RenderOptions::new()),
|
||||
Err(RenderError::InvalidPageNumber)
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
renderer.render_pages(&bytes, &[u32::MAX], None, &RenderOptions::new()),
|
||||
Err(RenderError::PageOutOfBounds { .. })
|
||||
));
|
||||
}
|
||||
@@ -1,214 +0,0 @@
|
||||
#![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));
|
||||
}
|
||||
@@ -46,9 +46,9 @@ more about investing in tax losses than burst, cap rates spreads steadily com- r
|
||||
|
||||
8 6 Z E L L / L U R I E R E A L E S T A T E C E N T E R
|
||||
|
||||
ingdebtspreadswerepartoforiginalpro formamodels.Thiscapratespreadcom- pressionoffsetweakcashflowsinapost- recessionary economy from 2002 to 2005, while continued compression, combined with improved cash flows, pushed property values skyward in 2006 throughmid-2007. Cap rate compression reduced the importance of the ability to add value. After all, if all you had to do to make moneywastoleveragetothehiltwhilecap ratesfell,whytakeontheextraworkand riskofattemptingtoaddvalue?Stateddif- ferently: Why print money if it is laying everywhereonthestreets? In Tables III and IV, we demonstrate thepowerofcapratecompressionviavery simple pro forma cash flow analyses that assume Year 1 NOI of $100; a going-in cap rate of 9 percent; an LTV of 70 percent; and an interest rate of 7 percent. Withineachfigure,wedisplaytwoscenar- ios, which vary based on NOI growth assumptions.ScenarioIassumesthatNOI growsby3percentperyear,whileScenario IIassumesavalue-addNOIgrowthof20 percentbetweenyearstwoandthree. The only other difference between TablesIIIandIVisinresidualcaprates, which are assumed to be 6 percent and 9 percent, respectively. Based on these assumptions, we calculate the equity IRRs. It is clear that cap rate compression is a significant factor in driving
|
||||
ingdebtspreadswerepartoforiginalpro formamodels.Thiscapratespreadcom- pressionoffsetweakcashflowsinapost- recessionary economy from 2002 to 2005, while continued compression, combined with improved cash flows, pushed property values skyward in 2006 throughmid-2007. Cap rate compression reduced the importance of the ability to add value. After all, if all you had to do to make moneywastoleveragetothehiltwhilecap ratesfell,whytakeontheextraworkand riskofattemptingtoaddvalue?Stateddif- ferently: Why print money if it is laying everywhereonthestreets? In Tables III and IV, we demonstrate thepowerofcapratecompressionviavery simple pro forma cash flow analyses that assume Year 1 NOI of $100; a going-in cap rate of 9 percent; an LTV of 70 per- cent; and an interest rate of 7 percent. Withineachfigure,wedisplaytwoscenar- ios, which vary based on NOI growth assumptions.ScenarioIassumesthatNOI growsby3percentperyear,whileScenario IIassumesavalue-addNOIgrowthof20 percentbetweenyearstwoandthree. The only other difference between TablesIIIandIVisinresidualcaprates, which are assumed to be 6 percent and 9 percent, respectively. Based on these assumptions, we calculate the equity IRRs. It is clear that cap rate compres- sion is a significant factor in driving
|
||||
|
||||
returns. That is, cap rate compression from 9 percent to 6 percent increased IRR on leveraged stabilized properties by 250 percent, to a staggering 57 percent. Who needs to take on value add riskatthisreturnforstabilizedassets? Intheearly1980s,moneywasmadein real estate by mastering the creation and syndication of tax gimmicks. In the late 1980s, one made money by mastering bank and S&L connections to over-leverage.Intheearly1990s,onemademoneyin realestatebyhavingaccesstoequity—the morethebetter.Duringthelate1990s,one made money from real estate by realizing large spreads between cap rates and debt costs.And,overthepastfiveyears,theway to make money in real estate was to own realestateonahighlyleveragedbasisascap ratesplunged. Theclassicassetpricingmodelisthe capital asset pricing model (CAPM). CAPM is a simple, yet elegant, model that relates asset pricing to the risk-free rate(F),theabilityofanassettoreduce portfolio variance (B), and the expected rate of return on the market bundle of investableassets(M).CAPMisfarfrom perfect,butprovidesacrudebenchmark for asset pricing, around which discrep- ancies and novelties arise. Specifically, CAPM states that an asset’s price is set suchthattheexpectedreturnforanasset
|
||||
returns. That is, cap rate compression from 9 percent to 6 percent increased IRR on leveraged stabilized properties by 250 percent, to a staggering 57 per- cent. Who needs to take on value add riskatthisreturnforstabilizedassets? Intheearly1980s,moneywasmadein real estate by mastering the creation and syndication of tax gimmicks. In the late 1980s, one made money by mastering bank and S&L connections to over-lever- age.Intheearly1990s,onemademoneyin realestatebyhavingaccesstoequity—the morethebetter.Duringthelate1990s,one made money from real estate by realizing large spreads between cap rates and debt costs.And,overthepastfiveyears,theway to make money in real estate was to own realestateonahighlyleveragedbasisascap ratesplunged. Theclassicassetpricingmodelisthe capital asset pricing model (CAPM). CAPM is a simple, yet elegant, model that relates asset pricing to the risk-free rate(F),theabilityofanassettoreduce portfolio variance (B), and the expected rate of return on the market bundle of investableassets(M).CAPMisfarfrom perfect,butprovidesacrudebenchmark for asset pricing, around which discrep- ancies and novelties arise. Specifically, CAPM states that an asset’s price is set suchthattheexpectedreturnforanasset
|
||||
|
||||
(R)is R=F+ β(M-F).
|
||||
R E V I E W 8 7
|
||||
|
||||
@@ -14,7 +14,7 @@ basis for such a theory is contained in the important papers of Nyquist¹ and Ha
|
||||
|
||||
1. It is practically more useful. Parameters of engineering importance such as time, bandwidth, number of relays, etc., tend to vary linearly with the logarithm of the number of possibilities. For example, adding one relay to a group doubles the number of possible states of the relays. It adds 1 to the base 2 logarithm of this number. Doubling the time roughly squares the number of possible messages, or doubles the logarithm, etc.
|
||||
2. It is nearer to our intuitive feeling as to the proper measure. This is closely related to (1) since we in- tuitively measures entities by linear comparison with common standards. One feels, for example, that two punched cards should have twice the capacity of one for information storage, and two identical channels twice the capacity of one for transmitting information.
|
||||
3. It is mathematically more suitable. Many of the limiting operations are simple in terms of the logarithm but would require clumsy restatement in terms of the number of possibilities. The choice of a logarithmic base corresponds to the choice of a unit for measuring information. If the
|
||||
3. It is mathematically more suitable. Many of the limiting operations are simple in terms of the loga- rithm but would require clumsy restatement in terms of the number of possibilities. The choice of a logarithmic base corresponds to the choice of a unit for measuring information. If the
|
||||
base 2 is used the resulting units may be called binary digits, or more briefly *bits,* a word suggested by
|
||||
|
||||
J. W. Tukey. A device with two stable positions, such as a relay or a flip-flop circuit, can store one bit of information. *N* such devices can store*N* bits, since the total number of possible states is 2
|
||||
@@ -34,8 +34,8 @@ Fig. 1 — Schematic diagram of a general communication system.
|
||||
|
||||
a decimal digit is about 3 13 bits. A digit wheel on a desk computing machine has ten stable positions and therefore has a storage capacity of one decimal digit. In analytical work where integration and differentiation are involved the base *e* is sometimes useful. The resulting units of information will be called natural units. Change from the base *a* to base *b* merely requires multiplication by log*ba*. By a communication system we will mean a system of the type indicated schematically in Fig. 1. It consists of essentially five parts:
|
||||
|
||||
1. An *information source* which produces a message or sequence of messages to be communicated to the receiving terminal. The message may be of various types: (a) A sequence of letters as in a telegraph of teletype system; (b) A single function of time *f* (*t*) as in radio or telephony; (c) A function of time and other variables as in black and white television — here the message may be thought of as a function *f* (*x*; *y*;*t*) of two space coordinates and time, the light intensity at point (*x*; *y*) and time *t* on a pickup tube plate; (d) Two or more functions of time, say *f* (*t*), *g*(*t*), *h*(*t*) — this is the case in “three-dimensional” sound transmission or if the system is intended to service several individual channels in multiplex; (e) Several functions of several variables — in color television the message consists of three functions *f* (*x*; *y*;*t*), *g*(*x*; *y*;*t*), *h*(*x*; *y*;*t*) defined in a three-dimensional continuum — we may also think of these three functions as components of a vector field defined in the region — similarly, several black and white television sources would produce “messages” consisting of a number of functions of three variables; (f) Various combinations also occur, for example in television with an associated audio channel.
|
||||
2. A *transmitter* which operates on the message in some way to produce a signal suitable for transmission over the channel. In telephony this operation consists merely of changing sound pressure into a proportional electrical current. In telegraphy we have an encoding operation which produces a sequence of dots, dashes and spaces on the channel corresponding to the message. In a multiplex PCM system the different speech functions must be sampled, compressed, quantized and encoded, and finally interleaved properly to construct the signal. Vocoder systems, television and frequency modulation are other examples of complex operations applied to the message to obtain the signal.
|
||||
1. An *information source* which produces a message or sequence of messages to be communicated to the receiving terminal. The message may be of various types: (a) A sequence of letters as in a telegraph of teletype system; (b) A single function of time *f* (*t*) as in radio or telephony; (c) A function of time and other variables as in black and white television — here the message may be thought of as a function *f* (*x*; *y*;*t*) of two space coordinates and time, the light intensity at point (*x*; *y*) and time *t* on a pickup tube plate; (d) Two or more functions of time, say *f* (*t*), *g*(*t*), *h*(*t*) — this is the case in “three- dimensional” sound transmission or if the system is intended to service several individual channels in multiplex; (e) Several functions of several variables — in color television the message consists of three functions *f* (*x*; *y*;*t*), *g*(*x*; *y*;*t*), *h*(*x*; *y*;*t*) defined in a three-dimensional continuum — we may also think of these three functions as components of a vector field defined in the region — similarly, several black and white television sources would produce “messages” consisting of a number of functions of three variables; (f) Various combinations also occur, for example in television with an associated audio channel.
|
||||
2. A *transmitter* which operates on the message in some way to produce a signal suitable for trans- mission over the channel. In telephony this operation consists merely of changing sound pressure into a proportional electrical current. In telegraphy we have an encoding operation which produces a sequence of dots, dashes and spaces on the channel corresponding to the message. In a multiplex PCM system the different speech functions must be sampled, compressed, quantized and encoded, and finally interleaved properly to construct the signal. Vocoder systems, television and frequency modulation are other examples of complex operations applied to the message to obtain the signal.
|
||||
3. The *channel* is merely the medium used to transmit the signal from transmitter to receiver. It may be a pair of wires, a coaxial cable, a band of radio frequencies, a beam of light, etc.
|
||||
4. The *receiver* ordinarily performs the inverse operation of that done by the transmitter, reconstructing the message from the signal.
|
||||
5. The *destination* is the person (or thing) for whom the message is intended. We wish to consider certain general problems involving communication systems. To do this it is first
|
||||
|
||||
Reference in New Issue
Block a user