Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21a436ac1b | ||
|
|
d2d8e35a7b | ||
|
|
30c9dbbc72 | ||
|
|
162c5cbf10 | ||
|
|
4e4cfdc74c | ||
|
|
24e66245cb | ||
|
|
cee7b6c381 | ||
|
|
b5a63d7036 | ||
|
|
6829397bce | ||
|
|
fc16133a58 | ||
|
|
41a6a67c03 | ||
|
|
58fe5a0224 | ||
|
|
c4c969a562 | ||
|
|
bf0cd8decb | ||
|
|
232b4cdef5 | ||
|
|
e3f5429638 | ||
|
|
f6cbe979f6 | ||
|
|
3f43745313 | ||
|
|
a012cb65a6 | ||
|
|
6567e1ab2d | ||
|
|
3af409d27f |
@@ -1,2 +0,0 @@
|
||||
*.pdf binary
|
||||
tests/snapshots/*.md text eol=lf
|
||||
+1
-165
@@ -9,9 +9,6 @@ on:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
@@ -71,12 +68,9 @@ jobs:
|
||||
with:
|
||||
key: clippy
|
||||
|
||||
- name: Run default clippy
|
||||
- name: Run clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
|
||||
- name: Run OCR clippy
|
||||
run: cargo clippy --features ocr -- -D warnings
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -99,164 +93,6 @@ jobs:
|
||||
- name: Build
|
||||
run: cargo build --release --verbose
|
||||
|
||||
ocr:
|
||||
name: OCR (${{ matrix.os }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
with:
|
||||
key: ocr-${{ matrix.os }}
|
||||
|
||||
- name: Test optional OCR feature
|
||||
run: cargo test --features ocr
|
||||
|
||||
ocr-runtime:
|
||||
name: OCR runtime smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
with:
|
||||
key: ocr-runtime
|
||||
workspaces: |
|
||||
. -> target
|
||||
napi -> target
|
||||
|
||||
- name: Install PDFium
|
||||
shell: bash
|
||||
run: |
|
||||
archive="$RUNNER_TEMP/firecrawl-pdfium-linux-x64.tgz"
|
||||
directory="$RUNNER_TEMP/firecrawl-pdfium"
|
||||
curl --fail --location --silent --show-error \
|
||||
https://github.com/firecrawl/pdfium-rs/releases/download/native-v7988/firecrawl-pdfium-linux-x64.tgz \
|
||||
--output "$archive"
|
||||
echo "6248189e07bbc33cdeb31976c539a88614307c8a19f3276dbd018efbe5b4a2a2 $archive" | sha256sum --check
|
||||
mkdir -p "$directory"
|
||||
tar -xzf "$archive" -C "$directory"
|
||||
pdfium_path="$(find "$directory" -type f -name 'libpdfium.so' -print -quit)"
|
||||
test -n "$pdfium_path"
|
||||
echo "PDFIUM_LIB_PATH=$pdfium_path" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install ONNX Runtime
|
||||
shell: bash
|
||||
run: |
|
||||
archive="$RUNNER_TEMP/onnxruntime-linux-x64-1.27.0.tgz"
|
||||
directory="$RUNNER_TEMP/onnxruntime"
|
||||
curl --fail --location --silent --show-error \
|
||||
https://github.com/microsoft/onnxruntime/releases/download/v1.27.0/onnxruntime-linux-x64-1.27.0.tgz \
|
||||
--output "$archive"
|
||||
echo "547e40a48f1fe73e3f812d7c88a948612c23f896b91e4e2ee1e232d7b468246f $archive" | sha256sum --check
|
||||
mkdir -p "$directory"
|
||||
tar -xzf "$archive" -C "$directory"
|
||||
ort_path="$(find "$directory" -type f -name 'libonnxruntime.so*' -print -quit)"
|
||||
test -n "$ort_path"
|
||||
echo "ORT_DYLIB_PATH=$ort_path" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Build OCR CLI
|
||||
run: cargo build --features ocr --bin pdf2md
|
||||
|
||||
- name: Test PDFium runtime
|
||||
run: cargo test --features ocr --test local_render_tests
|
||||
|
||||
- name: Run OCR CLI
|
||||
shell: bash
|
||||
run: |
|
||||
target/debug/pdf2md \
|
||||
tests/fixtures/scan_with_native_header_text.pdf \
|
||||
--ocr auto \
|
||||
--json > "$RUNNER_TEMP/ocr-result.json"
|
||||
|
||||
- name: Validate OCR JSON contract
|
||||
shell: bash
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
result = json.loads(
|
||||
(Path(os.environ["RUNNER_TEMP"]) / "ocr-result.json").read_text()
|
||||
)
|
||||
assert result["schema_version"] == 1
|
||||
assert result["pages_routed_to_ocr"] == [1]
|
||||
assert result["pages_recommending_hosted"] == []
|
||||
assert result["pages"][0]["source"] in {"ocr", "fused"}
|
||||
assert result["pages"][0]["markdown"].strip()
|
||||
assert "layout_ms" not in result["pages"][0]["timings"]
|
||||
PY
|
||||
|
||||
- name: Build Node binding
|
||||
working-directory: napi
|
||||
run: |
|
||||
bun install --frozen-lockfile
|
||||
bunx napi build --platform --release
|
||||
|
||||
- name: Run Node OCR binding
|
||||
shell: bash
|
||||
run: |
|
||||
node --input-type=module - <<'JS'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { processPdfWithOcr } from './napi/index.js'
|
||||
|
||||
const pdf = readFileSync('tests/fixtures/scan_with_native_header_text.pdf')
|
||||
const result = await processPdfWithOcr(pdf, { offline: true })
|
||||
if (JSON.stringify(result.pagesRoutedToOcr) !== '[1]') throw new Error('unexpected OCR route')
|
||||
if (result.pagesRecommendingHosted.length !== 0) throw new Error('unexpected hosted recommendation')
|
||||
if (!['Ocr', 'Fused'].includes(result.pages[0].provenance.source)) throw new Error('unexpected source')
|
||||
if (!result.pages[0].markdown.trim()) throw new Error('empty OCR markdown')
|
||||
JS
|
||||
|
||||
- name: Build and install Python binding
|
||||
shell: bash
|
||||
run: |
|
||||
python -m pip install 'maturin>=1,<2'
|
||||
maturin build --release --out "$RUNNER_TEMP/python-wheels"
|
||||
python -m pip install "$RUNNER_TEMP"/python-wheels/*.whl
|
||||
|
||||
- name: Run Python OCR binding
|
||||
shell: bash
|
||||
run: |
|
||||
python - <<'PY'
|
||||
import pdf_inspector
|
||||
|
||||
result = pdf_inspector.process_pdf_with_ocr(
|
||||
"tests/fixtures/scan_with_native_header_text.pdf",
|
||||
offline=True,
|
||||
)
|
||||
assert result.pages_routed_to_ocr == [1]
|
||||
assert result.pages_recommending_hosted == []
|
||||
assert result.pages[0].provenance.source in {"ocr", "fused"}
|
||||
assert result.pages[0].markdown.strip()
|
||||
PY
|
||||
|
||||
wasm:
|
||||
name: WebAssembly
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@ tempfile = "3.3"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
python = ["pyo3", "ocr"]
|
||||
python = ["pyo3"]
|
||||
vision = []
|
||||
model-cache = ["vision", "dep:dirs", "dep:fs2", "dep:sha2", "dep:windows-sys"]
|
||||
model-download = ["model-cache", "dep:ureq"]
|
||||
|
||||
@@ -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.
|
||||
- **Selective OCR** — Rust, CLI, Python, and Node can render only pages that need OCR, run PP-OCRv6 Small locally, and preserve 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 Rust and browser builds remain pure extraction. Native Python and Node packages include the OCR integration, but PDFium, ONNX Runtime, and model files remain external and are touched only when a page is routed to OCR.
|
||||
- **Lightweight** — Pure Rust, no ML models, no external services. Single dependency on `lopdf` for PDF parsing.
|
||||
|
||||
## Benchmark
|
||||
|
||||
@@ -58,10 +57,6 @@ import pdf_inspector
|
||||
result = pdf_inspector.process_pdf("document.pdf")
|
||||
print(result.pdf_type) # "text_based", "scanned", "image_based", "mixed"
|
||||
print(result.markdown) # Markdown string or None
|
||||
|
||||
# Selective OCR; clean text PDFs do not load the external OCR runtime.
|
||||
ocr = pdf_inspector.process_pdf_with_ocr("document.pdf")
|
||||
print(ocr.pages_routed_to_ocr)
|
||||
```
|
||||
|
||||
> Full API reference: [docs/python.md](docs/python.md)
|
||||
@@ -74,15 +69,11 @@ npm install @firecrawl/pdf-inspector
|
||||
|
||||
```javascript
|
||||
import { readFileSync } from 'fs';
|
||||
import { processPdf, processPdfWithOcr } from '@firecrawl/pdf-inspector';
|
||||
import { processPdf, classifyPdf } from '@firecrawl/pdf-inspector';
|
||||
|
||||
const pdf = readFileSync('document.pdf');
|
||||
const result = processPdf(pdf);
|
||||
const result = processPdf(readFileSync('document.pdf'));
|
||||
console.log(result.pdfType); // "TextBased", "Scanned", "ImageBased", "Mixed"
|
||||
console.log(result.markdown); // Markdown string or null
|
||||
|
||||
const ocr = await processPdfWithOcr(pdf); // selective OCR, off the event loop
|
||||
console.log(ocr.pagesRoutedToOcr);
|
||||
```
|
||||
|
||||
> Full API reference: [napi/README.md](napi/README.md)
|
||||
@@ -169,22 +160,6 @@ detect-pdf document.pdf --json
|
||||
detect-pdf document.pdf --analyze --json
|
||||
```
|
||||
|
||||
Rust and CLI consumers opt into OCR at build time:
|
||||
|
||||
```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. Native Python and Node packages expose the same pipeline without a
|
||||
source-build feature. All native entry points still require separately
|
||||
installed PDFium and ONNX Runtime libraries only when OCR is routed. 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
|
||||
|
||||
+2
-63
@@ -1,6 +1,6 @@
|
||||
# pdf-inspector
|
||||
|
||||
Fast PDF classification, text extraction, and selective OCR. Detects whether a PDF is text-based or scanned, extracts text with position awareness, and converts clean native and OCR results to Markdown. Python bindings via [PyO3](https://pyo3.rs) for the [pdf-inspector](https://github.com/firecrawl/pdf-inspector) Rust library.
|
||||
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. Python bindings via [PyO3](https://pyo3.rs) for the [pdf-inspector](https://github.com/firecrawl/pdf-inspector) Rust library.
|
||||
|
||||
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.
|
||||
|
||||
@@ -10,8 +10,7 @@ Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in
|
||||
- **Markdown conversion** — headings, lists, code blocks, bold/italic, URL linking, and dual-mode table detection (PDF drawing ops + text-alignment heuristics).
|
||||
- **Layout-aware extraction** — multi-column reading order, position and font info per text item, RTL support.
|
||||
- **Robust text decoding** — CID/Type0 fonts via ToUnicode CMaps, plus automatic flagging of broken encodings so callers can fall back to OCR.
|
||||
- **Selective OCR** — `auto` routes only pages rejected by native extraction; `force` OCRs every selected page; `off` keeps the result/provenance contract without external runtime work.
|
||||
- **External artifacts** — the wheel embeds no OCR models, PDFium, or ONNX Runtime; clean `auto` requests never load or download them.
|
||||
- **Lightweight** — native Rust core, no ML models, no external services; ships type stubs.
|
||||
|
||||
## Benchmark
|
||||
|
||||
@@ -40,12 +39,6 @@ pip install maturin
|
||||
maturin develop --release
|
||||
```
|
||||
|
||||
OCR calls that route work require compatible PDFium and ONNX Runtime shared
|
||||
libraries. Set `PDFIUM_LIB_PATH` and `ORT_DYLIB_PATH` when they are not on the
|
||||
platform library search path. The pinned OCR model set is downloaded and
|
||||
checksum-verified on the first routed page; use `offline=True` with a warm
|
||||
cache or `model_directory` to prohibit network access.
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
@@ -88,19 +81,6 @@ for page in result.pages:
|
||||
# Restrict to specific 0-indexed pages (preserves caller order)
|
||||
result = pdf_inspector.extract_pages_markdown("document.pdf", pages=[0, 2])
|
||||
|
||||
# One-call selective OCR. This releases the GIL while processing.
|
||||
ocr = pdf_inspector.process_pdf_with_ocr("document.pdf")
|
||||
for page in ocr.pages:
|
||||
print(page.page_number, page.provenance.source)
|
||||
|
||||
# Restrict OCR processing to 1-indexed PDF pages and prohibit downloads.
|
||||
ocr = pdf_inspector.process_pdf_with_ocr(
|
||||
"document.pdf",
|
||||
page_numbers=[1, 3],
|
||||
model_directory="/opt/models/pp-ocrv6-small",
|
||||
offline=True,
|
||||
)
|
||||
|
||||
# Structure-tree elements from tagged PDFs (empty list when untagged).
|
||||
# Pages are 1-indexed to match TextItem.page, so (page, mcid) joins directly
|
||||
# against extract_text_with_positions — e.g. to recover real heading levels:
|
||||
@@ -119,8 +99,6 @@ headings = [
|
||||
|---|---|
|
||||
| `process_pdf(path, pages=None)` | Full processing (detect + extract + markdown) |
|
||||
| `process_pdf_bytes(data, pages=None)` | Full processing from bytes |
|
||||
| `process_pdf_with_ocr(path, **options)` | Native extraction + selective OCR with provenance |
|
||||
| `process_pdf_with_ocr_bytes(data, **options)` | Native extraction + selective OCR from bytes |
|
||||
| `detect_pdf(path)` | Fast detection only (returns PdfResult) |
|
||||
| `detect_pdf_bytes(data)` | Fast detection from bytes |
|
||||
| `classify_pdf(path)` | Lightweight classification (returns PdfClassification) |
|
||||
@@ -159,45 +137,6 @@ class PageOcrReasons: # per-page OCR diagnostics
|
||||
page: int # 1-indexed
|
||||
reasons: list[str] # machine-readable reason identifiers
|
||||
|
||||
class OcrModelIdentity:
|
||||
name: str # model family/name
|
||||
revision: str # immutable artifact-set revision
|
||||
|
||||
class OcrTimings: # per-page processing stages
|
||||
render_ms: int
|
||||
ocr_ms: int
|
||||
assembly_ms: int
|
||||
|
||||
class OcrPageProvenance:
|
||||
page_number: int # 1-indexed
|
||||
source: Literal["native", "ocr", "fused"]
|
||||
ocr_model: OcrModelIdentity | None
|
||||
render_dpi: float | None
|
||||
ocr_confidence: float | None
|
||||
timings: OcrTimings
|
||||
warnings: list[str]
|
||||
hosted_recommended: bool
|
||||
|
||||
class OcrPageResult:
|
||||
page_number: int # 1-indexed
|
||||
markdown: str
|
||||
provenance: OcrPageProvenance
|
||||
|
||||
class OcrPdfResult: # process_pdf_with_ocr / bytes
|
||||
markdown: str
|
||||
pages: list[OcrPageResult]
|
||||
page_count: int
|
||||
pages_recommended_for_ocr: list[int]
|
||||
pages_routed_to_ocr: list[int]
|
||||
pages_recommending_hosted: list[int]
|
||||
ocr_reasons_by_page: list[PageOcrReasons]
|
||||
pages_with_tables: list[int]
|
||||
pages_with_columns: list[int]
|
||||
is_complex: bool
|
||||
processing_time_ms: int
|
||||
render_time_ms: int
|
||||
ocr_time_ms: int
|
||||
|
||||
class PdfClassification: # classify_pdf
|
||||
pdf_type: str
|
||||
page_count: int
|
||||
|
||||
+23
-61
@@ -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. The default build is pure Rust, has no ML models or external services, and uses [lopdf](https://crates.io/crates/lopdf) for PDF parsing. Also available for [Python](https://pypi.org/project/pdf-inspector/) and [Node.js](https://www.npmjs.com/package/@firecrawl/pdf-inspector/).
|
||||
|
||||
Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in under 200ms, skipping expensive OCR services for the ~54% of PDFs that don't need them.
|
||||
|
||||
@@ -123,10 +123,10 @@ The native-only `vision` feature exposes the stable seam used by OCR
|
||||
integrations without selecting or embedding an inference runtime. The
|
||||
separate `model-cache` feature adds pinned artifact management:
|
||||
|
||||
- `PageRenderer` and `OcrEngine` traits;
|
||||
- `PageRenderer`, `OcrEngine`, and `LayoutEngine` 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
|
||||
- positioned OCR/layout results and per-page provenance types; and
|
||||
- a versioned PP-OCRv6 Small manifest with checksum-verified, locked, atomic
|
||||
model-cache installation and explicit offline-directory overrides.
|
||||
|
||||
@@ -135,9 +135,9 @@ separate `model-cache` feature adds pinned artifact management:
|
||||
pdf-inspector = { version = "1", features = ["vision", "model-cache"] }
|
||||
```
|
||||
|
||||
The OCR contracts preserve existing behavior by default: OCR is `Off` and
|
||||
model resolution is never reached. `ModelStore` itself does not access the
|
||||
network. The optional `model-download` feature provides an
|
||||
The OCR contracts preserve existing behavior by default: OCR is `Off`, learned
|
||||
layout is disabled, and model resolution is never reached. `ModelStore` itself
|
||||
does not access the network. 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
|
||||
@@ -171,10 +171,9 @@ and `PdfiumRenderer` implements the renderer-neutral `PageRenderer` trait.
|
||||
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.
|
||||
PDFium is loaded at runtime. Set `PDFIUM_LIB_PATH`, place its shared library
|
||||
next to the executable, or use another discovery route supported by
|
||||
`firecrawl-pdfium`.
|
||||
|
||||
```rust
|
||||
use pdf_inspector::vision::{PdfiumRenderer, RenderOptions};
|
||||
@@ -206,11 +205,9 @@ The native-only `ocr-oar` feature adds a CPU PP-OCRv6 Small implementation of
|
||||
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.
|
||||
ONNX Runtime shared library separately and set `ORT_DYLIB_PATH` when it is not
|
||||
available through the platform library search path. The feature currently
|
||||
requires Rust 1.95 or newer, matching OAR 0.9.1's MSRV.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
@@ -332,10 +329,7 @@ let fused = fuse_ocr_pages(
|
||||
for page in &fused.pages {
|
||||
println!("{}", page.markdown);
|
||||
if page.provenance.hosted_recommended {
|
||||
eprintln!(
|
||||
"page {} needs the hosted document pipeline",
|
||||
page.page_number,
|
||||
);
|
||||
eprintln!("page {} needs the hosted document pipeline", page.page + 1);
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -360,11 +354,15 @@ pdf-inspector = { version = "1", features = ["ocr"] }
|
||||
```
|
||||
|
||||
```rust
|
||||
use pdf_inspector::vision::{process_pdf_with_ocr, OcrPdfOptions};
|
||||
use pdf_inspector::vision::{
|
||||
process_pdf_with_ocr, OcrMode, OcrPdfOptions,
|
||||
};
|
||||
|
||||
let result = process_pdf_with_ocr(
|
||||
"document.pdf",
|
||||
OcrPdfOptions::auto().page_numbers([1, 2, 3]),
|
||||
OcrPdfOptions::new()
|
||||
.mode(OcrMode::Auto)
|
||||
.pages([1, 2, 3]),
|
||||
)?;
|
||||
|
||||
println!("{}", result.markdown);
|
||||
@@ -379,47 +377,12 @@ 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.
|
||||
shape; `Force` renders every selected page. Learned layout intentionally
|
||||
returns an explicit unsupported error in this lightweight pipeline.
|
||||
|
||||
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
|
||||
@@ -428,11 +391,10 @@ pdf2md document.pdf --ocr auto --ocr-offline --ocr-model-dir /opt/models/pp-ocrv
|
||||
|
||||
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
|
||||
`--password` option. JSON output 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`.
|
||||
are 1-indexed, matching the PDF page numbers accepted by `OcrPdfOptions::pages`.
|
||||
|
||||
Extract per-page Markdown (one string per page, plus document-wide layout
|
||||
metadata):
|
||||
|
||||
Generated
+30
-2253
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -7,7 +7,7 @@ edition = "2021"
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
pdf-inspector = { path = "..", features = ["ocr"] }
|
||||
pdf-inspector = { path = ".." }
|
||||
napi = { version = "3.0.0", features = ["serde-json"] }
|
||||
napi-derive = "3.0.0"
|
||||
|
||||
|
||||
+1
-50
@@ -10,8 +10,7 @@ Built by [Firecrawl](https://firecrawl.dev) for hybrid OCR pipelines — extract
|
||||
- **Region-based extraction** — pull text from bounding boxes with per-region quality checks (`needsOcr`).
|
||||
- **Layout-aware** — multi-column reading order, position and font info per text item, RTL support.
|
||||
- **Robust text decoding** — CID/Type0 fonts via ToUnicode CMaps, plus automatic flagging of broken encodings so callers can fall back to OCR.
|
||||
- **Selective OCR** — `Auto` routes only pages rejected by native extraction and returns source/model provenance plus hosted-fallback recommendations.
|
||||
- **External artifacts** — the native package embeds no OCR models, PDFium, or ONNX Runtime; clean `Auto` requests never load or download them.
|
||||
- **Lightweight** — native Rust core via napi-rs, no ML models, no external services; ~5–6 MB platform binary, TypeScript definitions included.
|
||||
|
||||
## Benchmark
|
||||
|
||||
@@ -37,40 +36,8 @@ bun add @firecrawl/pdf-inspector
|
||||
|
||||
Prebuilt binaries for **Linux x64/ARM64** (glibc and musl/Alpine), **macOS ARM64**, and **Windows x64** — npm installs only the one matching your platform. No Rust toolchain needed.
|
||||
|
||||
OCR calls that route work require compatible PDFium and ONNX Runtime shared
|
||||
libraries. Set `PDFIUM_LIB_PATH` and `ORT_DYLIB_PATH` when they are not on the
|
||||
platform library search path. The pinned OCR model set is downloaded and
|
||||
checksum-verified on the first routed page; use `offline: true` with a warm
|
||||
cache or `modelDirectory` to prohibit network access.
|
||||
|
||||
## API
|
||||
|
||||
### `processPdfWithOcr(buffer: Buffer, options?: OcrOptions): Promise<OcrPdfResult>`
|
||||
|
||||
Run native extraction first and OCR only the pages selected by its quality
|
||||
signals. The default mode is `Auto`; `Off` returns the same detailed result
|
||||
shape without external runtime work, and `Force` OCRs every selected page.
|
||||
The work runs on the libuv thread pool and never blocks Node's event loop.
|
||||
|
||||
```typescript
|
||||
import { OcrMode, processPdfWithOcr } from '@firecrawl/pdf-inspector'
|
||||
|
||||
const result = await processPdfWithOcr(pdf, {
|
||||
mode: OcrMode.Auto,
|
||||
pageNumbers: [1, 3], // 1-indexed
|
||||
})
|
||||
|
||||
for (const page of result.pages) {
|
||||
console.log(page.pageNumber, page.provenance.source)
|
||||
}
|
||||
console.log(result.pagesRoutedToOcr)
|
||||
console.log(result.pagesRecommendingHosted)
|
||||
```
|
||||
|
||||
For offline deployments, pass `modelDirectory` and `offline: true`. Other
|
||||
controls include `dpi`, `minimumConfidence`,
|
||||
`hostedRecommendationConfidence`, and `password`.
|
||||
|
||||
### `classifyPdf(buffer: Buffer): PdfClassification`
|
||||
|
||||
Classify a PDF as TextBased, Scanned, Mixed, or ImageBased (~10-50ms). Returns which pages need OCR.
|
||||
@@ -157,22 +124,6 @@ interface RegionText {
|
||||
needsOcr: boolean // true when text is unreliable
|
||||
ocrReason?: string // "suspected_garbled_text" when known
|
||||
}
|
||||
|
||||
interface OcrPdfResult {
|
||||
markdown: string
|
||||
pages: OcrPageResult[] // 1-indexed pages + provenance
|
||||
pageCount: number
|
||||
pagesRecommendedForOcr: number[]
|
||||
pagesRoutedToOcr: number[]
|
||||
pagesRecommendingHosted: number[]
|
||||
ocrReasonsByPage: PageOcrReasons[]
|
||||
pagesWithTables: number[]
|
||||
pagesWithColumns: number[]
|
||||
isComplex: boolean
|
||||
processingTimeMs: number
|
||||
renderTimeMs: number
|
||||
ocrTimeMs: number
|
||||
}
|
||||
```
|
||||
|
||||
## Platforms
|
||||
|
||||
-241
@@ -27,26 +27,6 @@ pub enum ItemType {
|
||||
FormField,
|
||||
}
|
||||
|
||||
/// Selects when OCR runs.
|
||||
#[napi(string_enum)]
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum OcrMode {
|
||||
/// Never run OCR; return the native extraction in the OCR result shape.
|
||||
Off,
|
||||
/// Run OCR only on pages selected by the native quality signals.
|
||||
Auto,
|
||||
/// Run OCR on every selected page.
|
||||
Force,
|
||||
}
|
||||
|
||||
/// How final page content was sourced.
|
||||
#[napi(string_enum)]
|
||||
pub enum PageContentSource {
|
||||
Native,
|
||||
Ocr,
|
||||
Fused,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -148,84 +128,6 @@ pub struct VectorGridDetectionJs {
|
||||
pub cell_bboxes: Vec<Vec<f64>>,
|
||||
}
|
||||
|
||||
/// Options for one-call native extraction with selective OCR.
|
||||
#[napi(object)]
|
||||
#[derive(Clone)]
|
||||
pub struct OcrOptions {
|
||||
/// OCR routing behavior. Defaults to Auto.
|
||||
pub mode: Option<OcrMode>,
|
||||
/// Optional 1-indexed page selection.
|
||||
pub page_numbers: Option<Vec<u32>>,
|
||||
/// Password for an encrypted PDF.
|
||||
pub password: Option<String>,
|
||||
/// Page rasterization resolution. Defaults to 150 DPI.
|
||||
pub dpi: Option<f64>,
|
||||
/// Drop OCR spans below this inclusive 0-1 threshold.
|
||||
pub minimum_confidence: Option<f64>,
|
||||
/// Recommend hosted parsing below this inclusive 0-1 page confidence.
|
||||
pub hosted_recommendation_confidence: Option<f64>,
|
||||
/// Directory containing an offline OCR model set.
|
||||
pub model_directory: Option<String>,
|
||||
/// Disable model downloads and require a model directory or warm cache.
|
||||
pub offline: Option<bool>,
|
||||
}
|
||||
|
||||
/// Exact OCR model identity retained in page provenance.
|
||||
#[napi(object)]
|
||||
pub struct OcrModelIdentity {
|
||||
pub name: String,
|
||||
pub revision: String,
|
||||
}
|
||||
|
||||
/// Per-page OCR processing timings.
|
||||
#[napi(object)]
|
||||
pub struct OcrTimings {
|
||||
pub render_ms: u32,
|
||||
pub ocr_ms: u32,
|
||||
pub assembly_ms: u32,
|
||||
}
|
||||
|
||||
/// Source, model, confidence, and fallback metadata for one page.
|
||||
#[napi(object)]
|
||||
pub struct OcrPageProvenance {
|
||||
/// 1-indexed page number.
|
||||
pub page_number: u32,
|
||||
pub source: PageContentSource,
|
||||
pub ocr_model: Option<OcrModelIdentity>,
|
||||
pub render_dpi: Option<f64>,
|
||||
pub ocr_confidence: Option<f64>,
|
||||
pub timings: OcrTimings,
|
||||
pub warnings: Vec<String>,
|
||||
pub hosted_recommended: bool,
|
||||
}
|
||||
|
||||
/// Final Markdown and provenance for one page.
|
||||
#[napi(object)]
|
||||
pub struct OcrPageResult {
|
||||
/// 1-indexed page number.
|
||||
pub page_number: u32,
|
||||
pub markdown: String,
|
||||
pub provenance: OcrPageProvenance,
|
||||
}
|
||||
|
||||
/// Complete native/OCR Markdown output.
|
||||
#[napi(object)]
|
||||
pub struct OcrPdfResult {
|
||||
pub markdown: String,
|
||||
pub pages: Vec<OcrPageResult>,
|
||||
pub page_count: u32,
|
||||
pub pages_recommended_for_ocr: Vec<u32>,
|
||||
pub pages_routed_to_ocr: Vec<u32>,
|
||||
pub pages_recommending_hosted: Vec<u32>,
|
||||
pub ocr_reasons_by_page: Vec<PageOcrReasons>,
|
||||
pub pages_with_tables: Vec<u32>,
|
||||
pub pages_with_columns: Vec<u32>,
|
||||
pub is_complex: bool,
|
||||
pub processing_time_ms: u32,
|
||||
pub render_time_ms: u32,
|
||||
pub ocr_time_ms: u32,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -266,103 +168,6 @@ fn to_napi_page_ocr_reasons(reasons: Vec<pdf_inspector::PageOcrReasons>) -> Vec<
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn to_core_ocr_options(options: Option<OcrOptions>) -> pdf_inspector::vision::OcrPdfOptions {
|
||||
let mut result = pdf_inspector::vision::OcrPdfOptions::auto();
|
||||
let Some(options) = options else {
|
||||
return result;
|
||||
};
|
||||
|
||||
if let Some(mode) = options.mode {
|
||||
result.ocr.mode = match mode {
|
||||
OcrMode::Off => pdf_inspector::vision::OcrMode::Off,
|
||||
OcrMode::Auto => pdf_inspector::vision::OcrMode::Auto,
|
||||
OcrMode::Force => pdf_inspector::vision::OcrMode::Force,
|
||||
};
|
||||
}
|
||||
if let Some(pages) = options.page_numbers {
|
||||
result = result.page_numbers(pages);
|
||||
}
|
||||
if let Some(password) = options.password {
|
||||
result = result.password(password);
|
||||
}
|
||||
if let Some(dpi) = options.dpi {
|
||||
result.render.dpi = dpi as f32;
|
||||
}
|
||||
if let Some(minimum_confidence) = options.minimum_confidence {
|
||||
result.ocr.minimum_confidence = minimum_confidence as f32;
|
||||
}
|
||||
if let Some(confidence) = options.hosted_recommendation_confidence {
|
||||
result.hosted_recommendation_confidence = confidence as f32;
|
||||
}
|
||||
if let Some(directory) = options.model_directory {
|
||||
result.ocr.model_directory = Some(directory.into());
|
||||
}
|
||||
if options.offline.unwrap_or(false) {
|
||||
result.ocr.model_downloads = pdf_inspector::vision::ModelDownloadPolicy::Offline;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn convert_page_content_source(
|
||||
source: pdf_inspector::vision::PageContentSource,
|
||||
) -> PageContentSource {
|
||||
match source {
|
||||
pdf_inspector::vision::PageContentSource::Native => PageContentSource::Native,
|
||||
pdf_inspector::vision::PageContentSource::Ocr => PageContentSource::Ocr,
|
||||
pdf_inspector::vision::PageContentSource::Fused => PageContentSource::Fused,
|
||||
_ => PageContentSource::Native,
|
||||
}
|
||||
}
|
||||
|
||||
fn timing_ms(value: u64) -> u32 {
|
||||
u32::try_from(value).unwrap_or(u32::MAX)
|
||||
}
|
||||
|
||||
fn to_napi_ocr_result(result: pdf_inspector::vision::OcrPdfResult) -> OcrPdfResult {
|
||||
OcrPdfResult {
|
||||
markdown: result.markdown,
|
||||
pages: result
|
||||
.pages
|
||||
.into_iter()
|
||||
.map(|page| {
|
||||
let provenance = page.provenance;
|
||||
OcrPageResult {
|
||||
page_number: page.page_number,
|
||||
markdown: page.markdown,
|
||||
provenance: OcrPageProvenance {
|
||||
page_number: provenance.page_number,
|
||||
source: convert_page_content_source(provenance.source),
|
||||
ocr_model: provenance.ocr_model.map(|model| OcrModelIdentity {
|
||||
name: model.name,
|
||||
revision: model.revision,
|
||||
}),
|
||||
render_dpi: provenance.render_dpi.map(f64::from),
|
||||
ocr_confidence: provenance.ocr_confidence.map(f64::from),
|
||||
timings: OcrTimings {
|
||||
render_ms: timing_ms(provenance.timings.render_ms),
|
||||
ocr_ms: timing_ms(provenance.timings.ocr_ms),
|
||||
assembly_ms: timing_ms(provenance.timings.assembly_ms),
|
||||
},
|
||||
warnings: provenance.warnings,
|
||||
hosted_recommended: provenance.hosted_recommended,
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
page_count: result.page_count,
|
||||
pages_recommended_for_ocr: result.pages_recommended_for_ocr,
|
||||
pages_routed_to_ocr: result.pages_routed_to_ocr,
|
||||
pages_recommending_hosted: result.pages_recommending_hosted,
|
||||
ocr_reasons_by_page: to_napi_page_ocr_reasons(result.ocr_reasons_by_page),
|
||||
pages_with_tables: result.pages_with_tables,
|
||||
pages_with_columns: result.pages_with_columns,
|
||||
is_complex: result.is_complex,
|
||||
processing_time_ms: timing_ms(result.processing_time_ms),
|
||||
render_time_ms: timing_ms(result.render_time_ms),
|
||||
ocr_time_ms: timing_ms(result.ocr_time_ms),
|
||||
}
|
||||
}
|
||||
|
||||
fn convert_item_type(t: &pdf_inspector::types::ItemType) -> (ItemType, Option<String>) {
|
||||
match t {
|
||||
pdf_inspector::types::ItemType::Text => (ItemType::Text, None),
|
||||
@@ -414,13 +219,6 @@ fn process_pdf_impl(bytes: &[u8], pages: Option<Vec<u32>>) -> Result<PdfResult>
|
||||
Ok(to_napi_result(result))
|
||||
}
|
||||
|
||||
fn process_pdf_with_ocr_impl(bytes: &[u8], options: Option<OcrOptions>) -> Result<OcrPdfResult> {
|
||||
let options = to_core_ocr_options(options);
|
||||
let result = pdf_inspector::vision::process_pdf_with_ocr_mem(bytes, options)
|
||||
.map_err(|error| to_napi_err(error, "process_pdf_with_ocr"))?;
|
||||
Ok(to_napi_ocr_result(result))
|
||||
}
|
||||
|
||||
fn classify_pdf_impl(bytes: &[u8]) -> Result<PdfClassification> {
|
||||
let result =
|
||||
pdf_inspector::classify_pdf_mem(bytes).map_err(|e| to_napi_err(e, "classify_pdf"))?;
|
||||
@@ -1020,45 +818,6 @@ pub fn process_pdf_async(buffer: Buffer, pages: Option<Vec<u32>>) -> AsyncTask<P
|
||||
})
|
||||
}
|
||||
|
||||
pub struct ProcessPdfWithOcrTask {
|
||||
bytes: Vec<u8>,
|
||||
options: Option<OcrOptions>,
|
||||
}
|
||||
|
||||
impl Task for ProcessPdfWithOcrTask {
|
||||
type Output = OcrPdfResult;
|
||||
type JsValue = OcrPdfResult;
|
||||
|
||||
fn compute(&mut self) -> Result<Self::Output> {
|
||||
let bytes = std::mem::take(&mut self.bytes);
|
||||
let options = self.options.take();
|
||||
catch_panic(
|
||||
"process_pdf_with_ocr",
|
||||
panic::AssertUnwindSafe(move || process_pdf_with_ocr_impl(&bytes, options)),
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve(&mut self, _env: Env, output: Self::Output) -> Result<Self::JsValue> {
|
||||
Ok(output)
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a PDF with selective OCR on the libuv thread pool.
|
||||
///
|
||||
/// OCR defaults to Auto, which only loads PDFium, ONNX Runtime, and the OCR
|
||||
/// model if native extraction routes at least one page. The input buffer is
|
||||
/// copied before the promise is returned and is safe to reuse immediately.
|
||||
#[napi(ts_return_type = "Promise<OcrPdfResult>")]
|
||||
pub fn process_pdf_with_ocr(
|
||||
buffer: Buffer,
|
||||
options: Option<OcrOptions>,
|
||||
) -> AsyncTask<ProcessPdfWithOcrTask> {
|
||||
AsyncTask::new(ProcessPdfWithOcrTask {
|
||||
bytes: buffer.to_vec(),
|
||||
options,
|
||||
})
|
||||
}
|
||||
|
||||
pub struct ClassifyPdfTask {
|
||||
bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { strict as assert } from 'assert';
|
||||
import {
|
||||
processPdf,
|
||||
processPdfAsync,
|
||||
processPdfWithOcr,
|
||||
detectPdf,
|
||||
classifyPdf,
|
||||
classifyPdfAsync,
|
||||
@@ -221,37 +220,6 @@ const fromMutated = await inFlight;
|
||||
assert.equal(fromMutated.markdown, result.markdown);
|
||||
console.log(' processPdfAsync input copied at call time: OK');
|
||||
|
||||
// --- Selective OCR ---
|
||||
console.log('Testing processPdfWithOcr...');
|
||||
|
||||
// Off exercises the complete result/provenance contract without loading
|
||||
// external PDFium, ONNX Runtime, or model artifacts.
|
||||
const ocrOff = await processPdfWithOcr(fixture, { mode: 'Off' });
|
||||
assert.equal(ocrOff.pageCount, 3);
|
||||
assert.equal(ocrOff.pages.length, 3);
|
||||
assert.deepEqual(ocrOff.pagesRoutedToOcr, []);
|
||||
assert.ok(ocrOff.pages.every(page => page.provenance.source === 'Native'));
|
||||
assert.ok(ocrOff.pages.every(page => page.provenance.ocrModel === undefined));
|
||||
assert.ok(ocrOff.markdown.length > 0);
|
||||
|
||||
// Auto must preserve the lightweight path for clean text PDFs.
|
||||
const ocrAuto = await processPdfWithOcr(fixture);
|
||||
assert.deepEqual(ocrAuto.pagesRoutedToOcr, []);
|
||||
assert.equal(ocrAuto.renderTimeMs, 0);
|
||||
assert.equal(ocrAuto.ocrTimeMs, 0);
|
||||
|
||||
const ocrSelected = await processPdfWithOcr(fixture, {
|
||||
mode: 'Off',
|
||||
pageNumbers: [2],
|
||||
});
|
||||
assert.deepEqual(ocrSelected.pages.map(page => page.pageNumber), [2]);
|
||||
|
||||
await assert.rejects(
|
||||
processPdfWithOcr(fixture, { mode: 'Off', pageNumbers: [0] }),
|
||||
/page 0/,
|
||||
);
|
||||
console.log(' processPdfWithOcr: OK');
|
||||
|
||||
// concurrent async calls all settle
|
||||
const [c1, c2, c3] = await Promise.all([
|
||||
processPdfAsync(fixture),
|
||||
|
||||
+1
-81
@@ -1,6 +1,6 @@
|
||||
"""Type stubs for pdf_inspector."""
|
||||
|
||||
from typing import Literal, Optional
|
||||
from typing import Optional
|
||||
|
||||
class PdfResult:
|
||||
"""Result of processing a PDF file."""
|
||||
@@ -27,53 +27,6 @@ class PageOcrReasons:
|
||||
reasons: list[str]
|
||||
"""Machine-readable OCR reason identifiers."""
|
||||
|
||||
class OcrModelIdentity:
|
||||
"""Exact OCR model identity retained in page provenance."""
|
||||
name: str
|
||||
revision: str
|
||||
|
||||
class OcrTimings:
|
||||
"""Per-page OCR processing timings."""
|
||||
render_ms: int
|
||||
ocr_ms: int
|
||||
assembly_ms: int
|
||||
|
||||
class OcrPageProvenance:
|
||||
"""Source, model, confidence, and fallback metadata for one page."""
|
||||
page_number: int
|
||||
"""1-indexed page number."""
|
||||
source: Literal["native", "ocr", "fused"]
|
||||
"""'native', 'ocr', or 'fused'."""
|
||||
ocr_model: Optional[OcrModelIdentity]
|
||||
render_dpi: Optional[float]
|
||||
ocr_confidence: Optional[float]
|
||||
timings: OcrTimings
|
||||
warnings: list[str]
|
||||
hosted_recommended: bool
|
||||
|
||||
class OcrPageResult:
|
||||
"""Final Markdown and provenance for one page."""
|
||||
page_number: int
|
||||
"""1-indexed page number."""
|
||||
markdown: str
|
||||
provenance: OcrPageProvenance
|
||||
|
||||
class OcrPdfResult:
|
||||
"""Complete native/OCR Markdown output."""
|
||||
markdown: str
|
||||
pages: list[OcrPageResult]
|
||||
page_count: int
|
||||
pages_recommended_for_ocr: list[int]
|
||||
pages_routed_to_ocr: list[int]
|
||||
pages_recommending_hosted: list[int]
|
||||
ocr_reasons_by_page: list[PageOcrReasons]
|
||||
pages_with_tables: list[int]
|
||||
pages_with_columns: list[int]
|
||||
is_complex: bool
|
||||
processing_time_ms: int
|
||||
render_time_ms: int
|
||||
ocr_time_ms: int
|
||||
|
||||
class PdfClassification:
|
||||
"""Lightweight PDF classification result."""
|
||||
pdf_type: str
|
||||
@@ -161,39 +114,6 @@ def process_pdf_bytes(data: bytes, pages: Optional[list[int]] = None) -> PdfResu
|
||||
"""Process a PDF from bytes in memory."""
|
||||
...
|
||||
|
||||
def process_pdf_with_ocr(
|
||||
path: str,
|
||||
*,
|
||||
mode: Literal["off", "auto", "force"] = "auto",
|
||||
page_numbers: Optional[list[int]] = None,
|
||||
password: Optional[str] = None,
|
||||
dpi: float = 150.0,
|
||||
minimum_confidence: float = 0.0,
|
||||
hosted_recommendation_confidence: float = 0.5,
|
||||
model_directory: Optional[str] = None,
|
||||
offline: bool = False,
|
||||
) -> OcrPdfResult:
|
||||
"""Process a PDF through native extraction and selective OCR.
|
||||
|
||||
Page numbers are 1-indexed. OCR runs without holding the Python GIL.
|
||||
"""
|
||||
...
|
||||
|
||||
def process_pdf_with_ocr_bytes(
|
||||
data: bytes,
|
||||
*,
|
||||
mode: Literal["off", "auto", "force"] = "auto",
|
||||
page_numbers: Optional[list[int]] = None,
|
||||
password: Optional[str] = None,
|
||||
dpi: float = 150.0,
|
||||
minimum_confidence: float = 0.0,
|
||||
hosted_recommendation_confidence: float = 0.5,
|
||||
model_directory: Optional[str] = None,
|
||||
offline: bool = False,
|
||||
) -> OcrPdfResult:
|
||||
"""Process PDF bytes through native extraction and selective OCR."""
|
||||
...
|
||||
|
||||
def detect_pdf(path: str) -> PdfResult:
|
||||
"""Fast detection only — no text extraction."""
|
||||
...
|
||||
|
||||
+29
-61
@@ -165,8 +165,8 @@ fn format_ocr_json(result: &OcrPdfResult) -> String {
|
||||
.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,
|
||||
r#"{{"page":{},"source":"{}","markdown":"{}","ocr_model":{},"render_dpi":{},"ocr_confidence":{},"hosted_recommended":{},"timings":{{"render_ms":{},"ocr_ms":{},"layout_ms":{},"assembly_ms":{}}},"warnings":[{}]}}"#,
|
||||
provenance.page,
|
||||
source,
|
||||
json_escape(&page.markdown),
|
||||
model,
|
||||
@@ -175,6 +175,7 @@ fn format_ocr_json(result: &OcrPdfResult) -> String {
|
||||
provenance.hosted_recommended,
|
||||
provenance.timings.render_ms,
|
||||
provenance.timings.ocr_ms,
|
||||
provenance.timings.layout_ms,
|
||||
provenance.timings.assembly_ms,
|
||||
warnings,
|
||||
)
|
||||
@@ -195,7 +196,7 @@ fn format_ocr_json(result: &OcrPdfResult) -> String {
|
||||
.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":"{}"}}"#,
|
||||
r#"{{"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,
|
||||
@@ -223,19 +224,6 @@ fn argument_value<'a>(args: &'a [String], name: &str) -> Result<Option<&'a str>,
|
||||
.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)?
|
||||
@@ -259,9 +247,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 +297,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.
|
||||
@@ -476,27 +441,23 @@ fn main() {
|
||||
.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,
|
||||
);
|
||||
eprintln!("Error: OCR options require --ocr off, --ocr auto, or --ocr force");
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
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,
|
||||
eprintln!(
|
||||
"Error: --ocr cannot be combined with --items-json, --detect-only, or --analyze"
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
#[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,
|
||||
);
|
||||
eprintln!("Error: this pdf2md build does not include OCR; rebuild with --features ocr");
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
|
||||
@@ -506,26 +467,28 @@ fn main() {
|
||||
"auto" => OcrMode::Auto,
|
||||
"force" => OcrMode::Force,
|
||||
value => {
|
||||
exit_ocr_error(
|
||||
&format!("invalid --ocr mode {value:?}; expected off, auto, or force"),
|
||||
json_output,
|
||||
);
|
||||
eprintln!("Error: invalid --ocr mode {value:?}; expected off, auto, or force");
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
let dpi = float_argument(&args, "--ocr-dpi", 150.0).unwrap_or_else(|error| {
|
||||
exit_ocr_error(&error, json_output);
|
||||
eprintln!("Error: {error}");
|
||||
process::exit(1);
|
||||
});
|
||||
let minimum_confidence = float_argument(&args, "--ocr-min-confidence", 0.0)
|
||||
.unwrap_or_else(|error| {
|
||||
exit_ocr_error(&error, json_output);
|
||||
eprintln!("Error: {error}");
|
||||
process::exit(1);
|
||||
});
|
||||
let hosted_threshold = float_argument(&args, "--ocr-hosted-threshold", 0.5)
|
||||
.unwrap_or_else(|error| {
|
||||
exit_ocr_error(&error, json_output);
|
||||
eprintln!("Error: {error}");
|
||||
process::exit(1);
|
||||
});
|
||||
let model_directory =
|
||||
argument_value(&args, "--ocr-model-dir").unwrap_or_else(|error| {
|
||||
exit_ocr_error(&error, json_output);
|
||||
eprintln!("Error: {error}");
|
||||
process::exit(1);
|
||||
});
|
||||
|
||||
let mut ocr = OcrOptions::new()
|
||||
@@ -548,7 +511,7 @@ fn main() {
|
||||
.markdown(markdown)
|
||||
.hosted_recommendation_confidence(hosted_threshold);
|
||||
if let Some(pages) = page_filter.clone() {
|
||||
pdf_options = pdf_options.page_numbers(pages);
|
||||
pdf_options = pdf_options.pages(pages);
|
||||
}
|
||||
if let Some(password) = password.clone() {
|
||||
pdf_options = pdf_options.password(password);
|
||||
@@ -586,7 +549,12 @@ fn main() {
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
exit_ocr_error(&error.to_string(), json_output);
|
||||
if json_output {
|
||||
println!(r#"{{"error":"{}"}}"#, json_escape(&error.to_string()));
|
||||
} else {
|
||||
eprintln!("Error: {error}");
|
||||
}
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
return;
|
||||
|
||||
+3
-20
@@ -459,15 +459,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)
|
||||
extract_pages_markdown_mem_impl(buffer, pages, None, &MarkdownOptions::default(), false)
|
||||
.map(|(result, _)| result)
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
|
||||
@@ -483,7 +476,6 @@ pub(crate) fn extract_pages_markdown_mem_for_ocr(
|
||||
password,
|
||||
markdown_options,
|
||||
markdown_options.strip_headers_footers,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -493,7 +485,6 @@ fn extract_pages_markdown_mem_impl(
|
||||
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)?;
|
||||
@@ -672,15 +663,7 @@ 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,
|
||||
});
|
||||
|
||||
-296
@@ -85,107 +85,6 @@ impl PyPageOcrReasons {
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact OCR model identity retained in page provenance.
|
||||
#[pyclass(name = "OcrModelIdentity")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyOcrModelIdentity {
|
||||
#[pyo3(get)]
|
||||
pub name: String,
|
||||
#[pyo3(get)]
|
||||
pub revision: String,
|
||||
}
|
||||
|
||||
/// Per-page OCR processing timings.
|
||||
#[pyclass(name = "OcrTimings")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyOcrTimings {
|
||||
#[pyo3(get)]
|
||||
pub render_ms: u64,
|
||||
#[pyo3(get)]
|
||||
pub ocr_ms: u64,
|
||||
#[pyo3(get)]
|
||||
pub assembly_ms: u64,
|
||||
}
|
||||
|
||||
/// Source, model, confidence, and fallback metadata for one page.
|
||||
#[pyclass(name = "OcrPageProvenance")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyOcrPageProvenance {
|
||||
/// 1-indexed page number.
|
||||
#[pyo3(get)]
|
||||
pub page_number: u32,
|
||||
/// "native", "ocr", or "fused".
|
||||
#[pyo3(get)]
|
||||
pub source: String,
|
||||
#[pyo3(get)]
|
||||
pub ocr_model: Option<PyOcrModelIdentity>,
|
||||
#[pyo3(get)]
|
||||
pub render_dpi: Option<f32>,
|
||||
#[pyo3(get)]
|
||||
pub ocr_confidence: Option<f32>,
|
||||
#[pyo3(get)]
|
||||
pub timings: PyOcrTimings,
|
||||
#[pyo3(get)]
|
||||
pub warnings: Vec<String>,
|
||||
#[pyo3(get)]
|
||||
pub hosted_recommended: bool,
|
||||
}
|
||||
|
||||
/// Final Markdown and provenance for one page.
|
||||
#[pyclass(name = "OcrPageResult")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyOcrPageResult {
|
||||
/// 1-indexed page number.
|
||||
#[pyo3(get)]
|
||||
pub page_number: u32,
|
||||
#[pyo3(get)]
|
||||
pub markdown: String,
|
||||
#[pyo3(get)]
|
||||
pub provenance: PyOcrPageProvenance,
|
||||
}
|
||||
|
||||
/// Complete native/OCR Markdown output.
|
||||
#[pyclass(name = "OcrPdfResult")]
|
||||
#[derive(Clone)]
|
||||
pub struct PyOcrPdfResult {
|
||||
#[pyo3(get)]
|
||||
pub markdown: String,
|
||||
#[pyo3(get)]
|
||||
pub pages: Vec<PyOcrPageResult>,
|
||||
#[pyo3(get)]
|
||||
pub page_count: u32,
|
||||
#[pyo3(get)]
|
||||
pub pages_recommended_for_ocr: Vec<u32>,
|
||||
#[pyo3(get)]
|
||||
pub pages_routed_to_ocr: Vec<u32>,
|
||||
#[pyo3(get)]
|
||||
pub pages_recommending_hosted: Vec<u32>,
|
||||
#[pyo3(get)]
|
||||
pub ocr_reasons_by_page: Vec<PyPageOcrReasons>,
|
||||
#[pyo3(get)]
|
||||
pub pages_with_tables: Vec<u32>,
|
||||
#[pyo3(get)]
|
||||
pub pages_with_columns: Vec<u32>,
|
||||
#[pyo3(get)]
|
||||
pub is_complex: bool,
|
||||
#[pyo3(get)]
|
||||
pub processing_time_ms: u64,
|
||||
#[pyo3(get)]
|
||||
pub render_time_ms: u64,
|
||||
#[pyo3(get)]
|
||||
pub ocr_time_ms: u64,
|
||||
}
|
||||
|
||||
#[pymethods]
|
||||
impl PyOcrPdfResult {
|
||||
fn __repr__(&self) -> String {
|
||||
format!(
|
||||
"OcrPdfResult(pages={}, routed_to_ocr={:?}, recommending_hosted={:?})",
|
||||
self.page_count, self.pages_routed_to_ocr, self.pages_recommending_hosted
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Classification wrapper (lightweight)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -463,101 +362,6 @@ fn to_py_err(e: crate::PdfError) -> PyErr {
|
||||
PyValueError::new_err(e.to_string())
|
||||
}
|
||||
|
||||
struct PythonOcrOptions {
|
||||
mode: String,
|
||||
page_numbers: Option<Vec<u32>>,
|
||||
password: Option<String>,
|
||||
dpi: f32,
|
||||
minimum_confidence: f32,
|
||||
hosted_recommendation_confidence: f32,
|
||||
model_directory: Option<String>,
|
||||
offline: bool,
|
||||
}
|
||||
|
||||
fn build_ocr_options(binding: PythonOcrOptions) -> PyResult<crate::vision::OcrPdfOptions> {
|
||||
let mode = match binding.mode.trim().to_ascii_lowercase().as_str() {
|
||||
"off" => crate::vision::OcrMode::Off,
|
||||
"auto" => crate::vision::OcrMode::Auto,
|
||||
"force" => crate::vision::OcrMode::Force,
|
||||
_ => {
|
||||
return Err(PyValueError::new_err(
|
||||
"mode must be 'off', 'auto', or 'force'",
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let mut options = crate::vision::OcrPdfOptions::new().mode(mode);
|
||||
options.render.dpi = binding.dpi;
|
||||
options.ocr.minimum_confidence = binding.minimum_confidence;
|
||||
options.hosted_recommendation_confidence = binding.hosted_recommendation_confidence;
|
||||
if let Some(pages) = binding.page_numbers {
|
||||
options = options.page_numbers(pages);
|
||||
}
|
||||
if let Some(password) = binding.password {
|
||||
options = options.password(password);
|
||||
}
|
||||
if let Some(directory) = binding.model_directory {
|
||||
options.ocr.model_directory = Some(directory.into());
|
||||
}
|
||||
if binding.offline {
|
||||
options.ocr.model_downloads = crate::vision::ModelDownloadPolicy::Offline;
|
||||
}
|
||||
Ok(options)
|
||||
}
|
||||
|
||||
fn page_content_source_str(source: crate::vision::PageContentSource) -> String {
|
||||
match source {
|
||||
crate::vision::PageContentSource::Native => "native".into(),
|
||||
crate::vision::PageContentSource::Ocr => "ocr".into(),
|
||||
crate::vision::PageContentSource::Fused => "fused".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn to_py_ocr_result(result: crate::vision::OcrPdfResult) -> PyOcrPdfResult {
|
||||
PyOcrPdfResult {
|
||||
markdown: result.markdown,
|
||||
pages: result
|
||||
.pages
|
||||
.into_iter()
|
||||
.map(|page| {
|
||||
let provenance = page.provenance;
|
||||
PyOcrPageResult {
|
||||
page_number: page.page_number,
|
||||
markdown: page.markdown,
|
||||
provenance: PyOcrPageProvenance {
|
||||
page_number: provenance.page_number,
|
||||
source: page_content_source_str(provenance.source),
|
||||
ocr_model: provenance.ocr_model.map(|model| PyOcrModelIdentity {
|
||||
name: model.name,
|
||||
revision: model.revision,
|
||||
}),
|
||||
render_dpi: provenance.render_dpi,
|
||||
ocr_confidence: provenance.ocr_confidence,
|
||||
timings: PyOcrTimings {
|
||||
render_ms: provenance.timings.render_ms,
|
||||
ocr_ms: provenance.timings.ocr_ms,
|
||||
assembly_ms: provenance.timings.assembly_ms,
|
||||
},
|
||||
warnings: provenance.warnings,
|
||||
hosted_recommended: provenance.hosted_recommended,
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
page_count: result.page_count,
|
||||
pages_recommended_for_ocr: result.pages_recommended_for_ocr,
|
||||
pages_routed_to_ocr: result.pages_routed_to_ocr,
|
||||
pages_recommending_hosted: result.pages_recommending_hosted,
|
||||
ocr_reasons_by_page: to_py_page_ocr_reasons(result.ocr_reasons_by_page),
|
||||
pages_with_tables: result.pages_with_tables,
|
||||
pages_with_columns: result.pages_with_columns,
|
||||
is_complex: result.is_complex,
|
||||
processing_time_ms: result.processing_time_ms,
|
||||
render_time_ms: result.render_time_ms,
|
||||
ocr_time_ms: result.ocr_time_ms,
|
||||
}
|
||||
}
|
||||
|
||||
fn item_type_str(t: &ItemType) -> String {
|
||||
match t {
|
||||
ItemType::Text => "text".into(),
|
||||
@@ -698,99 +502,6 @@ fn process_pdf_bytes(data: &[u8], pages: Option<Vec<u32>>) -> PyResult<PyPdfResu
|
||||
Ok(to_py_result(result))
|
||||
}
|
||||
|
||||
/// Process a PDF file through native extraction and selective OCR.
|
||||
///
|
||||
/// OCR defaults to ``auto`` and only initializes its external runtime and
|
||||
/// model when native quality signals route at least one page. Page numbers
|
||||
/// are 1-indexed. The GIL is released for the complete processing call.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (
|
||||
path,
|
||||
*,
|
||||
mode="auto",
|
||||
page_numbers=None,
|
||||
password=None,
|
||||
dpi=150.0,
|
||||
minimum_confidence=0.0,
|
||||
hosted_recommendation_confidence=0.5,
|
||||
model_directory=None,
|
||||
offline=false
|
||||
))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn process_pdf_with_ocr(
|
||||
py: Python<'_>,
|
||||
path: String,
|
||||
mode: &str,
|
||||
page_numbers: Option<Vec<u32>>,
|
||||
password: Option<String>,
|
||||
dpi: f32,
|
||||
minimum_confidence: f32,
|
||||
hosted_recommendation_confidence: f32,
|
||||
model_directory: Option<String>,
|
||||
offline: bool,
|
||||
) -> PyResult<PyOcrPdfResult> {
|
||||
let options = build_ocr_options(PythonOcrOptions {
|
||||
mode: mode.to_string(),
|
||||
page_numbers,
|
||||
password,
|
||||
dpi,
|
||||
minimum_confidence,
|
||||
hosted_recommendation_confidence,
|
||||
model_directory,
|
||||
offline,
|
||||
})?;
|
||||
let result = py
|
||||
.allow_threads(move || crate::vision::process_pdf_with_ocr(path, options))
|
||||
.map_err(|error| PyValueError::new_err(error.to_string()))?;
|
||||
Ok(to_py_ocr_result(result))
|
||||
}
|
||||
|
||||
/// Process PDF bytes through native extraction and selective OCR.
|
||||
///
|
||||
/// See [`process_pdf_with_ocr`] for options and result semantics.
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (
|
||||
data,
|
||||
*,
|
||||
mode="auto",
|
||||
page_numbers=None,
|
||||
password=None,
|
||||
dpi=150.0,
|
||||
minimum_confidence=0.0,
|
||||
hosted_recommendation_confidence=0.5,
|
||||
model_directory=None,
|
||||
offline=false
|
||||
))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn process_pdf_with_ocr_bytes(
|
||||
py: Python<'_>,
|
||||
data: &[u8],
|
||||
mode: &str,
|
||||
page_numbers: Option<Vec<u32>>,
|
||||
password: Option<String>,
|
||||
dpi: f32,
|
||||
minimum_confidence: f32,
|
||||
hosted_recommendation_confidence: f32,
|
||||
model_directory: Option<String>,
|
||||
offline: bool,
|
||||
) -> PyResult<PyOcrPdfResult> {
|
||||
let options = build_ocr_options(PythonOcrOptions {
|
||||
mode: mode.to_string(),
|
||||
page_numbers,
|
||||
password,
|
||||
dpi,
|
||||
minimum_confidence,
|
||||
hosted_recommendation_confidence,
|
||||
model_directory,
|
||||
offline,
|
||||
})?;
|
||||
let data = data.to_vec();
|
||||
let result = py
|
||||
.allow_threads(move || crate::vision::process_pdf_with_ocr_mem(&data, options))
|
||||
.map_err(|error| PyValueError::new_err(error.to_string()))?;
|
||||
Ok(to_py_ocr_result(result))
|
||||
}
|
||||
|
||||
/// Fast detection only — no text extraction or markdown.
|
||||
#[pyfunction]
|
||||
fn detect_pdf(path: &str) -> PyResult<PyPdfResult> {
|
||||
@@ -993,11 +704,6 @@ fn extract_structure_elements_bytes(
|
||||
fn pdf_inspector(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyPdfResult>()?;
|
||||
m.add_class::<PyPageOcrReasons>()?;
|
||||
m.add_class::<PyOcrModelIdentity>()?;
|
||||
m.add_class::<PyOcrTimings>()?;
|
||||
m.add_class::<PyOcrPageProvenance>()?;
|
||||
m.add_class::<PyOcrPageResult>()?;
|
||||
m.add_class::<PyOcrPdfResult>()?;
|
||||
m.add_class::<PyPdfClassification>()?;
|
||||
m.add_class::<PyTextItem>()?;
|
||||
m.add_class::<PyStructureElement>()?;
|
||||
@@ -1007,8 +713,6 @@ fn pdf_inspector(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
m.add_class::<PyPagesExtractionResult>()?;
|
||||
m.add_function(wrap_pyfunction!(process_pdf, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(process_pdf_bytes, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(process_pdf_with_ocr, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(process_pdf_with_ocr_bytes, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(detect_pdf, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(detect_pdf_bytes, m)?)?;
|
||||
m.add_function(wrap_pyfunction!(classify_pdf, m)?)?;
|
||||
|
||||
@@ -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"));
|
||||
|
||||
+152
-3
@@ -1,4 +1,4 @@
|
||||
//! Public contracts between rendering, OCR, and orchestration.
|
||||
//! Public contracts between rendering, OCR, layout, and orchestration.
|
||||
|
||||
use std::error::Error;
|
||||
use std::path::PathBuf;
|
||||
@@ -18,6 +18,19 @@ pub enum OcrMode {
|
||||
Force,
|
||||
}
|
||||
|
||||
/// Resource/quality profile for the OCR engine.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum OcrProfile {
|
||||
/// Lowest latency and memory footprint.
|
||||
Edge,
|
||||
/// OCR-oriented balance of quality and CPU cost.
|
||||
#[default]
|
||||
Balanced,
|
||||
/// Highest quality within the lightweight model family.
|
||||
Quality,
|
||||
}
|
||||
|
||||
/// Controls whether missing model artifacts may be fetched.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
@@ -34,8 +47,12 @@ pub enum ModelDownloadPolicy {
|
||||
pub struct OcrOptions {
|
||||
/// Page-level routing behavior.
|
||||
pub mode: OcrMode,
|
||||
/// Local quality/resource profile.
|
||||
pub profile: OcrProfile,
|
||||
/// Drop recognition spans below this confidence threshold.
|
||||
pub minimum_confidence: f32,
|
||||
/// Optional language hints understood by the selected engine.
|
||||
pub languages: Vec<String>,
|
||||
/// Optional directory containing an offline model set.
|
||||
pub model_directory: Option<PathBuf>,
|
||||
/// Whether a missing pinned artifact may be downloaded.
|
||||
@@ -46,7 +63,9 @@ impl Default for OcrOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: OcrMode::Off,
|
||||
profile: OcrProfile::Balanced,
|
||||
minimum_confidence: 0.0,
|
||||
languages: Vec::new(),
|
||||
model_directory: None,
|
||||
model_downloads: ModelDownloadPolicy::IfMissing,
|
||||
}
|
||||
@@ -65,12 +84,24 @@ impl OcrOptions {
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the local resource/quality profile.
|
||||
pub fn profile(mut self, profile: OcrProfile) -> Self {
|
||||
self.profile = profile;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the minimum accepted recognition confidence.
|
||||
pub fn minimum_confidence(mut self, minimum_confidence: f32) -> Self {
|
||||
self.minimum_confidence = minimum_confidence;
|
||||
self
|
||||
}
|
||||
|
||||
/// Replaces the language hints passed to the OCR engine.
|
||||
pub fn languages(mut self, languages: impl IntoIterator<Item = impl Into<String>>) -> Self {
|
||||
self.languages = languages.into_iter().map(Into::into).collect();
|
||||
self
|
||||
}
|
||||
|
||||
/// Uses an explicit model directory, suitable for offline packaging.
|
||||
pub fn model_directory(mut self, directory: impl Into<PathBuf>) -> Self {
|
||||
self.model_directory = Some(directory.into());
|
||||
@@ -84,6 +115,55 @@ impl OcrOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for an optional learned layout engine.
|
||||
///
|
||||
/// Layout inference is disabled by default. Existing deterministic layout,
|
||||
/// table, and Markdown logic remains the assembly path when this is disabled.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LayoutOptions {
|
||||
/// Whether the learned layout extension may run.
|
||||
pub enabled: bool,
|
||||
/// Drop layout regions below this confidence threshold.
|
||||
pub minimum_confidence: f32,
|
||||
/// Optional directory containing an offline layout model set.
|
||||
pub model_directory: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl Default for LayoutOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
minimum_confidence: 0.0,
|
||||
model_directory: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl LayoutOptions {
|
||||
/// Creates layout options with learned layout disabled.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Enables or disables learned layout inference.
|
||||
pub fn enabled(mut self, enabled: bool) -> Self {
|
||||
self.enabled = enabled;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the minimum accepted region confidence.
|
||||
pub fn minimum_confidence(mut self, minimum_confidence: f32) -> Self {
|
||||
self.minimum_confidence = minimum_confidence;
|
||||
self
|
||||
}
|
||||
|
||||
/// Uses an explicit layout model directory.
|
||||
pub fn model_directory(mut self, directory: impl Into<PathBuf>) -> Self {
|
||||
self.model_directory = Some(directory.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A point in bitmap space, measured from the top-left in pixels.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct ImagePoint {
|
||||
@@ -150,7 +230,7 @@ pub struct OcrSpan {
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct OcrPage {
|
||||
/// 1-indexed PDF page number.
|
||||
pub page_number: u32,
|
||||
pub page: u32,
|
||||
/// Positioned recognition spans.
|
||||
pub spans: Vec<OcrSpan>,
|
||||
/// Mean confidence across accepted spans, when available.
|
||||
@@ -163,6 +243,54 @@ pub struct OcrPage {
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// Normalized semantic class emitted by a learned layout engine.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum LayoutRegionKind {
|
||||
/// Body or other prose text.
|
||||
Text,
|
||||
/// Document heading or title.
|
||||
Heading,
|
||||
/// Table region.
|
||||
Table,
|
||||
/// Figure/image region.
|
||||
Figure,
|
||||
/// Figure or table caption.
|
||||
Caption,
|
||||
/// Header/footer/page furniture.
|
||||
Furniture,
|
||||
/// Model-specific class retained without changing the common taxonomy.
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// One learned layout region in bitmap coordinates.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LayoutRegion {
|
||||
/// Normalized semantic class.
|
||||
pub kind: LayoutRegionKind,
|
||||
/// Region polygon in the original rendered page's pixel space.
|
||||
pub polygon: ImageQuad,
|
||||
/// Model confidence in the inclusive range 0–1.
|
||||
pub confidence: f32,
|
||||
/// Optional model-provided reading-order position.
|
||||
pub reading_order: Option<u32>,
|
||||
}
|
||||
|
||||
/// Learned layout output for one 1-indexed page.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LayoutPage {
|
||||
/// 1-indexed PDF page number.
|
||||
pub page: u32,
|
||||
/// Semantic regions.
|
||||
pub regions: Vec<LayoutRegion>,
|
||||
/// Exact model identity used for this result.
|
||||
pub model: ModelIdentity,
|
||||
/// Layout inference wall time for this page.
|
||||
pub processing_time_ms: u64,
|
||||
/// Non-fatal engine warnings.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// How final page content was sourced.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
@@ -182,6 +310,8 @@ pub struct VisionTimings {
|
||||
pub render_ms: u64,
|
||||
/// OCR wall time.
|
||||
pub ocr_ms: u64,
|
||||
/// Optional learned layout wall time.
|
||||
pub layout_ms: u64,
|
||||
/// Native/OCR fusion and assembly wall time.
|
||||
pub assembly_ms: u64,
|
||||
}
|
||||
@@ -190,11 +320,13 @@ pub struct VisionTimings {
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PageProvenance {
|
||||
/// 1-indexed PDF page number.
|
||||
pub page_number: u32,
|
||||
pub page: u32,
|
||||
/// Final page-content source.
|
||||
pub source: PageContentSource,
|
||||
/// OCR model, when OCR ran.
|
||||
pub ocr_model: Option<ModelIdentity>,
|
||||
/// Learned layout model, when layout inference ran.
|
||||
pub layout_model: Option<ModelIdentity>,
|
||||
/// Render resolution used for local vision.
|
||||
pub render_dpi: Option<f32>,
|
||||
/// Mean accepted OCR confidence, when available.
|
||||
@@ -239,6 +371,23 @@ pub trait OcrEngine: Send + Sync {
|
||||
) -> Result<Vec<OcrPage>, Self::Error>;
|
||||
}
|
||||
|
||||
/// Optional learned semantic layout extension.
|
||||
pub trait LayoutEngine: Send + Sync {
|
||||
/// Engine-specific failure type.
|
||||
type Error: Error + Send + Sync + 'static;
|
||||
|
||||
/// Exact model identity used by this engine instance.
|
||||
fn model(&self) -> &ModelIdentity;
|
||||
|
||||
/// Analyzes rendered pages, optionally using their OCR spans.
|
||||
fn analyze(
|
||||
&self,
|
||||
pages: &[RenderedPage],
|
||||
ocr: &[OcrPage],
|
||||
options: &LayoutOptions,
|
||||
) -> Result<Vec<LayoutPage>, Self::Error>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+14
-416
@@ -6,7 +6,6 @@ use std::time::Instant;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::markdown::{to_markdown_from_items_with_rects_and_page_count, MarkdownOptions};
|
||||
use crate::text_quality::{detect_encoding_issues, is_cid_garbage, is_garbage_text};
|
||||
use crate::types::{ItemType, TextItem};
|
||||
use crate::PageMarkdown;
|
||||
|
||||
@@ -74,7 +73,7 @@ impl OcrFusionOptions {
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct FusedPageMarkdown {
|
||||
/// 1-indexed document page number, matching OCR and provenance fields.
|
||||
pub page_number: u32,
|
||||
pub page: u32,
|
||||
/// Final page Markdown.
|
||||
pub markdown: String,
|
||||
/// Native/OCR source, model, timing, and fallback metadata.
|
||||
@@ -92,73 +91,6 @@ pub struct FusedPages {
|
||||
pub ocr_time_ms: u64,
|
||||
}
|
||||
|
||||
/// Origin of a trustworthy native-text candidate retained for adaptive OCR.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum NativeCandidateOrigin {
|
||||
/// Text produced by pdf-inspector's normal native extractor.
|
||||
Extractor,
|
||||
/// Positioned text independently recovered through PDFium.
|
||||
Pdfium,
|
||||
}
|
||||
|
||||
impl NativeCandidateOrigin {
|
||||
fn description(self) -> &'static str {
|
||||
match self {
|
||||
Self::Extractor => "native extraction",
|
||||
Self::Pdfium => "PDFium native recovery",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct TextCandidateQuality {
|
||||
alphanumeric_chars: usize,
|
||||
score: f32,
|
||||
}
|
||||
|
||||
/// Clean native text retained while an ambiguous page is compared with OCR.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct NativeFallbackCandidate {
|
||||
markdown: String,
|
||||
quality: TextCandidateQuality,
|
||||
origin: NativeCandidateOrigin,
|
||||
}
|
||||
|
||||
impl NativeFallbackCandidate {
|
||||
/// True when an independent native recovery is substantial enough to
|
||||
/// cancel OCR for recoverable font/vector routing reasons.
|
||||
pub(crate) fn is_complete_recovery(&self) -> bool {
|
||||
self.quality.alphanumeric_chars >= 40 && self.quality.score >= 0.68
|
||||
}
|
||||
|
||||
pub(crate) fn markdown(&self) -> &str {
|
||||
&self.markdown
|
||||
}
|
||||
|
||||
pub(crate) fn is_stronger_than(&self, other: &Self) -> bool {
|
||||
self.quality.alphanumeric_chars > other.quality.alphanumeric_chars
|
||||
|| (self.quality.alphanumeric_chars == other.quality.alphanumeric_chars
|
||||
&& self.quality.score > other.quality.score)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validates and scores native Markdown for possible post-OCR comparison.
|
||||
///
|
||||
/// This intentionally uses script-agnostic evidence. A native candidate only
|
||||
/// needs to be trustworthy, not necessarily complete: a clean native header
|
||||
/// can still be fused with an image-backed OCR body.
|
||||
pub(crate) fn assess_native_candidate(
|
||||
markdown: String,
|
||||
origin: NativeCandidateOrigin,
|
||||
) -> Option<NativeFallbackCandidate> {
|
||||
let quality = assess_text_candidate(&markdown)?;
|
||||
(quality.alphanumeric_chars >= 8).then_some(NativeFallbackCandidate {
|
||||
markdown,
|
||||
quality,
|
||||
origin,
|
||||
})
|
||||
}
|
||||
|
||||
/// Converts positioned OCR spans to Markdown through pdf-inspector's existing
|
||||
/// deterministic geometry, reading-order, table, and Markdown pipeline.
|
||||
///
|
||||
@@ -184,47 +116,13 @@ pub fn ocr_page_to_markdown(
|
||||
/// OCR replaces pages whose native extraction was already rejected. On clean
|
||||
/// native pages (for example in `Force` mode), normalized duplicate OCR blocks
|
||||
/// are removed and only genuinely additional blocks are appended. Pages that
|
||||
/// needed OCR but still have no credible OCR result, or whose confident OCR
|
||||
/// only repeats an incomplete native fragment, recommend the hosted document
|
||||
/// pipeline instead of silently presenting partial content as final.
|
||||
/// needed OCR but still have no credible local result recommend the hosted
|
||||
/// document pipeline instead of silently presenting an empty result as final.
|
||||
pub fn fuse_ocr_pages(
|
||||
native_pages: &[PageMarkdown],
|
||||
ocr_run: &OcrRun,
|
||||
document_page_count: u32,
|
||||
options: &OcrFusionOptions,
|
||||
) -> Result<FusedPages, OcrFusionError> {
|
||||
fuse_ocr_pages_impl(
|
||||
native_pages,
|
||||
ocr_run,
|
||||
document_page_count,
|
||||
options,
|
||||
&BTreeMap::new(),
|
||||
)
|
||||
}
|
||||
|
||||
/// OCR-pipeline fusion with trustworthy partial native candidates.
|
||||
pub(crate) fn fuse_ocr_pages_adaptive(
|
||||
native_pages: &[PageMarkdown],
|
||||
ocr_run: &OcrRun,
|
||||
document_page_count: u32,
|
||||
options: &OcrFusionOptions,
|
||||
native_candidates: &BTreeMap<u32, NativeFallbackCandidate>,
|
||||
) -> Result<FusedPages, OcrFusionError> {
|
||||
fuse_ocr_pages_impl(
|
||||
native_pages,
|
||||
ocr_run,
|
||||
document_page_count,
|
||||
options,
|
||||
native_candidates,
|
||||
)
|
||||
}
|
||||
|
||||
fn fuse_ocr_pages_impl(
|
||||
native_pages: &[PageMarkdown],
|
||||
ocr_run: &OcrRun,
|
||||
document_page_count: u32,
|
||||
options: &OcrFusionOptions,
|
||||
native_candidates: &BTreeMap<u32, NativeFallbackCandidate>,
|
||||
) -> Result<FusedPages, OcrFusionError> {
|
||||
validate_options(options)?;
|
||||
|
||||
@@ -280,31 +178,16 @@ fn fuse_ocr_pages_impl(
|
||||
document_page_count,
|
||||
);
|
||||
let ocr_markdown = preserve_ocr_line_breaks(&ocr_markdown, local);
|
||||
let (markdown, source, adaptive_recommends_hosted) = if let Some(candidate) =
|
||||
native_candidates
|
||||
.get(&page_number)
|
||||
.filter(|_| native.needs_ocr)
|
||||
{
|
||||
let choice = choose_adaptive_content(
|
||||
candidate,
|
||||
&ocr_markdown,
|
||||
local.ocr.mean_confidence,
|
||||
options.hosted_recommendation_confidence,
|
||||
);
|
||||
warnings.push(choice.warning);
|
||||
(choice.markdown, choice.source, choice.recommend_hosted)
|
||||
} else if native.markdown.trim().is_empty() || native.needs_ocr {
|
||||
(ocr_markdown, PageContentSource::Ocr, false)
|
||||
let (markdown, source) = if native.markdown.trim().is_empty() || native.needs_ocr {
|
||||
(ocr_markdown, PageContentSource::Ocr)
|
||||
} else {
|
||||
let (markdown, source) = merge_native_and_ocr(&native.markdown, &ocr_markdown);
|
||||
(markdown, source, false)
|
||||
merge_native_and_ocr(&native.markdown, &ocr_markdown)
|
||||
};
|
||||
let weak_ocr = local
|
||||
.ocr
|
||||
.mean_confidence
|
||||
.is_none_or(|confidence| confidence < options.hosted_recommendation_confidence);
|
||||
let recommend_hosted = native.needs_ocr
|
||||
&& (markdown.trim().is_empty() || weak_ocr || adaptive_recommends_hosted);
|
||||
let recommend_hosted = native.needs_ocr && (markdown.trim().is_empty() || weak_ocr);
|
||||
if native.needs_ocr && markdown.trim().is_empty() {
|
||||
warnings.push("OCR produced no usable text".to_string());
|
||||
}
|
||||
@@ -331,12 +214,13 @@ fn fuse_ocr_pages_impl(
|
||||
};
|
||||
|
||||
pages.push(FusedPageMarkdown {
|
||||
page_number,
|
||||
page: page_number,
|
||||
markdown,
|
||||
provenance: PageProvenance {
|
||||
page_number,
|
||||
page: page_number,
|
||||
source,
|
||||
ocr_model,
|
||||
layout_model: None,
|
||||
render_dpi: ocr_by_page
|
||||
.contains_key(&page_number)
|
||||
.then_some(options.render_dpi),
|
||||
@@ -344,6 +228,7 @@ fn fuse_ocr_pages_impl(
|
||||
timings: VisionTimings {
|
||||
render_ms: render_by_page.get(&page_number).copied().unwrap_or(0),
|
||||
ocr_ms,
|
||||
layout_ms: 0,
|
||||
assembly_ms: elapsed_ms(assembly_started),
|
||||
},
|
||||
warnings,
|
||||
@@ -359,159 +244,6 @@ fn fuse_ocr_pages_impl(
|
||||
})
|
||||
}
|
||||
|
||||
struct AdaptiveContentChoice {
|
||||
markdown: String,
|
||||
source: PageContentSource,
|
||||
warning: String,
|
||||
recommend_hosted: bool,
|
||||
}
|
||||
|
||||
fn choose_adaptive_content(
|
||||
native: &NativeFallbackCandidate,
|
||||
ocr: &str,
|
||||
ocr_confidence: Option<f32>,
|
||||
weak_ocr_threshold: f32,
|
||||
) -> AdaptiveContentChoice {
|
||||
let ocr_quality = assess_text_candidate(ocr);
|
||||
let weak_ocr = ocr_confidence.is_none_or(|confidence| confidence < weak_ocr_threshold);
|
||||
if weak_ocr || ocr_quality.is_none() {
|
||||
return AdaptiveContentChoice {
|
||||
markdown: ensure_trailing_newline(native.markdown()),
|
||||
source: PageContentSource::Native,
|
||||
warning: format!(
|
||||
"kept trustworthy {} because OCR was weak or unusable",
|
||||
native.origin.description()
|
||||
),
|
||||
recommend_hosted: true,
|
||||
};
|
||||
}
|
||||
|
||||
let ocr_quality = ocr_quality.expect("checked above");
|
||||
let overlap = content_overlap(native.markdown(), ocr);
|
||||
let ocr_novel_chars = ocr_quality
|
||||
.alphanumeric_chars
|
||||
.saturating_sub(overlap.shared_chars);
|
||||
let material_novelty =
|
||||
ocr_novel_chars >= 2 && ocr_novel_chars * 8 >= ocr_quality.alphanumeric_chars.max(1);
|
||||
let native_substantially_covered =
|
||||
overlap.shared_chars * 4 >= native.quality.alphanumeric_chars.max(1) * 3;
|
||||
|
||||
if native_substantially_covered && !material_novelty {
|
||||
return AdaptiveContentChoice {
|
||||
markdown: ensure_trailing_newline(native.markdown()),
|
||||
source: PageContentSource::Native,
|
||||
warning: format!(
|
||||
"kept trustworthy {} because OCR added no material coverage",
|
||||
native.origin.description()
|
||||
),
|
||||
// The candidate exists only because this page was routed with
|
||||
// incomplete native coverage. Agreement between two partial
|
||||
// hypotheses preserves trustworthy text, but does not prove that
|
||||
// the rest of the page was recovered.
|
||||
recommend_hosted: true,
|
||||
};
|
||||
}
|
||||
|
||||
// A shorter, materially lower-quality OCR hypothesis should not displace
|
||||
// exact native text even when its mean confidence happens to be high.
|
||||
if ocr_quality.score + 0.12 < native.quality.score
|
||||
&& ocr_quality.alphanumeric_chars * 10
|
||||
<= native.quality.alphanumeric_chars.saturating_mul(11)
|
||||
{
|
||||
return AdaptiveContentChoice {
|
||||
markdown: ensure_trailing_newline(native.markdown()),
|
||||
source: PageContentSource::Native,
|
||||
warning: format!(
|
||||
"kept higher-quality {} after comparing OCR",
|
||||
native.origin.description()
|
||||
),
|
||||
recommend_hosted: false,
|
||||
};
|
||||
}
|
||||
|
||||
let (markdown, source) = merge_native_and_ocr(native.markdown(), ocr);
|
||||
let warning = match source {
|
||||
PageContentSource::Native => format!(
|
||||
"kept trustworthy {} because OCR duplicated its content",
|
||||
native.origin.description()
|
||||
),
|
||||
PageContentSource::Fused => format!(
|
||||
"fused trustworthy {} with complementary OCR",
|
||||
native.origin.description()
|
||||
),
|
||||
PageContentSource::Ocr => unreachable!("merge never returns OCR-only content"),
|
||||
};
|
||||
AdaptiveContentChoice {
|
||||
markdown,
|
||||
source,
|
||||
warning,
|
||||
recommend_hosted: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct ContentOverlap {
|
||||
shared_chars: usize,
|
||||
}
|
||||
|
||||
fn content_overlap(first: &str, second: &str) -> ContentOverlap {
|
||||
let mut first_counts = BTreeMap::<char, usize>::new();
|
||||
for character in normalized_content_chars(first) {
|
||||
*first_counts.entry(character).or_insert(0) += 1;
|
||||
}
|
||||
let mut second_counts = BTreeMap::<char, usize>::new();
|
||||
for character in normalized_content_chars(second) {
|
||||
*second_counts.entry(character).or_insert(0) += 1;
|
||||
}
|
||||
let shared_chars = first_counts
|
||||
.iter()
|
||||
.map(|(character, count)| (*count).min(second_counts.get(character).copied().unwrap_or(0)))
|
||||
.sum();
|
||||
ContentOverlap { shared_chars }
|
||||
}
|
||||
|
||||
fn normalized_content_chars(text: &str) -> impl Iterator<Item = char> + '_ {
|
||||
text.chars()
|
||||
.flat_map(char::to_lowercase)
|
||||
.filter(|character| character.is_alphanumeric())
|
||||
}
|
||||
|
||||
fn assess_text_candidate(markdown: &str) -> Option<TextCandidateQuality> {
|
||||
if markdown.trim().is_empty()
|
||||
|| is_garbage_text(markdown)
|
||||
|| is_cid_garbage(markdown)
|
||||
|| detect_encoding_issues(markdown)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let alphanumeric_chars = markdown
|
||||
.chars()
|
||||
.filter(|character| character.is_alphanumeric())
|
||||
.count();
|
||||
if alphanumeric_chars == 0 {
|
||||
return None;
|
||||
}
|
||||
let visible_chars = markdown
|
||||
.chars()
|
||||
.filter(|character| !character.is_whitespace())
|
||||
.count()
|
||||
.max(1);
|
||||
let density = alphanumeric_chars as f32 / visible_chars as f32;
|
||||
let length_score = (alphanumeric_chars as f32 / 160.0).min(1.0);
|
||||
let nonempty_lines = markdown
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.count()
|
||||
.max(1);
|
||||
let line_score = (alphanumeric_chars as f32 / nonempty_lines as f32 / 12.0).min(1.0);
|
||||
let score = (0.45 + length_score * 0.25 + density * 0.20 + line_score * 0.10).min(1.0);
|
||||
Some(TextCandidateQuality {
|
||||
alphanumeric_chars,
|
||||
score,
|
||||
})
|
||||
}
|
||||
|
||||
/// Converts recognized line polygons to ordinary PDF-space text items.
|
||||
fn ocr_text_items(page: &RoutedOcrPage) -> (Vec<TextItem>, usize) {
|
||||
let mut discarded = 0usize;
|
||||
@@ -1014,7 +746,7 @@ mod tests {
|
||||
RoutedOcrPage {
|
||||
rendered: rendered_page(page),
|
||||
ocr: OcrPage {
|
||||
page_number: page,
|
||||
page,
|
||||
spans,
|
||||
mean_confidence: confidence,
|
||||
model: ModelIdentity::new("test-ocr", "v1"),
|
||||
@@ -1046,10 +778,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn native_candidate(markdown: &str) -> NativeFallbackCandidate {
|
||||
assess_native_candidate(markdown.to_string(), NativeCandidateOrigin::Extractor).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scanned_page_uses_geometry_ordered_ocr_and_provenance() {
|
||||
let native = [native(0, "", true)];
|
||||
@@ -1069,11 +797,8 @@ mod tests {
|
||||
< result.pages[0].markdown.find("Second").unwrap()
|
||||
);
|
||||
assert_eq!(result.pages[0].provenance.source, PageContentSource::Ocr);
|
||||
assert_eq!(result.pages[0].page_number, 1);
|
||||
assert_eq!(
|
||||
result.pages[0].page_number,
|
||||
result.pages[0].provenance.page_number
|
||||
);
|
||||
assert_eq!(result.pages[0].page, 1);
|
||||
assert_eq!(result.pages[0].page, result.pages[0].provenance.page);
|
||||
assert_eq!(
|
||||
result.pages[0].provenance.ocr_model.as_ref().unwrap().name,
|
||||
"test-ocr"
|
||||
@@ -1205,133 +930,6 @@ mod tests {
|
||||
assert_eq!(result.pages[0].provenance.source, PageContentSource::Native);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adaptive_fallback_keeps_native_text_when_ocr_is_weak() {
|
||||
let native = [native(0, "", true)];
|
||||
let run = run(vec![routed_page(
|
||||
1,
|
||||
vec![span("Inv0ice total uncertain", 10.0, 0.3)],
|
||||
Some(0.3),
|
||||
)]);
|
||||
let candidates = BTreeMap::from([(
|
||||
1,
|
||||
native_candidate("Invoice total: $420.00\nPayment received\n"),
|
||||
)]);
|
||||
|
||||
let result =
|
||||
fuse_ocr_pages_adaptive(&native, &run, 1, &OcrFusionOptions::new(), &candidates)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.pages[0].provenance.source, PageContentSource::Native);
|
||||
assert_eq!(
|
||||
result.pages[0].markdown,
|
||||
"Invoice total: $420.00\nPayment received\n"
|
||||
);
|
||||
assert!(result.pages[0].provenance.hosted_recommended);
|
||||
assert!(result.pages[0].provenance.warnings[0].contains("OCR was weak"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adaptive_fallback_prefers_exact_native_text_over_duplicate_ocr() {
|
||||
let native = [native(0, "", true)];
|
||||
let run = run(vec![routed_page(
|
||||
1,
|
||||
vec![span("Invoice total 420.00 Payment received", 10.0, 0.98)],
|
||||
Some(0.98),
|
||||
)]);
|
||||
let candidates = BTreeMap::from([(
|
||||
1,
|
||||
native_candidate("Invoice total: $420.00\nPayment received\n"),
|
||||
)]);
|
||||
|
||||
let result =
|
||||
fuse_ocr_pages_adaptive(&native, &run, 1, &OcrFusionOptions::new(), &candidates)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.pages[0].provenance.source, PageContentSource::Native);
|
||||
assert!(result.pages[0].provenance.hosted_recommended);
|
||||
assert_eq!(result.pages[0].markdown.matches("Invoice").count(), 1);
|
||||
assert!(result.pages[0].provenance.warnings[0].contains("no material coverage"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adaptive_fallback_fuses_short_novel_ocr_content() {
|
||||
let native = [native(0, "", true)];
|
||||
let run = run(vec![routed_page(
|
||||
1,
|
||||
vec![span("Status ready 42", 10.0, 0.98)],
|
||||
Some(0.98),
|
||||
)]);
|
||||
let candidates = BTreeMap::from([(1, native_candidate("Status ready\n"))]);
|
||||
|
||||
let result =
|
||||
fuse_ocr_pages_adaptive(&native, &run, 1, &OcrFusionOptions::new(), &candidates)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.pages[0].provenance.source, PageContentSource::Fused);
|
||||
assert!(result.pages[0].markdown.contains("42"));
|
||||
assert!(!result.pages[0].provenance.hosted_recommended);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adaptive_fallback_recommends_hosted_for_high_confidence_garbage_ocr() {
|
||||
let native = [native(0, "", true)];
|
||||
let garbage = "a@@b%%c&&d==e~~".repeat(12);
|
||||
let run = run(vec![routed_page(
|
||||
1,
|
||||
vec![span(&garbage, 10.0, 0.99)],
|
||||
Some(0.99),
|
||||
)]);
|
||||
let candidates = BTreeMap::from([(1, native_candidate("Invoice total 420\n"))]);
|
||||
|
||||
let result =
|
||||
fuse_ocr_pages_adaptive(&native, &run, 1, &OcrFusionOptions::new(), &candidates)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.pages[0].provenance.source, PageContentSource::Native);
|
||||
assert!(result.pages[0].provenance.hosted_recommended);
|
||||
assert!(!result.pages[0].markdown.contains("@@"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn adaptive_fallback_fuses_native_header_with_scanned_body() {
|
||||
let native = [native(0, "", true)];
|
||||
let run = run(vec![routed_page(
|
||||
1,
|
||||
vec![
|
||||
span("Quarterly account report", 10.0, 0.96),
|
||||
span("March revenue 420 units", 30.0, 0.96),
|
||||
span("April revenue 510 units", 50.0, 0.96),
|
||||
],
|
||||
Some(0.96),
|
||||
)]);
|
||||
let candidates = BTreeMap::from([(1, native_candidate("# Quarterly account report\n"))]);
|
||||
|
||||
let result =
|
||||
fuse_ocr_pages_adaptive(&native, &run, 1, &OcrFusionOptions::new(), &candidates)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(result.pages[0].provenance.source, PageContentSource::Fused);
|
||||
assert_eq!(result.pages[0].markdown.matches("Quarterly").count(), 1);
|
||||
assert!(result.pages[0].markdown.contains("March revenue 420 units"));
|
||||
assert!(result.pages[0].markdown.contains("April revenue 510 units"));
|
||||
assert!(result.pages[0].provenance.warnings[0].contains("complementary"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_candidate_scoring_is_multilingual_and_rejects_garbage() {
|
||||
assert!(assess_native_candidate(
|
||||
"請求書 合計金額 4200円\n支払済み\n".to_string(),
|
||||
NativeCandidateOrigin::Extractor,
|
||||
)
|
||||
.is_some());
|
||||
assert!(assess_native_candidate(
|
||||
"a@@b%%c&&d==e~~".repeat(12),
|
||||
NativeCandidateOrigin::Extractor,
|
||||
)
|
||||
.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn force_mode_appends_only_additional_ocr_blocks() {
|
||||
let native = [native(0, "Native title\n", false)];
|
||||
|
||||
+3
-2
@@ -30,8 +30,9 @@ 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,
|
||||
ImagePoint, ImageQuad, LayoutEngine, LayoutOptions, LayoutPage, LayoutRegion, LayoutRegionKind,
|
||||
ModelDownloadPolicy, ModelIdentity, OcrEngine, OcrMode, OcrOptions, OcrPage, OcrProfile,
|
||||
OcrSpan, PageContentSource, PageProvenance, PageRenderer, VisionTimings,
|
||||
};
|
||||
#[cfg(all(feature = "model-download", not(target_arch = "wasm32")))]
|
||||
pub use download::{HttpModelDownloadError, HttpModelDownloader, DEFAULT_MODEL_DOWNLOAD_TIMEOUT};
|
||||
|
||||
@@ -190,17 +190,16 @@ impl ModelStore {
|
||||
&self.cache_root
|
||||
}
|
||||
|
||||
/// Effective directory containing one manifest's artifacts.
|
||||
pub(crate) fn model_root(&self, manifest: &ModelManifest) -> PathBuf {
|
||||
self.override_root
|
||||
.clone()
|
||||
.unwrap_or_else(|| self.manifest_cache_root(manifest))
|
||||
}
|
||||
|
||||
/// Validates and resolves every required artifact.
|
||||
pub fn resolve(&self, manifest: &ModelManifest) -> Result<ModelPaths, ModelStoreError> {
|
||||
validate_manifest(manifest)?;
|
||||
let root = self.model_root(manifest);
|
||||
let managed_root;
|
||||
let root = if let Some(root) = self.override_root.as_deref() {
|
||||
root
|
||||
} else {
|
||||
managed_root = self.manifest_cache_root(manifest);
|
||||
managed_root.as_path()
|
||||
};
|
||||
|
||||
let mut artifacts = BTreeMap::new();
|
||||
for artifact in manifest.artifacts {
|
||||
|
||||
+11
-38
@@ -4,7 +4,6 @@ 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;
|
||||
@@ -49,9 +48,7 @@ pub enum OarOcrError {
|
||||
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}"
|
||||
)]
|
||||
#[error("failed to load ONNX Runtime from {path}: {source}")]
|
||||
OnnxRuntimeLoad {
|
||||
/// Requested shared-library path or platform library name.
|
||||
path: PathBuf,
|
||||
@@ -89,13 +86,7 @@ impl OarOcrEngine {
|
||||
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 pipeline = OAROCRBuilder::new(detection, recognition, dictionary).build()?;
|
||||
let model = ModelIdentity::new(models.manifest_id(), models.revision());
|
||||
Ok(Self { pipeline, model })
|
||||
}
|
||||
@@ -145,6 +136,10 @@ impl OarOcrEngine {
|
||||
}
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
if !options.languages.is_empty() {
|
||||
warnings
|
||||
.push("language hints are not used by the PP-OCRv6 Small OAR backend".to_string());
|
||||
}
|
||||
if missing_recognition > 0 {
|
||||
warnings.push(format!(
|
||||
"discarded {missing_recognition} regions without usable recognition output"
|
||||
@@ -164,7 +159,7 @@ impl OarOcrEngine {
|
||||
let processing_time_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
|
||||
Ok(OcrPage {
|
||||
page_number: page.page(),
|
||||
page: page.page(),
|
||||
spans,
|
||||
mean_confidence,
|
||||
model: self.model.clone(),
|
||||
@@ -174,18 +169,11 @@ impl OarOcrEngine {
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
let path = std::env::var_os(ONNX_RUNTIME_LIBRARY_ENV)
|
||||
.filter(|path| !path.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(default_onnx_runtime_library);
|
||||
drop(
|
||||
ort::init_from(&path).map_err(|source| OarOcrError::OnnxRuntimeLoad {
|
||||
path: path.clone(),
|
||||
@@ -195,13 +183,6 @@ fn load_onnx_runtime() -> Result<(), OarOcrError> {
|
||||
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";
|
||||
@@ -374,14 +355,6 @@ mod tests {
|
||||
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();
|
||||
|
||||
+3
-215
@@ -2,11 +2,9 @@
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use firecrawl_pdfium::{PageChar, Pdfium, PixelFormat, PixelPoint, RenderConfig};
|
||||
use firecrawl_pdfium::{Pdfium, PixelFormat, PixelPoint, RenderConfig};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::types::{ItemType, TextItem};
|
||||
|
||||
use super::{
|
||||
PageRenderer, PageTransform, RenderBufferError, RenderOptions, RenderPixelFormat, RenderedPage,
|
||||
};
|
||||
@@ -49,15 +47,6 @@ pub enum RenderError {
|
||||
/// Number of pages in the document.
|
||||
page_count: usize,
|
||||
},
|
||||
/// The PDFium shared library could not be discovered or loaded.
|
||||
#[error(
|
||||
"failed to load PDFium; install a compatible PDFium shared library or set PDFIUM_LIB_PATH to its path"
|
||||
)]
|
||||
PdfiumLoad {
|
||||
/// Dynamic loading failure.
|
||||
#[source]
|
||||
source: firecrawl_pdfium::Error,
|
||||
},
|
||||
/// PDFium loading, document parsing, form setup, or rendering failed.
|
||||
#[error(transparent)]
|
||||
Pdfium(#[from] firecrawl_pdfium::Error),
|
||||
@@ -76,28 +65,18 @@ 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 })?,
|
||||
pdfium: Pdfium::load()?,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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 })?,
|
||||
pdfium: Pdfium::load_from_path(path)?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -121,56 +100,6 @@ impl PdfiumRenderer {
|
||||
self.render_pages_impl(pdf_bytes, pages, password, options)
|
||||
}
|
||||
|
||||
/// Extracts positioned native text from selected 1-indexed pages.
|
||||
///
|
||||
/// This is deliberately separate from rendering: callers can probe a
|
||||
/// suspicious embedded text layer before paying for rasterization and
|
||||
/// OCR. A page-level text failure is treated as an unavailable recovery
|
||||
/// candidate so the caller can continue to its normal OCR fallback.
|
||||
pub(crate) fn extract_text_pages(
|
||||
&self,
|
||||
pdf_bytes: &[u8],
|
||||
pages: &[u32],
|
||||
password: Option<&str>,
|
||||
) -> Result<Vec<PdfiumTextPage>, RenderError> {
|
||||
const MAX_TEXT_CHARS_PER_PAGE: usize = 250_000;
|
||||
|
||||
if pages.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if pages.contains(&0) {
|
||||
return Err(RenderError::InvalidPageNumber);
|
||||
}
|
||||
|
||||
let document = self.pdfium.load_document(pdf_bytes.to_vec(), password)?;
|
||||
let page_count = document.page_count();
|
||||
if let Some(&page) = pages.iter().find(|&&page| page as usize > page_count) {
|
||||
return Err(RenderError::PageOutOfBounds { page, page_count });
|
||||
}
|
||||
|
||||
let mut recovered = Vec::with_capacity(pages.len());
|
||||
for &page_number in pages {
|
||||
let page = document.page(page_number as usize - 1)?;
|
||||
let page_size = page.size();
|
||||
let text = match page.text_with_limit(MAX_TEXT_CHARS_PER_PAGE) {
|
||||
Ok(text) => text,
|
||||
Err(error) => {
|
||||
log::debug!(
|
||||
"page {page_number}: positioned native text recovery unavailable: {error}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
recovered.push(PdfiumTextPage {
|
||||
page: page_number,
|
||||
page_width: page_size.width,
|
||||
page_height: page_size.height,
|
||||
items: text_chars_to_items(text.chars(), page_number),
|
||||
});
|
||||
}
|
||||
Ok(recovered)
|
||||
}
|
||||
|
||||
fn render_pages_impl(
|
||||
&self,
|
||||
pdf_bytes: &[u8],
|
||||
@@ -216,106 +145,6 @@ impl PdfiumRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
fn text_chars_to_items(chars: &[PageChar], page: u32) -> Vec<TextItem> {
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Bounds {
|
||||
left: f64,
|
||||
bottom: f64,
|
||||
right: f64,
|
||||
top: f64,
|
||||
}
|
||||
|
||||
fn flush(items: &mut Vec<TextItem>, text: &mut String, bounds: &mut Option<Bounds>, page: u32) {
|
||||
let Some(bounds) = bounds.take() else {
|
||||
text.clear();
|
||||
return;
|
||||
};
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let width = (bounds.right - bounds.left) as f32;
|
||||
let height = (bounds.top - bounds.bottom) as f32;
|
||||
let x = bounds.left as f32;
|
||||
let y = bounds.bottom as f32;
|
||||
if !x.is_finite()
|
||||
|| !y.is_finite()
|
||||
|| !width.is_finite()
|
||||
|| !height.is_finite()
|
||||
|| width <= 0.0
|
||||
|| height <= 0.0
|
||||
{
|
||||
text.clear();
|
||||
return;
|
||||
}
|
||||
items.push(TextItem {
|
||||
text: std::mem::take(text),
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
font: "PDFium native text".to_string(),
|
||||
font_size: height.max(1.0),
|
||||
page,
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
is_underline: false,
|
||||
is_strikeout: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
});
|
||||
}
|
||||
|
||||
let mut items = Vec::new();
|
||||
let mut text = String::new();
|
||||
let mut bounds: Option<Bounds> = None;
|
||||
for character in chars {
|
||||
let Some(value) = character.unicode else {
|
||||
flush(&mut items, &mut text, &mut bounds, page);
|
||||
continue;
|
||||
};
|
||||
if value.is_whitespace() {
|
||||
flush(&mut items, &mut text, &mut bounds, page);
|
||||
continue;
|
||||
}
|
||||
|
||||
let rect = character.loose_bounds.normalized();
|
||||
if !rect.left.is_finite()
|
||||
|| !rect.bottom.is_finite()
|
||||
|| !rect.right.is_finite()
|
||||
|| !rect.top.is_finite()
|
||||
|| rect.width() <= 0.0
|
||||
|| rect.height() <= 0.0
|
||||
{
|
||||
flush(&mut items, &mut text, &mut bounds, page);
|
||||
continue;
|
||||
}
|
||||
text.push(value);
|
||||
bounds = Some(match bounds {
|
||||
Some(bounds) => Bounds {
|
||||
left: bounds.left.min(rect.left),
|
||||
bottom: bounds.bottom.min(rect.bottom),
|
||||
right: bounds.right.max(rect.right),
|
||||
top: bounds.top.max(rect.top),
|
||||
},
|
||||
None => Bounds {
|
||||
left: rect.left,
|
||||
bottom: rect.bottom,
|
||||
right: rect.right,
|
||||
top: rect.top,
|
||||
},
|
||||
});
|
||||
}
|
||||
flush(&mut items, &mut text, &mut bounds, page);
|
||||
items.sort_by(|first, second| {
|
||||
first
|
||||
.page
|
||||
.cmp(&second.page)
|
||||
.then(second.y.total_cmp(&first.y))
|
||||
.then(first.x.total_cmp(&second.x))
|
||||
});
|
||||
items
|
||||
}
|
||||
|
||||
impl PageRenderer for PdfiumRenderer {
|
||||
type Error = RenderError;
|
||||
|
||||
@@ -408,17 +237,6 @@ fn bgr_to_rgb_in_place(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use firecrawl_pdfium::{PagePoint, PageRect};
|
||||
|
||||
fn page_char(value: char, bounds: PageRect) -> PageChar {
|
||||
PageChar {
|
||||
unicode: Some(value),
|
||||
code: value as u32,
|
||||
bounds,
|
||||
loose_bounds: bounds,
|
||||
origin: PagePoint::new(bounds.left, bounds.bottom),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bgr_pixels_are_converted_to_rgb_in_place() {
|
||||
@@ -445,34 +263,4 @@ mod tests {
|
||||
Err(RenderBufferError::InvalidBufferLength { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_character_geometry_splits_text_runs() {
|
||||
let chars = [
|
||||
page_char('A', PageRect::new(0.0, 0.0, 8.0, 10.0)),
|
||||
page_char('X', PageRect::new(10.0, 0.0, 10.0, 10.0)),
|
||||
page_char('B', PageRect::new(20.0, 0.0, 28.0, 10.0)),
|
||||
];
|
||||
|
||||
let items = text_chars_to_items(&chars, 1);
|
||||
|
||||
assert_eq!(
|
||||
items
|
||||
.iter()
|
||||
.map(|item| item.text.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["A", "B"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coordinates_that_overflow_f32_are_discarded() {
|
||||
let left = f64::from(f32::MAX) * 2.0;
|
||||
let chars = [page_char(
|
||||
'A',
|
||||
PageRect::new(left, 0.0, left + 1.0e30, 10.0),
|
||||
)];
|
||||
|
||||
assert!(text_chars_to_items(&chars, 1).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
+46
-963
File diff suppressed because it is too large
Load Diff
@@ -106,11 +106,7 @@ where
|
||||
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),
|
||||
)?;
|
||||
validate_page_order("OCR engine", pages, recognized.iter().map(|page| page.page))?;
|
||||
|
||||
Ok(OcrRun {
|
||||
pages: rendered
|
||||
@@ -272,7 +268,7 @@ mod tests {
|
||||
Ok(pages
|
||||
.iter()
|
||||
.map(|page| OcrPage {
|
||||
page_number: page.page(),
|
||||
page: page.page(),
|
||||
spans: vec![OcrSpan {
|
||||
text: format!("page {}", page.page()),
|
||||
polygon: ImageQuad::new([
|
||||
|
||||
@@ -5,7 +5,9 @@ use pdf_inspector::vision::{PdfiumRenderer, RenderError, RenderOptions, RenderPi
|
||||
fn load_renderer() -> Option<PdfiumRenderer> {
|
||||
match PdfiumRenderer::load() {
|
||||
Ok(renderer) => Some(renderer),
|
||||
Err(RenderError::PdfiumLoad { .. }) => {
|
||||
Err(RenderError::Pdfium(firecrawl_pdfium::Error::Load(
|
||||
firecrawl_pdfium::LoadError::LibraryNotFound { .. },
|
||||
))) => {
|
||||
eprintln!("skipping PDFium runtime test because no native library is installed");
|
||||
None
|
||||
}
|
||||
|
||||
+7
-62
@@ -2,8 +2,7 @@
|
||||
|
||||
#[cfg(feature = "ocr")]
|
||||
use pdf_inspector::vision::{
|
||||
process_pdf_with_ocr_mem, ModelDownloadPolicy, OcrPdfOptions, OcrPipelineError,
|
||||
PageContentSource,
|
||||
process_pdf_with_ocr_mem, ModelDownloadPolicy, OcrPdfOptions, PageContentSource,
|
||||
};
|
||||
use pdf_inspector::vision::{
|
||||
ModelStore, OarOcrEngine, OcrEngine, OcrMode, OcrOptions, PageTransform, RenderPixelFormat,
|
||||
@@ -20,7 +19,9 @@ const EXPECTED_TEXT_ENV: &str = "PDF_INSPECTOR_OCR_TEST_EXPECTED";
|
||||
fn load_renderer() -> Option<PdfiumRenderer> {
|
||||
match PdfiumRenderer::load() {
|
||||
Ok(renderer) => Some(renderer),
|
||||
Err(RenderError::PdfiumLoad { .. }) => {
|
||||
Err(RenderError::Pdfium(firecrawl_pdfium::Error::Load(
|
||||
firecrawl_pdfium::LoadError::LibraryNotFound { .. },
|
||||
))) => {
|
||||
eprintln!("skipping OCR runtime test because no native PDFium library is installed");
|
||||
None
|
||||
}
|
||||
@@ -120,71 +121,15 @@ fn complete_ocr_pipeline_routes_and_assembles_a_scanned_fixture() {
|
||||
.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();
|
||||
let result = process_pdf_with_ocr_mem(&bytes, OcrPdfOptions::new().ocr(ocr)).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.source, PageContentSource::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(
|
||||
@@ -206,7 +151,7 @@ fn recognize(
|
||||
|
||||
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].page, 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());
|
||||
|
||||
@@ -77,50 +77,6 @@ class TestProcessPdfBytes:
|
||||
assert result.markdown is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# process_pdf_with_ocr / process_pdf_with_ocr_bytes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestProcessPdfWithOcr:
|
||||
def test_off_mode_has_full_provenance_without_external_runtimes(self):
|
||||
result = pdf_inspector.process_pdf_with_ocr(
|
||||
fixture_path("thermo-freon12.pdf"), mode="off"
|
||||
)
|
||||
assert result.page_count == 3
|
||||
assert len(result.pages) == 3
|
||||
assert result.pages_routed_to_ocr == []
|
||||
assert all(page.provenance.source == "native" for page in result.pages)
|
||||
assert all(page.provenance.ocr_model is None for page in result.pages)
|
||||
assert result.markdown
|
||||
assert "OcrPdfResult" in repr(result)
|
||||
|
||||
def test_auto_mode_skips_external_runtimes_for_clean_text(self):
|
||||
result = pdf_inspector.process_pdf_with_ocr_bytes(
|
||||
fixture_bytes("thermo-freon12.pdf")
|
||||
)
|
||||
assert result.pages_routed_to_ocr == []
|
||||
assert result.render_time_ms == 0
|
||||
assert result.ocr_time_ms == 0
|
||||
|
||||
def test_selected_pages_are_one_indexed(self):
|
||||
result = pdf_inspector.process_pdf_with_ocr(
|
||||
fixture_path("thermo-freon12.pdf"), mode="off", page_numbers=[2]
|
||||
)
|
||||
assert [page.page_number for page in result.pages] == [2]
|
||||
|
||||
def test_rejects_invalid_options(self):
|
||||
with pytest.raises(ValueError, match="mode must be"):
|
||||
pdf_inspector.process_pdf_with_ocr(
|
||||
fixture_path("thermo-freon12.pdf"), mode="sometimes"
|
||||
)
|
||||
with pytest.raises(ValueError, match="page 0"):
|
||||
pdf_inspector.process_pdf_with_ocr(
|
||||
fixture_path("thermo-freon12.pdf"),
|
||||
mode="off",
|
||||
page_numbers=[0],
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# detect_pdf / detect_pdf_bytes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user