Compare commits
42
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
841513d3fb | ||
|
|
bac056e801 | ||
|
|
0027b048ce | ||
|
|
fb45d37dfe | ||
|
|
5eb6a13860 | ||
|
|
74ebce430c | ||
|
|
cf4e42b91c | ||
|
|
d390d402a5 | ||
|
|
84789459b1 | ||
|
|
06a9bab6b3 | ||
|
|
ca6d667146 | ||
|
|
a4b1c714e8 | ||
|
|
0f9b5fa1c6 | ||
|
|
dba1eadf4d | ||
|
|
264a1c8372 | ||
|
|
72003730ee | ||
|
|
828a68c03b | ||
|
|
99069ce3d9 | ||
|
|
e2d2bc33d9 | ||
|
|
926720f8ff | ||
|
|
aa3ad2e6e0 | ||
|
|
2cebb3c95f | ||
|
|
7c63a00242 | ||
|
|
e460a45f73 | ||
|
|
12d30b43b0 | ||
|
|
dd467dd78d | ||
|
|
d9b83993df | ||
|
|
2543abe371 | ||
|
|
7f982d2094 | ||
|
|
4bee4f993b | ||
|
|
1719d24871 | ||
|
|
f114e79c8b | ||
|
|
544538b99f | ||
|
|
c8ba909407 | ||
|
|
076183e2e4 | ||
|
|
ec6e54afb8 | ||
|
|
89dd20d02c | ||
|
|
3d33ff3dbd | ||
|
|
75e9b09593 | ||
|
|
f4aab3b36f | ||
|
|
f65b25906c | ||
|
|
9947485a92 |
@@ -0,0 +1,2 @@
|
||||
*.pdf binary
|
||||
tests/snapshots/*.md text eol=lf
|
||||
+184
-1
@@ -9,6 +9,9 @@ on:
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
test:
|
||||
name: Test
|
||||
@@ -68,9 +71,12 @@ jobs:
|
||||
with:
|
||||
key: clippy
|
||||
|
||||
- name: Run clippy
|
||||
- name: Run default clippy
|
||||
run: cargo clippy -- -D warnings
|
||||
|
||||
- name: Run OCR clippy
|
||||
run: cargo clippy --features ocr -- -D warnings
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -93,6 +99,183 @@ 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: Configure isolated model cache
|
||||
shell: bash
|
||||
run: echo "PDF_INSPECTOR_MODEL_CACHE=$RUNNER_TEMP/pdf-inspector-models" >> "$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: Provision OCR model cache
|
||||
shell: bash
|
||||
run: |
|
||||
target/debug/pdf2md \
|
||||
tests/fixtures/scan_with_native_header_text.pdf \
|
||||
--ocr force \
|
||||
--json > /dev/null
|
||||
|
||||
- name: Run OCR CLI
|
||||
shell: bash
|
||||
run: |
|
||||
target/debug/pdf2md \
|
||||
tests/fixtures/scan_with_native_header_text.pdf \
|
||||
--ocr auto \
|
||||
--ocr-offline \
|
||||
--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: Run OCR launch smoke set
|
||||
shell: bash
|
||||
run: |
|
||||
export PDF_INSPECTOR_OCR_TEST_MODELS="$PDF_INSPECTOR_MODEL_CACHE/pp-ocrv6-small/oar-ocr-v0.7.0"
|
||||
cargo test --features ocr --test ocr_tests -- --nocapture
|
||||
|
||||
- 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
|
||||
|
||||
@@ -86,12 +86,14 @@ jobs:
|
||||
name: Build ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-gnu
|
||||
docker-options: -e CFLAGS_aarch64_unknown_linux_gnu=-D__ARM_ARCH=8
|
||||
# macos-13 was retired by GitHub; macos-15-intel is the remaining
|
||||
# Intel runner label (available through 2027).
|
||||
- os: macos-15-intel
|
||||
@@ -113,6 +115,9 @@ jobs:
|
||||
target: ${{ matrix.target }}
|
||||
args: --release --out dist
|
||||
manylinux: auto
|
||||
# The manylinux AArch64 GCC omits this macro while preprocessing
|
||||
# ring's assembly. AArch64 is ARMv8 by definition.
|
||||
docker-options: ${{ matrix.docker-options }}
|
||||
|
||||
- name: Upload wheel
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
|
||||
@@ -60,6 +60,7 @@ jobs:
|
||||
name: Build ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
@@ -70,6 +71,7 @@ jobs:
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-gnu
|
||||
build-flags: --use-napi-cross
|
||||
cflags: -D__ARM_ARCH=8
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-musl
|
||||
build-flags: -x
|
||||
@@ -126,6 +128,10 @@ jobs:
|
||||
|
||||
- name: Build native addon
|
||||
working-directory: napi
|
||||
env:
|
||||
# napi-cross's old AArch64 GCC omits this predefined macro while
|
||||
# preprocessing ring's assembly. AArch64 is ARMv8 by definition.
|
||||
CFLAGS_aarch64_unknown_linux_gnu: ${{ matrix.cflags }}
|
||||
run: bunx napi build --platform --release --target ${{ matrix.target }} ${{ matrix.build-flags }}
|
||||
|
||||
- name: Upload native binary
|
||||
|
||||
+29
-2
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "pdf-inspector"
|
||||
version = "1.14.0"
|
||||
version = "1.15.0"
|
||||
edition = "2021"
|
||||
autobins = false
|
||||
authors = ["Firecrawl Team"]
|
||||
@@ -49,6 +49,25 @@ ttf-parser = "0.25"
|
||||
lopdf = { version = "0.42.0", features = ["rayon"] }
|
||||
rayon = "1.10"
|
||||
env_logger = "0.11"
|
||||
# Optional native page rendering for OCR pipelines. PDFium is loaded at
|
||||
# runtime, so enabling this feature does not link or download a native library.
|
||||
firecrawl-pdfium = { version = "0.1.0", optional = true }
|
||||
# Small support crates used only by the opt-in model cache. Model files remain
|
||||
# external and are never embedded in pdf-inspector artifacts.
|
||||
dirs = { version = "6.0", optional = true }
|
||||
fs2 = { version = "0.4", optional = true }
|
||||
sha2 = { version = "0.11", optional = true }
|
||||
# Optional CPU OCR backend. Models and ONNX Runtime stay external: the latter
|
||||
# is loaded dynamically from ORT_DYLIB_PATH or the platform library search path.
|
||||
image = { version = "0.25.6", default-features = false, optional = true }
|
||||
oar-ocr = { version = "0.9.1", default-features = false, features = ["simd"], optional = true }
|
||||
ort = { version = "=2.0.0-rc.13", default-features = false, features = ["load-dynamic"], optional = true }
|
||||
# HTTPS-only streaming downloader for pinned model artifacts. Kept separate
|
||||
# from model-cache so offline and package-managed deployments avoid HTTP/TLS.
|
||||
ureq = { version = "3.4", default-features = false, features = ["rustls", "platform-verifier"], optional = true }
|
||||
|
||||
[target.'cfg(all(windows, not(target_arch = "wasm32")))'.dependencies]
|
||||
windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"], optional = true }
|
||||
|
||||
# Browser builds use JavaScript randomness for encrypted PDFs and embed the
|
||||
# bundled CMaps because there is no filesystem at runtime.
|
||||
@@ -61,7 +80,15 @@ tempfile = "3.3"
|
||||
|
||||
[features]
|
||||
default = []
|
||||
python = ["pyo3"]
|
||||
python = ["pyo3", "ocr"]
|
||||
vision = []
|
||||
model-cache = ["vision", "dep:dirs", "dep:fs2", "dep:sha2", "dep:windows-sys"]
|
||||
model-download = ["model-cache", "dep:ureq"]
|
||||
ocr-oar = ["model-cache", "dep:image", "dep:oar-ocr", "dep:ort"]
|
||||
render-pdfium = ["vision", "dep:firecrawl-pdfium"]
|
||||
# Complete native OCR path. This remains opt-in so default library,
|
||||
# renderer-only, and browser consumers do not inherit inference or HTTP/TLS.
|
||||
ocr = ["render-pdfium", "ocr-oar", "model-download"]
|
||||
|
||||
[[bin]]
|
||||
name = "pdf2md"
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
[](https://pypi.org/project/pdf-inspector/)
|
||||
[](LICENSE)
|
||||
|
||||
Fast Rust library for PDF classification and text extraction. Detects whether a PDF is text-based or scanned, extracts text with position awareness, and converts to clean Markdown — all without OCR. Includes bindings for [Python](docs/python.md), [Node.js](napi/README.md), and [browser WebAssembly](wasm/README.md).
|
||||
Fast Rust library for PDF classification and text extraction. By default it detects whether a PDF is text-based or scanned, extracts text with position awareness, and converts to clean Markdown without OCR. Native Rust and CLI consumers can opt into selective OCR. Includes bindings for [Python](docs/python.md), [Node.js](napi/README.md), and [browser WebAssembly](wasm/README.md).
|
||||
|
||||
Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in under 200ms, skipping expensive OCR services for the ~54% of PDFs that don't need them.
|
||||
|
||||
@@ -18,9 +18,10 @@ Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in
|
||||
- **CID font support** — ToUnicode CMap decoding for Type0/Identity-H fonts, UTF-16BE, UTF-8, and Latin-1 encodings.
|
||||
- **Multi-column layout** — Automatic detection of newspaper-style columns, sequential reading order, and RTL text support.
|
||||
- **Encoding issue detection** — Automatically flags broken font encodings so callers can fall back to OCR.
|
||||
- **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** — Pure Rust, no ML models, no external services. Single dependency on `lopdf` for PDF parsing.
|
||||
- **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.
|
||||
|
||||
## Benchmark
|
||||
|
||||
@@ -47,8 +48,7 @@ Use the [paired benchmark harness](docs/benchmarking.md) to compare two local bu
|
||||
### Python
|
||||
|
||||
```bash
|
||||
pip install maturin
|
||||
maturin develop --release
|
||||
pip install pdf-inspector
|
||||
```
|
||||
|
||||
```python
|
||||
@@ -57,6 +57,10 @@ 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)
|
||||
@@ -69,11 +73,15 @@ npm install @firecrawl/pdf-inspector
|
||||
|
||||
```javascript
|
||||
import { readFileSync } from 'fs';
|
||||
import { processPdf, classifyPdf } from '@firecrawl/pdf-inspector';
|
||||
import { processPdf, processPdfWithOcr } from '@firecrawl/pdf-inspector';
|
||||
|
||||
const result = processPdf(readFileSync('document.pdf'));
|
||||
const pdf = readFileSync('document.pdf');
|
||||
const result = processPdf(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)
|
||||
@@ -160,6 +168,23 @@ 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
|
||||
[OCR runtime setup guide](docs/ocr-runtime.md) for pinned downloads, platform
|
||||
support, model-cache behavior, and hosted-fallback integration. See the
|
||||
[Rust API guide](docs/rust-api.md#complete-ocr-api) for lower-level controls.
|
||||
|
||||
From a source checkout, use `cargo run --bin pdf2md -- document.pdf` or `cargo run --bin detect-pdf -- document.pdf` instead.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
# OCR runtime setup
|
||||
|
||||
Selective OCR is available from the Rust library and CLI, Python, and Node.js.
|
||||
Clean native-text documents do not load an OCR dependency or download a model.
|
||||
When `auto` routes at least one page, the process needs PDFium, ONNX Runtime,
|
||||
and the pinned PP-OCRv6 Small model set.
|
||||
|
||||
## Validated versions
|
||||
|
||||
The reproducible runtime path uses these builds:
|
||||
|
||||
- [Firecrawl PDFium `native-v7988`](https://github.com/firecrawl/pdfium-rs/releases/tag/native-v7988),
|
||||
containing PDFium `153.0.7988.0`
|
||||
- [ONNX Runtime `1.27.0`](https://github.com/microsoft/onnxruntime/releases/tag/v1.27.0)
|
||||
- PP-OCRv6 Small artifact revision `oar-ocr-v0.7.0`
|
||||
|
||||
Use these versions for the reproducible path. Other compatible shared-library
|
||||
builds may work, but are not part of the release smoke test.
|
||||
|
||||
## Install the shared libraries
|
||||
|
||||
Download and extract the matching archives:
|
||||
|
||||
| Platform | PDFium asset | ONNX Runtime asset |
|
||||
|---|---|---|
|
||||
| Linux x64 | `firecrawl-pdfium-linux-x64.tgz` | `onnxruntime-linux-x64-1.27.0.tgz` |
|
||||
| Linux ARM64 | `firecrawl-pdfium-linux-arm64.tgz` | `onnxruntime-linux-aarch64-1.27.0.tgz` |
|
||||
| macOS Apple Silicon | `firecrawl-pdfium-mac-arm64.tgz` | `onnxruntime-osx-arm64-1.27.0.tgz` |
|
||||
| Windows x64 | `firecrawl-pdfium-win-x64.tgz` | `onnxruntime-win-x64-1.27.0.zip` |
|
||||
|
||||
The PDFium release publishes `SHA256SUMS`, build provenance, license files,
|
||||
and an SPDX document for every platform archive. GitHub publishes a SHA-256
|
||||
digest with each ONNX Runtime asset.
|
||||
|
||||
Point pdf-inspector at the extracted shared libraries when they are not on the
|
||||
platform library search path:
|
||||
|
||||
```bash
|
||||
export PDFIUM_LIB_PATH=/absolute/path/to/libpdfium.so
|
||||
export ORT_DYLIB_PATH=/absolute/path/to/libonnxruntime.so
|
||||
pdf2md scan.pdf --ocr auto --json
|
||||
```
|
||||
|
||||
On macOS the filenames end in `.dylib`. On Windows, use PowerShell and point
|
||||
the variables at `pdfium.dll` and `onnxruntime.dll`:
|
||||
|
||||
```powershell
|
||||
$env:PDFIUM_LIB_PATH = "C:\absolute\path\to\pdfium.dll"
|
||||
$env:ORT_DYLIB_PATH = "C:\absolute\path\to\onnxruntime.dll"
|
||||
pdf2md scan.pdf --ocr auto --json
|
||||
```
|
||||
|
||||
The native extraction packages also support platforms without these exact
|
||||
runtime assets. In particular, the Python package has an Intel macOS wheel,
|
||||
but ONNX Runtime 1.27.0 does not publish an Intel macOS archive; local OCR on
|
||||
that target requires a compatible custom ONNX Runtime build.
|
||||
|
||||
The full OCR path is exercised end to end on Linux x64 in CI. macOS and
|
||||
Windows compile and run the feature's platform-independent tests, while their
|
||||
external-runtime paths should be treated as preview until equivalent smoke
|
||||
jobs are added.
|
||||
|
||||
## Model cache and offline mode
|
||||
|
||||
The first routed page downloads and SHA-256-verifies three pinned artifacts:
|
||||
the detection model, recognition model, and character dictionary. Together
|
||||
they are about 31 MB. They are stored below the platform cache directory.
|
||||
Set `PDF_INSPECTOR_MODEL_CACHE` to choose a managed cache root.
|
||||
|
||||
For hermetic deployments, populate the model directory ahead of time and use
|
||||
the language-specific offline option:
|
||||
|
||||
- CLI: `--ocr-offline --ocr-model-dir /models/pp-ocrv6-small`
|
||||
- Rust: `ModelDownloadPolicy::Offline` with `OcrOptions::model_directory`
|
||||
- Python: `offline=True, model_directory="/models/pp-ocrv6-small"`
|
||||
- Node.js: `offline: true, modelDirectory: "/models/pp-ocrv6-small"`
|
||||
|
||||
The model artifacts come from
|
||||
[`GreatV/oar-ocr`](https://github.com/GreatV/oar-ocr/releases/tag/v0.7.0),
|
||||
whose OCR implementation and upstream PaddleOCR project use Apache-2.0
|
||||
licensing. Models are downloaded at runtime and are not embedded in any
|
||||
pdf-inspector package.
|
||||
|
||||
## Hosted fallback boundary
|
||||
|
||||
`pages_recommending_hosted` is available after the local pipeline completes.
|
||||
It marks pages whose completed OCR result is empty, low-confidence, or still
|
||||
appears incomplete.
|
||||
|
||||
Setup and execution failures happen before that result exists. A missing or
|
||||
incompatible PDFium/ONNX Runtime library, failed model acquisition, or OCR
|
||||
execution error is returned as an error. A downstream integration that has a
|
||||
hosted parser should catch that error and route the document to the hosted
|
||||
path. This keeps deployment problems distinct from page-quality judgments.
|
||||
|
||||
In `auto`, documents with no routed pages return successfully without touching
|
||||
PDFium, ONNX Runtime, the model cache, or the network.
|
||||
+65
-2
@@ -1,6 +1,6 @@
|
||||
# pdf-inspector
|
||||
|
||||
Fast PDF classification and text extraction. Detects whether a PDF is text-based or scanned, extracts text with position awareness, and converts to clean Markdown — all without OCR. Python bindings via [PyO3](https://pyo3.rs) for the [pdf-inspector](https://github.com/firecrawl/pdf-inspector) Rust library.
|
||||
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.
|
||||
|
||||
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,7 +10,8 @@ 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.
|
||||
- **Lightweight** — native Rust core, no ML models, no external services; ships type stubs.
|
||||
- **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.
|
||||
|
||||
## Benchmark
|
||||
|
||||
@@ -39,6 +40,14 @@ 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. See the
|
||||
[OCR runtime setup guide](https://github.com/firecrawl/pdf-inspector/blob/main/docs/ocr-runtime.md)
|
||||
for pinned downloads, supported platforms, and hosted-fallback behavior.
|
||||
|
||||
## Usage
|
||||
|
||||
```python
|
||||
@@ -81,6 +90,19 @@ 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:
|
||||
@@ -99,6 +121,8 @@ 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) |
|
||||
@@ -137,6 +161,45 @@ 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
|
||||
|
||||
+322
-1
@@ -1,6 +1,6 @@
|
||||
# pdf-inspector
|
||||
|
||||
Fast PDF classification and text extraction. Detects whether a PDF is text-based or scanned, extracts text with position awareness, and converts to clean Markdown — all without OCR. Pure Rust, no ML models, no external services; the only PDF dependency is [lopdf](https://crates.io/crates/lopdf). Also available for [Python](https://pypi.org/project/pdf-inspector/) and [Node.js](https://www.npmjs.com/package/@firecrawl/pdf-inspector).
|
||||
Fast PDF classification and text extraction. The default build detects whether a PDF is text-based or scanned, extracts text with position awareness, and converts to clean Markdown without OCR. It is pure Rust, has no ML models or external services, and uses [lopdf](https://crates.io/crates/lopdf) for PDF parsing. Native Rust and CLI consumers can opt into selective OCR. Also available for [Python](https://pypi.org/project/pdf-inspector/) and [Node.js](https://www.npmjs.com/package/@firecrawl/pdf-inspector/).
|
||||
|
||||
Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in under 200ms, skipping expensive OCR services for the ~54% of PDFs that don't need them.
|
||||
|
||||
@@ -117,6 +117,327 @@ let bytes = std::fs::read("document.pdf")?;
|
||||
let result = process_pdf_mem(&bytes)?;
|
||||
```
|
||||
|
||||
### Vision extension contracts
|
||||
|
||||
The native-only `vision` feature exposes the stable seam used by OCR
|
||||
integrations without selecting or embedding an inference runtime. The
|
||||
separate `model-cache` feature adds pinned artifact management:
|
||||
|
||||
- `PageRenderer` and `OcrEngine` traits;
|
||||
- renderer-neutral owned page buffers and affine pixel↔PDF transforms;
|
||||
- `OcrOptions` and opt-in `Off`/`Auto`/`Force` routing modes;
|
||||
- positioned OCR results and per-page provenance types; and
|
||||
- a versioned PP-OCRv6 Small manifest with checksum-verified, locked, atomic
|
||||
model-cache installation and explicit offline-directory overrides.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
pdf-inspector = { version = "1", features = ["vision", "model-cache"] }
|
||||
```
|
||||
|
||||
The OCR contracts preserve existing behavior by default: OCR is `Off` and
|
||||
model resolution is never reached. `ModelStore` itself does not access the
|
||||
network. The optional `model-download` feature provides an
|
||||
HTTPS downloader that streams pinned artifacts into the checksum-verified
|
||||
cache only after routing has selected OCR work. Offline consumers set an
|
||||
explicit model directory and `ModelDownloadPolicy::Offline`. Renderer-only
|
||||
consumers do not enable `model-cache` or `model-download` and therefore do not
|
||||
compile their filesystem, hashing, or HTTP dependencies.
|
||||
|
||||
```rust
|
||||
use pdf_inspector::vision::{
|
||||
ModelDownloadPolicy, ModelStore, OcrMode, OcrOptions, PP_OCR_V6_SMALL,
|
||||
};
|
||||
|
||||
let ocr = OcrOptions::new()
|
||||
.mode(OcrMode::Auto)
|
||||
.model_directory("/opt/firecrawl/models/pp-ocrv6-small")
|
||||
.model_downloads(ModelDownloadPolicy::Offline);
|
||||
// Verifies exact sizes and SHA-256 digests before an engine opens the files.
|
||||
let models = ModelStore::from_options(&ocr)?.resolve(&PP_OCR_V6_SMALL)?;
|
||||
println!("using {} at {}", models.manifest_id(), models.revision());
|
||||
```
|
||||
|
||||
### Optional native page rendering
|
||||
|
||||
The `render-pdfium` feature adds a native-only page renderer backed by
|
||||
[`firecrawl-pdfium`](https://crates.io/crates/firecrawl-pdfium). It is the
|
||||
rendering boundary for OCR pipelines; enabling it does not include an OCR
|
||||
model or change the existing extraction functions. It implies `vision`,
|
||||
and `PdfiumRenderer` implements the renderer-neutral `PageRenderer` trait.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
pdf-inspector = { version = "1", features = ["render-pdfium"] }
|
||||
```
|
||||
|
||||
PDFium is loaded at runtime and is not bundled into the crate. Set
|
||||
`PDFIUM_LIB_PATH` to the platform shared library, place that library next to
|
||||
the executable, or use another discovery route supported by
|
||||
`firecrawl-pdfium`. A load failure reports this prerequisite directly.
|
||||
|
||||
```rust
|
||||
use pdf_inspector::vision::{PdfiumRenderer, RenderOptions};
|
||||
|
||||
let renderer = PdfiumRenderer::load()?;
|
||||
let bytes = std::fs::read("document.pdf")?;
|
||||
let pages = renderer.render_pages(
|
||||
&bytes,
|
||||
&[1, 3], // 1-indexed, matching pages_needing_ocr
|
||||
None, // optional PDF password
|
||||
&RenderOptions::new().dpi(150.0),
|
||||
)?;
|
||||
|
||||
for page in pages {
|
||||
// Owned RGB pixels can leave the PDFium critical section and be sent to
|
||||
// an OCR worker. OCR pixel boxes can be mapped back to PDF coordinates.
|
||||
let rect = page.pixel_rect_to_pdf_rect(20.0, 30.0, 100.0, 24.0);
|
||||
println!("page {}: {}x{}, rect={rect:?}", page.page(), page.width(), page.height());
|
||||
}
|
||||
```
|
||||
|
||||
Browser WASM remains on the default text-only path and does not expose native
|
||||
PDFium rendering.
|
||||
|
||||
### Optional OCR engine
|
||||
|
||||
The native-only `ocr-oar` feature adds a CPU PP-OCRv6 Small implementation of
|
||||
`OcrEngine` backed by OAR and ONNX Runtime. It implies `model-cache`, but does
|
||||
not enable model auto-download, ONNX Runtime download, or PDF rendering. Model
|
||||
files remain external, must match the pinned manifest, and are opened only
|
||||
after `ModelStore` verifies their exact size and SHA-256 digest. Install an
|
||||
ONNX Runtime shared library separately and set `ORT_DYLIB_PATH` to its full
|
||||
path when it is not available through the platform library search path. The
|
||||
runtime is resolved only when an OCR engine is first constructed; clean
|
||||
`Auto` requests do not require it. The feature currently requires Rust 1.95
|
||||
or newer, matching OAR 0.9.1's MSRV.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
pdf-inspector = { version = "1", features = ["ocr-oar", "render-pdfium"] }
|
||||
```
|
||||
|
||||
Direct engine invocation is intentionally separate from extraction routing and
|
||||
native/OCR fusion:
|
||||
|
||||
```rust
|
||||
use pdf_inspector::vision::{
|
||||
ModelDownloadPolicy, ModelStore, OarOcrEngine, OcrEngine, OcrMode,
|
||||
OcrOptions, PdfiumRenderer, RenderOptions, PP_OCR_V6_SMALL,
|
||||
};
|
||||
|
||||
let options = OcrOptions::new()
|
||||
.mode(OcrMode::Force)
|
||||
.minimum_confidence(0.45)
|
||||
.model_directory("/opt/firecrawl/models/pp-ocrv6-small")
|
||||
.model_downloads(ModelDownloadPolicy::Offline);
|
||||
let models = ModelStore::from_options(&options)?.resolve(&PP_OCR_V6_SMALL)?;
|
||||
let engine = OarOcrEngine::from_models(&models)?;
|
||||
|
||||
let renderer = PdfiumRenderer::load()?;
|
||||
let bytes = std::fs::read("scan.pdf")?;
|
||||
let pages = renderer.render_pages(&bytes, &[1], None, &RenderOptions::new())?;
|
||||
let ocr_pages = engine.recognize(&pages, &options)?;
|
||||
|
||||
for span in &ocr_pages[0].spans {
|
||||
println!("{:.3}: {}", span.confidence, span.text);
|
||||
}
|
||||
```
|
||||
|
||||
The engine accepts renderer-neutral RGB, RGBA, and grayscale pages, preserves
|
||||
OAR's positioned quadrilaterals in bitmap coordinates, filters spans using
|
||||
`minimum_confidence`, and records the pinned model revision in every `OcrPage`.
|
||||
`OcrMode::Off` is rejected at the engine boundary so default options cannot run
|
||||
inference accidentally.
|
||||
|
||||
### Selective routing and lazy model acquisition
|
||||
|
||||
`route_ocr_pages` applies the existing detector/text-quality recommendations to
|
||||
the configured mode. `Auto` processes only recommended pages, `Force` processes
|
||||
all pages (or an explicit page selection), and `Off` always returns an empty
|
||||
route. `run_ocr_pages` renders only that route, checks that both dependencies
|
||||
preserve its order, and retains each bitmap's PDF transform for fusion.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
pdf-inspector = { version = "1", features = [
|
||||
"render-pdfium",
|
||||
"ocr-oar",
|
||||
"model-download",
|
||||
] }
|
||||
```
|
||||
|
||||
```rust
|
||||
use pdf_inspector::vision::{
|
||||
route_ocr_pages, run_ocr_pages, HttpModelDownloader, ModelStore,
|
||||
OarOcrEngine, OcrMode, OcrOptions, PdfiumRenderer, RenderOptions,
|
||||
PP_OCR_V6_SMALL,
|
||||
};
|
||||
|
||||
let bytes = std::fs::read("scan.pdf")?;
|
||||
let extraction = pdf_inspector::extract_pages_markdown_mem(&bytes, None)?;
|
||||
let options = OcrOptions::new().mode(OcrMode::Auto);
|
||||
let routed = route_ocr_pages(
|
||||
options.mode,
|
||||
extraction.pages.len() as u32,
|
||||
&extraction.pages_needing_ocr,
|
||||
None,
|
||||
)?;
|
||||
|
||||
if !routed.is_empty() {
|
||||
// No HTTP request or model initialization occurs before this point.
|
||||
let store = ModelStore::from_options(&options)?;
|
||||
let models = store.resolve_or_download(
|
||||
&PP_OCR_V6_SMALL,
|
||||
options.model_downloads,
|
||||
&HttpModelDownloader::default(),
|
||||
)?;
|
||||
let run = run_ocr_pages(
|
||||
&PdfiumRenderer::load()?,
|
||||
&OarOcrEngine::from_models(&models)?,
|
||||
&bytes,
|
||||
&routed,
|
||||
None,
|
||||
&RenderOptions::new(),
|
||||
&options,
|
||||
)?;
|
||||
println!("OCR processed {} pages", run.pages.len());
|
||||
}
|
||||
```
|
||||
|
||||
The downloader accepts HTTPS only, checks a declared content length, caps the
|
||||
response stream to the pinned size plus one byte, and delegates final size and
|
||||
SHA-256 verification to `ModelStore`. The store serializes installation across
|
||||
processes and publishes completed artifacts atomically. Warm caches make no
|
||||
network calls; offline mode and explicit model directories never download.
|
||||
|
||||
### OCR Markdown assembly and native fusion
|
||||
|
||||
`fuse_ocr_pages` maps OCR polygons back into PDF coordinates and sends the
|
||||
result through pdf-inspector's existing deterministic reading-order, table,
|
||||
and Markdown pipeline. Pages whose native extraction was rejected use OCR
|
||||
output. When `Force` runs on a clean native page, normalized duplicate OCR
|
||||
blocks are removed and only additional image-backed text is retained.
|
||||
|
||||
```rust
|
||||
use pdf_inspector::vision::{fuse_ocr_pages, OcrFusionOptions};
|
||||
|
||||
let fused = fuse_ocr_pages(
|
||||
&extraction.pages,
|
||||
&run,
|
||||
extraction.pages.len() as u32,
|
||||
&OcrFusionOptions::new().render_dpi(150.0),
|
||||
)?;
|
||||
|
||||
for page in &fused.pages {
|
||||
println!("{}", page.markdown);
|
||||
if page.provenance.hosted_recommended {
|
||||
eprintln!(
|
||||
"page {} needs the hosted document pipeline",
|
||||
page.page_number,
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Each page carries `Native`, `Ocr`, or `Fused` provenance, the exact OCR model
|
||||
revision, accepted-page confidence, local stage timings, and non-fatal
|
||||
warnings. A page that required OCR recommends the hosted pipeline when local
|
||||
OCR is missing, empty, or below the configurable page-confidence threshold.
|
||||
This keeps the lightweight path explicit about cases it cannot finish well.
|
||||
|
||||
### Complete OCR API
|
||||
|
||||
The `ocr` convenience feature enables the renderer, OCR engine, verified
|
||||
model acquisition, routing, and fusion layers together. It is the intended
|
||||
downstream application integration boundary; lower-level features remain
|
||||
available for consumers that bring their own renderer, model package manager,
|
||||
or engine.
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
pdf-inspector = { version = "1", features = ["ocr"] }
|
||||
```
|
||||
|
||||
```rust
|
||||
use pdf_inspector::vision::{process_pdf_with_ocr, OcrPdfOptions};
|
||||
|
||||
let result = process_pdf_with_ocr(
|
||||
"document.pdf",
|
||||
OcrPdfOptions::auto().page_numbers([1, 2, 3]),
|
||||
)?;
|
||||
|
||||
println!("{}", result.markdown);
|
||||
println!("OCR pages: {:?}", result.pages_routed_to_ocr);
|
||||
println!(
|
||||
"Hosted fallback pages: {:?}",
|
||||
result.pages_recommending_hosted,
|
||||
);
|
||||
```
|
||||
|
||||
Native extraction always runs first. In `Auto`, a clean PDF returns before
|
||||
PDFium loading, model-cache access, HTTP, or OAR initialization. Model files
|
||||
remain external and the default crate feature set remains unchanged. `Off`
|
||||
provides the same native-only behavior through the OCR result/provenance
|
||||
shape; `Force` renders every selected page. OCR uses the existing deterministic
|
||||
table, column, reading-order, and Markdown assembly path; no learned layout
|
||||
model is included.
|
||||
|
||||
The [OCR runtime setup guide](https://github.com/firecrawl/pdf-inspector/blob/main/docs/ocr-runtime.md)
|
||||
lists the pinned PDFium and ONNX Runtime builds, environment variables, model
|
||||
cache behavior, and the error boundary downstream hosted fallbacks should use.
|
||||
|
||||
For ambiguous mixed pages, `Auto` privately retains clean native fragments
|
||||
instead of discarding them when OCR is selected. After recognition it compares
|
||||
script-agnostic text quality, OCR confidence, character overlap, and material
|
||||
new coverage. Exact native text wins over a duplicate or weak OCR hypothesis;
|
||||
complementary image-backed text is fused; and pages where both candidates are
|
||||
weak recommend the hosted document pipeline. A page routed because native
|
||||
coverage appeared incomplete also recommends hosted processing when confident
|
||||
OCR only duplicates the retained fragment: the agreement preserves trustworthy
|
||||
text, but neither hypothesis proves full-page coverage. Public native-only
|
||||
extraction continues to suppress pages marked unreliable, and clean text
|
||||
documents pay no renderer or model-initialization cost.
|
||||
|
||||
In `Auto`, pages routed only for suspicious font encoding or vectorized text
|
||||
first get a bounded positioned-text probe through PDFium. A credible recovered
|
||||
text layer with sufficient geometric page coverage skips rasterization and
|
||||
model loading for that page; garbled, partial, or insubstantial recovery
|
||||
continues through OCR. Recovered tables are reflected in the same document
|
||||
metadata as tables found by the primary extractor.
|
||||
|
||||
The one-call API keeps the most recently used verified OCR engine in process.
|
||||
Long-lived workers therefore verify the pinned artifacts and build the ONNX
|
||||
sessions once, then reuse those loaded sessions across documents. The cache is
|
||||
bounded to one model configuration and keyed by normalized model/runtime paths
|
||||
plus the pinned manifest revision and artifact digests; switching the model
|
||||
directory, runtime library, or compiled manifest replaces it. An active engine
|
||||
owns the model data it already verified, so mutating artifacts in place does
|
||||
not hot-reload a running process; restart the process when intentionally
|
||||
replacing files at the same paths. CPU inference uses at most four intra-op
|
||||
threads per ONNX session so a single small page does not oversubscribe larger
|
||||
hosts, and recognizes variable-width line crops individually to avoid
|
||||
padding-heavy CPU batches. The high-level pipeline renders and fuses at most
|
||||
four routed pages at a time, bounding bitmap memory on long documents.
|
||||
|
||||
Build the CLI with the same opt-in feature:
|
||||
|
||||
```bash
|
||||
cargo install pdf-inspector --features ocr --bin pdf2md
|
||||
cargo build --release --features ocr --bin pdf2md
|
||||
pdf2md document.pdf --ocr auto --raw
|
||||
pdf2md document.pdf --ocr auto --json
|
||||
pdf2md document.pdf --ocr auto --ocr-offline --ocr-model-dir /opt/models/pp-ocrv6-small
|
||||
```
|
||||
|
||||
CLI controls include `--ocr-dpi`, `--ocr-min-confidence`,
|
||||
`--ocr-hosted-threshold`, `--select-pages`, and the existing encrypted-PDF
|
||||
`--password` option. JSON output has `schema_version: 1` and includes per-page Markdown, source/model
|
||||
provenance, confidence, timings, warnings, routed pages, and hosted-fallback
|
||||
recommendations. Page numbers in `OcrPdfResult` and its per-page provenance
|
||||
are 1-indexed, matching the PDF page numbers accepted by
|
||||
`OcrPdfOptions::page_numbers`.
|
||||
|
||||
Extract per-page Markdown (one string per page, plus document-wide layout
|
||||
metadata):
|
||||
|
||||
|
||||
Generated
+2256
-33
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,13 +1,13 @@
|
||||
[package]
|
||||
name = "pdf-inspector-napi"
|
||||
version = "1.14.0"
|
||||
version = "1.15.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
pdf-inspector = { path = ".." }
|
||||
pdf-inspector = { path = "..", features = ["ocr"] }
|
||||
napi = { version = "3.0.0", features = ["serde-json"] }
|
||||
napi-derive = "3.0.0"
|
||||
|
||||
|
||||
+52
-1
@@ -10,7 +10,8 @@ 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.
|
||||
- **Lightweight** — native Rust core via napi-rs, no ML models, no external services; ~5–6 MB platform binary, TypeScript definitions included.
|
||||
- **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.
|
||||
|
||||
## Benchmark
|
||||
|
||||
@@ -36,8 +37,42 @@ 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. See the
|
||||
[OCR runtime setup guide](https://github.com/firecrawl/pdf-inspector/blob/main/docs/ocr-runtime.md)
|
||||
for pinned downloads, supported platforms, and hosted-fallback behavior.
|
||||
|
||||
## 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.
|
||||
@@ -124,6 +159,22 @@ 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
|
||||
|
||||
+6
-6
@@ -8,12 +8,12 @@
|
||||
"@napi-rs/cli": "^3.4.1",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@firecrawl/pdf-inspector-darwin-arm64": "1.14.0",
|
||||
"@firecrawl/pdf-inspector-linux-arm64-gnu": "1.14.0",
|
||||
"@firecrawl/pdf-inspector-linux-arm64-musl": "1.14.0",
|
||||
"@firecrawl/pdf-inspector-linux-x64-gnu": "1.14.0",
|
||||
"@firecrawl/pdf-inspector-linux-x64-musl": "1.14.0",
|
||||
"@firecrawl/pdf-inspector-win32-x64-msvc": "1.14.0",
|
||||
"@firecrawl/pdf-inspector-darwin-arm64": "1.15.0",
|
||||
"@firecrawl/pdf-inspector-linux-arm64-gnu": "1.15.0",
|
||||
"@firecrawl/pdf-inspector-linux-arm64-musl": "1.15.0",
|
||||
"@firecrawl/pdf-inspector-linux-x64-gnu": "1.15.0",
|
||||
"@firecrawl/pdf-inspector-linux-x64-musl": "1.15.0",
|
||||
"@firecrawl/pdf-inspector-win32-x64-msvc": "1.15.0",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+7
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@firecrawl/pdf-inspector",
|
||||
"version": "1.14.0",
|
||||
"version": "1.15.0",
|
||||
"description": "Fast PDF classification and text extraction. Detect text-based vs scanned PDFs, extract text by region with quality checks. Native Rust performance via napi-rs.",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
@@ -52,11 +52,11 @@
|
||||
"@napi-rs/cli": "^3.4.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@firecrawl/pdf-inspector-linux-x64-gnu": "1.14.0",
|
||||
"@firecrawl/pdf-inspector-linux-x64-musl": "1.14.0",
|
||||
"@firecrawl/pdf-inspector-linux-arm64-gnu": "1.14.0",
|
||||
"@firecrawl/pdf-inspector-linux-arm64-musl": "1.14.0",
|
||||
"@firecrawl/pdf-inspector-darwin-arm64": "1.14.0",
|
||||
"@firecrawl/pdf-inspector-win32-x64-msvc": "1.14.0"
|
||||
"@firecrawl/pdf-inspector-linux-x64-gnu": "1.15.0",
|
||||
"@firecrawl/pdf-inspector-linux-x64-musl": "1.15.0",
|
||||
"@firecrawl/pdf-inspector-linux-arm64-gnu": "1.15.0",
|
||||
"@firecrawl/pdf-inspector-linux-arm64-musl": "1.15.0",
|
||||
"@firecrawl/pdf-inspector-darwin-arm64": "1.15.0",
|
||||
"@firecrawl/pdf-inspector-win32-x64-msvc": "1.15.0"
|
||||
}
|
||||
}
|
||||
|
||||
+241
@@ -27,6 +27,26 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -128,6 +148,84 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -168,6 +266,103 @@ 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),
|
||||
@@ -219,6 +414,13 @@ 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"))?;
|
||||
@@ -818,6 +1020,45 @@ 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,6 +3,7 @@ import { strict as assert } from 'assert';
|
||||
import {
|
||||
processPdf,
|
||||
processPdfAsync,
|
||||
processPdfWithOcr,
|
||||
detectPdf,
|
||||
classifyPdf,
|
||||
classifyPdfAsync,
|
||||
@@ -220,6 +221,37 @@ 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),
|
||||
|
||||
+81
-1
@@ -1,6 +1,6 @@
|
||||
"""Type stubs for pdf_inspector."""
|
||||
|
||||
from typing import Optional
|
||||
from typing import Literal, Optional
|
||||
|
||||
class PdfResult:
|
||||
"""Result of processing a PDF file."""
|
||||
@@ -27,6 +27,53 @@ 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
|
||||
@@ -114,6 +161,39 @@ 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."""
|
||||
...
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ build-backend = "maturin"
|
||||
name = "pdf-inspector"
|
||||
# Keep package versions in sync with `python3 scripts/version.py <version>`.
|
||||
# CI publishes automatically when the synchronized change lands on main.
|
||||
version = "1.14.0"
|
||||
version = "1.15.0"
|
||||
description = "Fast PDF inspection, classification, and text extraction with smart scanned vs text-based detection"
|
||||
readme = "docs/python.md"
|
||||
license = { text = "MIT" }
|
||||
|
||||
+1
-1
@@ -975,7 +975,7 @@ result = pdf_inspector.<span class="fn">process_pdf</span>(<span class="str">"do
|
||||
<script>
|
||||
(() => {
|
||||
const MAX_FILE_SIZE = 25 * 1024 * 1024;
|
||||
const WASM_MODULE_URL = "https://cdn.jsdelivr.net/npm/@firecrawl/pdf-inspector-wasm@1.14.0/pdf_inspector_wasm.js";
|
||||
const WASM_MODULE_URL = "https://cdn.jsdelivr.net/npm/@firecrawl/pdf-inspector-wasm@1.15.0/pdf_inspector_wasm.js";
|
||||
const input = document.querySelector("#pdf-input");
|
||||
const dropZone = document.querySelector("#drop-zone");
|
||||
const filePanel = document.querySelector("#demo-file");
|
||||
|
||||
+311
-6
@@ -1,6 +1,11 @@
|
||||
//! CLI tool for PDF to Markdown conversion
|
||||
|
||||
use pdf_inspector::extractor::ItemType;
|
||||
#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
|
||||
use pdf_inspector::vision::{
|
||||
process_pdf_with_ocr, ModelDownloadPolicy, OcrMode, OcrOptions, OcrPdfOptions, OcrPdfResult,
|
||||
PageContentSource, RenderOptions,
|
||||
};
|
||||
use pdf_inspector::{
|
||||
extract_text_with_positions_pages_with_password, process_pdf_with_options, LayoutComplexity,
|
||||
PdfOptions, PdfType, ProcessMode, TextItem,
|
||||
@@ -103,6 +108,146 @@ fn format_items_json(items: &[TextItem]) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
|
||||
fn optional_json_number(value: Option<f32>) -> String {
|
||||
value
|
||||
.filter(|value| value.is_finite())
|
||||
.map(|value| format!("{value:.4}"))
|
||||
.unwrap_or_else(|| "null".to_string())
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
|
||||
fn format_ocr_json(result: &OcrPdfResult) -> String {
|
||||
let routed = result
|
||||
.pages_routed_to_ocr
|
||||
.iter()
|
||||
.map(u32::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let recommended = result
|
||||
.pages_recommended_for_ocr
|
||||
.iter()
|
||||
.map(u32::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let hosted = result
|
||||
.pages_recommending_hosted
|
||||
.iter()
|
||||
.map(u32::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let pages = result
|
||||
.pages
|
||||
.iter()
|
||||
.map(|page| {
|
||||
let provenance = &page.provenance;
|
||||
let source = match provenance.source {
|
||||
PageContentSource::Native => "native",
|
||||
PageContentSource::Ocr => "ocr",
|
||||
PageContentSource::Fused => "fused",
|
||||
_ => "unknown",
|
||||
};
|
||||
let model = provenance
|
||||
.ocr_model
|
||||
.as_ref()
|
||||
.map(|model| {
|
||||
format!(
|
||||
r#"{{"name":"{}","revision":"{}"}}"#,
|
||||
json_escape(&model.name),
|
||||
json_escape(&model.revision)
|
||||
)
|
||||
})
|
||||
.unwrap_or_else(|| "null".to_string());
|
||||
let warnings = provenance
|
||||
.warnings
|
||||
.iter()
|
||||
.map(|warning| format!(r#""{}""#, json_escape(warning)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
format!(
|
||||
r#"{{"page":{},"source":"{}","markdown":"{}","ocr_model":{},"render_dpi":{},"ocr_confidence":{},"hosted_recommended":{},"timings":{{"render_ms":{},"ocr_ms":{},"assembly_ms":{}}},"warnings":[{}]}}"#,
|
||||
provenance.page_number,
|
||||
source,
|
||||
json_escape(&page.markdown),
|
||||
model,
|
||||
optional_json_number(provenance.render_dpi),
|
||||
optional_json_number(provenance.ocr_confidence),
|
||||
provenance.hosted_recommended,
|
||||
provenance.timings.render_ms,
|
||||
provenance.timings.ocr_ms,
|
||||
provenance.timings.assembly_ms,
|
||||
warnings,
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let table_pages = result
|
||||
.pages_with_tables
|
||||
.iter()
|
||||
.map(u32::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let column_pages = result
|
||||
.pages_with_columns
|
||||
.iter()
|
||||
.map(u32::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let ocr_reasons = format_ocr_reasons_by_page(&result.ocr_reasons_by_page);
|
||||
format!(
|
||||
r#"{{"schema_version":1,"page_count":{},"processing_time_ms":{},"render_time_ms":{},"ocr_time_ms":{},"pages_recommended_for_ocr":[{}],"pages_routed_to_ocr":[{}],"pages_recommending_hosted":[{}],"ocr_reasons_by_page":[{}],"is_complex":{},"pages_with_tables":[{}],"pages_with_columns":[{}],"pages":[{}],"markdown":"{}"}}"#,
|
||||
result.page_count,
|
||||
result.processing_time_ms,
|
||||
result.render_time_ms,
|
||||
result.ocr_time_ms,
|
||||
recommended,
|
||||
routed,
|
||||
hosted,
|
||||
ocr_reasons,
|
||||
result.is_complex,
|
||||
table_pages,
|
||||
column_pages,
|
||||
pages,
|
||||
json_escape(&result.markdown),
|
||||
)
|
||||
}
|
||||
|
||||
fn argument_value<'a>(args: &'a [String], name: &str) -> Result<Option<&'a str>, String> {
|
||||
args.iter()
|
||||
.position(|argument| argument == name)
|
||||
.map(|index| {
|
||||
args.get(index + 1)
|
||||
.map(String::as_str)
|
||||
.ok_or_else(|| format!("{name} requires a value"))
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn format_ocr_error_json(error: &str) -> String {
|
||||
format!(r#"{{"schema_version":1,"error":"{}"}}"#, json_escape(error))
|
||||
}
|
||||
|
||||
fn exit_ocr_error(error: &str, json_output: bool) -> ! {
|
||||
if json_output {
|
||||
println!("{}", format_ocr_error_json(error));
|
||||
} else {
|
||||
eprintln!("Error: {error}");
|
||||
}
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
|
||||
fn float_argument(args: &[String], name: &str, default: f32) -> Result<f32, String> {
|
||||
argument_value(args, name)?
|
||||
.map(|value| {
|
||||
value
|
||||
.parse::<f32>()
|
||||
.map_err(|_| format!("{name} requires a number, got {value:?}"))
|
||||
})
|
||||
.transpose()
|
||||
.map(|value| value.unwrap_or(default))
|
||||
}
|
||||
|
||||
fn extract_items_json(
|
||||
pdf_path: &str,
|
||||
page_filter: Option<&HashSet<u32>>,
|
||||
@@ -114,7 +259,9 @@ fn extract_items_json(
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{extract_items_json, format_items_json};
|
||||
use super::{extract_items_json, format_items_json, format_ocr_error_json};
|
||||
#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
|
||||
use super::{format_ocr_json, process_pdf_with_ocr, OcrPdfOptions};
|
||||
use pdf_inspector::extractor::ItemType;
|
||||
use pdf_inspector::TextItem;
|
||||
|
||||
@@ -164,6 +311,27 @@ mod tests {
|
||||
"decrypted item JSON should contain fixture text, got {json}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
|
||||
#[test]
|
||||
fn ocr_json_has_a_versioned_stable_envelope() {
|
||||
let result =
|
||||
process_pdf_with_ocr("tests/fixtures/thermo-freon12.pdf", OcrPdfOptions::new())
|
||||
.unwrap();
|
||||
let json = format_ocr_json(&result);
|
||||
|
||||
assert!(json.starts_with(r#"{"schema_version":1,"page_count":3,"#));
|
||||
assert!(json.contains(r#""page":1,"source":"native""#));
|
||||
assert!(!json.contains("layout_ms"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ocr_json_errors_use_the_same_versioned_envelope() {
|
||||
assert_eq!(
|
||||
format_ocr_error_json("bad \"value\""),
|
||||
r#"{"schema_version":1,"error":"bad \"value\""}"#
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a page specification like "1,3,5-10,20" into a HashSet of page numbers.
|
||||
@@ -242,6 +410,12 @@ fn main() {
|
||||
eprintln!(" --password PW Password for an encrypted PDF");
|
||||
eprintln!(" --detect-only Only detect PDF type (no extraction)");
|
||||
eprintln!(" --analyze Detect + extract + layout analysis (no markdown)");
|
||||
eprintln!(" --ocr MODE OCR mode: off, auto, or force (requires feature `ocr`)");
|
||||
eprintln!(" --ocr-dpi N OCR render resolution (default: 150)");
|
||||
eprintln!(" --ocr-min-confidence N Drop OCR spans below N (default: 0)");
|
||||
eprintln!(" --ocr-hosted-threshold N Recommend hosted parsing below N (default: 0.5)");
|
||||
eprintln!(" --ocr-model-dir DIR Use a package-managed local model directory");
|
||||
eprintln!(" --ocr-offline Never download missing OCR models");
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
@@ -253,6 +427,10 @@ fn main() {
|
||||
let page_numbers = args.iter().any(|a| a == "--pages");
|
||||
let detect_only = args.iter().any(|a| a == "--detect-only");
|
||||
let analyze = args.iter().any(|a| a == "--analyze");
|
||||
let ocr_mode_argument = argument_value(&args, "--ocr").unwrap_or_else(|error| {
|
||||
eprintln!("Error: {error}");
|
||||
process::exit(1);
|
||||
});
|
||||
|
||||
// Parse --password value
|
||||
let password = args.iter().position(|a| a == "--password").map(|i| {
|
||||
@@ -283,6 +461,138 @@ fn main() {
|
||||
})
|
||||
});
|
||||
|
||||
let output_file = args
|
||||
.get(2)
|
||||
.filter(|a| !a.starts_with("--"))
|
||||
.map(|s| s.as_str());
|
||||
|
||||
let has_ocr_only_option = [
|
||||
"--ocr-dpi",
|
||||
"--ocr-min-confidence",
|
||||
"--ocr-hosted-threshold",
|
||||
"--ocr-model-dir",
|
||||
"--ocr-offline",
|
||||
]
|
||||
.iter()
|
||||
.any(|option| args.iter().any(|argument| argument == option));
|
||||
if ocr_mode_argument.is_none() && has_ocr_only_option {
|
||||
exit_ocr_error(
|
||||
"OCR options require --ocr off, --ocr auto, or --ocr force",
|
||||
json_output,
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(mode) = ocr_mode_argument {
|
||||
if items_json_output || detect_only || analyze {
|
||||
exit_ocr_error(
|
||||
"--ocr cannot be combined with --items-json, --detect-only, or --analyze",
|
||||
json_output,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(all(feature = "ocr", not(target_arch = "wasm32"))))]
|
||||
{
|
||||
let _ = mode;
|
||||
exit_ocr_error(
|
||||
"this pdf2md build does not include OCR; rebuild with --features ocr",
|
||||
json_output,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
|
||||
{
|
||||
let mode = match mode {
|
||||
"off" => OcrMode::Off,
|
||||
"auto" => OcrMode::Auto,
|
||||
"force" => OcrMode::Force,
|
||||
value => {
|
||||
exit_ocr_error(
|
||||
&format!("invalid --ocr mode {value:?}; expected off, auto, or force"),
|
||||
json_output,
|
||||
);
|
||||
}
|
||||
};
|
||||
let dpi = float_argument(&args, "--ocr-dpi", 150.0).unwrap_or_else(|error| {
|
||||
exit_ocr_error(&error, json_output);
|
||||
});
|
||||
let minimum_confidence = float_argument(&args, "--ocr-min-confidence", 0.0)
|
||||
.unwrap_or_else(|error| {
|
||||
exit_ocr_error(&error, json_output);
|
||||
});
|
||||
let hosted_threshold = float_argument(&args, "--ocr-hosted-threshold", 0.5)
|
||||
.unwrap_or_else(|error| {
|
||||
exit_ocr_error(&error, json_output);
|
||||
});
|
||||
let model_directory =
|
||||
argument_value(&args, "--ocr-model-dir").unwrap_or_else(|error| {
|
||||
exit_ocr_error(&error, json_output);
|
||||
});
|
||||
|
||||
let mut ocr = OcrOptions::new()
|
||||
.mode(mode)
|
||||
.minimum_confidence(minimum_confidence);
|
||||
if let Some(directory) = model_directory {
|
||||
ocr = ocr.model_directory(directory);
|
||||
}
|
||||
if args.iter().any(|argument| argument == "--ocr-offline") {
|
||||
ocr = ocr.model_downloads(ModelDownloadPolicy::Offline);
|
||||
}
|
||||
let mut markdown = pdf_inspector::MarkdownOptions::default();
|
||||
if compact_output {
|
||||
markdown.profile = pdf_inspector::MarkdownProfile::Compact;
|
||||
}
|
||||
markdown.include_page_numbers = page_numbers;
|
||||
let mut pdf_options = OcrPdfOptions::new()
|
||||
.render(RenderOptions::new().dpi(dpi))
|
||||
.ocr(ocr)
|
||||
.markdown(markdown)
|
||||
.hosted_recommendation_confidence(hosted_threshold);
|
||||
if let Some(pages) = page_filter.clone() {
|
||||
pdf_options = pdf_options.page_numbers(pages);
|
||||
}
|
||||
if let Some(password) = password.clone() {
|
||||
pdf_options = pdf_options.password(password);
|
||||
}
|
||||
|
||||
match process_pdf_with_ocr(pdf_path, pdf_options) {
|
||||
Ok(result) => {
|
||||
if json_output {
|
||||
println!("{}", format_ocr_json(&result));
|
||||
} else if raw_output {
|
||||
print!("{}", result.markdown);
|
||||
} else {
|
||||
eprintln!("PDF to Markdown Conversion (OCR)");
|
||||
eprintln!("======================================");
|
||||
eprintln!("File: {pdf_path}");
|
||||
eprintln!("Pages: {}", result.page_count);
|
||||
eprintln!("Pages routed to OCR: {:?}", result.pages_routed_to_ocr);
|
||||
if !result.pages_recommending_hosted.is_empty() {
|
||||
eprintln!(
|
||||
"Hosted parsing recommended for pages: {:?}",
|
||||
result.pages_recommending_hosted
|
||||
);
|
||||
}
|
||||
eprintln!("Processing time: {}ms", result.processing_time_ms);
|
||||
if let Some(output) = output_file {
|
||||
fs::write(output, &result.markdown)
|
||||
.expect("Failed to write output file");
|
||||
eprintln!("Markdown written to: {output}");
|
||||
} else {
|
||||
eprintln!();
|
||||
eprintln!("--- Markdown Output ---");
|
||||
eprintln!();
|
||||
print!("{}", result.markdown);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
exit_ocr_error(&error.to_string(), json_output);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if items_json_output {
|
||||
match extract_items_json(pdf_path, page_filter.as_ref(), password.as_deref()) {
|
||||
Ok(json) => println!("{}", json),
|
||||
@@ -294,11 +604,6 @@ fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
let output_file = args
|
||||
.get(2)
|
||||
.filter(|a| !a.starts_with("--"))
|
||||
.map(|s| s.as_str());
|
||||
|
||||
let process_mode = if detect_only {
|
||||
ProcessMode::DetectOnly
|
||||
} else if analyze {
|
||||
|
||||
+248
-36
@@ -403,8 +403,15 @@ pub(crate) fn detect_from_document(
|
||||
&& !(analysis.has_decodable_text_fonts && analysis.text_operator_count >= 10);
|
||||
let looks_like_scan =
|
||||
analysis.image_count <= 1 && analysis.text_operator_count < 50 && alphanum_low;
|
||||
// A template-image page below the `pages_with_text` floor is
|
||||
// a scan with incidental chrome (masthead, stamp, date line)
|
||||
// even when that chrome is diverse, decodable text — keep
|
||||
// this in sync with `page_ocr_signals`.
|
||||
let sparse_text_over_scan = analysis.has_template_image
|
||||
&& analysis.text_operator_count < config.min_text_ops_per_page.max(10);
|
||||
if (analysis.has_template_image && looks_like_scan)
|
||||
|| analysis.has_vector_text
|
||||
|| sparse_text_over_scan
|
||||
|| (analysis.text_operator_count < config.min_text_ops_per_page
|
||||
&& analysis.has_images)
|
||||
{
|
||||
@@ -1382,7 +1389,13 @@ fn scan_content_for_text_operators(
|
||||
let is_word_end =
|
||||
|pos: usize| -> bool { pos + 1 >= content.len() || content[pos + 1].is_ascii_whitespace() };
|
||||
|
||||
// Simple state machine to find operators
|
||||
// Simple state machine to find operators.
|
||||
// Each Tj/TJ/Tf lookback stops at the previous text/font operator so a
|
||||
// malformed `] TJ` (no `[`) cannot rescan the entire prefix — that was
|
||||
// quadratic in the number of operators.
|
||||
// `Tj`/`TJ` are only counted when the preceding token closes a string or
|
||||
// array (')', '>', ']'), so `Tj` inside `(Hello Tj World)` cannot pin the floor.
|
||||
let mut operand_floor = 0usize;
|
||||
let mut i = 0;
|
||||
while i < content.len() {
|
||||
let b = content[i];
|
||||
@@ -1392,14 +1405,15 @@ fn scan_content_for_text_operators(
|
||||
let next = content[i + 1];
|
||||
if next == b'j' || next == b'J' {
|
||||
// Verify it's an operator (followed by whitespace or newline)
|
||||
if i + 2 >= content.len()
|
||||
if (i + 2 >= content.len()
|
||||
|| content[i + 2].is_ascii_whitespace()
|
||||
|| content[i + 2] == b'\n'
|
||||
|| content[i + 2] == b'\r'
|
||||
|| content[i + 2] == b'\r')
|
||||
&& preceding_operand_closer(content, i, operand_floor)
|
||||
{
|
||||
text_ops += 1;
|
||||
// Scan backward for text string operand to collect unique chars
|
||||
collect_text_chars_before(content, i, unique_chars);
|
||||
collect_text_chars_before(content, i, unique_chars, operand_floor);
|
||||
operand_floor = i;
|
||||
}
|
||||
} else if next == b'f' {
|
||||
// Tf = set font operator
|
||||
@@ -1415,12 +1429,10 @@ fn scan_content_for_text_operators(
|
||||
|| content[i + 2] == b'<'
|
||||
|| content[i + 2] == b'/'
|
||||
{
|
||||
font_changes += 1;
|
||||
// Extract the font name operand preceding the size + Tf.
|
||||
// Pattern: /FontName <size> Tf
|
||||
// Scan backward past the size number and whitespace to find /Name.
|
||||
if let Some(name) = extract_font_name_before_tf(content, i) {
|
||||
if let Some(name) = extract_font_name_before_tf(content, i, operand_floor) {
|
||||
used_font_names.insert(name);
|
||||
font_changes += 1;
|
||||
operand_floor = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1466,6 +1478,20 @@ fn scan_content_for_text_operators(
|
||||
(text_ops, image_count, path_ops, font_changes)
|
||||
}
|
||||
|
||||
/// True when the token before `op_pos` (skipping whitespace, not crossing
|
||||
/// `floor`) is a string/array closer. Used so `Tj` inside `(Hello Tj World)`
|
||||
/// is not treated as an operator.
|
||||
fn preceding_operand_closer(content: &[u8], op_pos: usize, floor: usize) -> bool {
|
||||
let mut j = op_pos;
|
||||
while j > floor {
|
||||
j -= 1;
|
||||
if !content[j].is_ascii_whitespace() {
|
||||
return matches!(content[j], b')' | b'>' | b']');
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Extract the font name operand from content stream bytes preceding a Tf operator.
|
||||
///
|
||||
/// The Tf operator syntax is: `/FontName size Tf`
|
||||
@@ -1473,25 +1499,27 @@ fn scan_content_for_text_operators(
|
||||
/// whitespace to find the `/Name` token.
|
||||
///
|
||||
/// Returns the font name bytes (without the leading `/`), e.g. `b"F1"` for `/F1`.
|
||||
fn extract_font_name_before_tf(content: &[u8], tf_pos: usize) -> Option<Vec<u8>> {
|
||||
/// `floor` is the start of the previous text/font operator (or 0); lookback
|
||||
/// must not cross it.
|
||||
fn extract_font_name_before_tf(content: &[u8], tf_pos: usize, floor: usize) -> Option<Vec<u8>> {
|
||||
// Scan backward past whitespace before "Tf"
|
||||
let mut j = tf_pos;
|
||||
while j > 0 && content[j - 1].is_ascii_whitespace() {
|
||||
while j > floor && content[j - 1].is_ascii_whitespace() {
|
||||
j -= 1;
|
||||
}
|
||||
// Scan backward past the size number (digits, '.', '-')
|
||||
while j > 0
|
||||
while j > floor
|
||||
&& (content[j - 1].is_ascii_digit() || content[j - 1] == b'.' || content[j - 1] == b'-')
|
||||
{
|
||||
j -= 1;
|
||||
}
|
||||
// Scan backward past whitespace between font name and size
|
||||
while j > 0 && content[j - 1].is_ascii_whitespace() {
|
||||
while j > floor && content[j - 1].is_ascii_whitespace() {
|
||||
j -= 1;
|
||||
}
|
||||
// Now j should point just after the font name. Scan backward to find '/'.
|
||||
let name_end = j;
|
||||
while j > 0 && content[j - 1] != b'/' {
|
||||
while j > floor && content[j - 1] != b'/' {
|
||||
// Font names consist of regular characters (not whitespace, not delimiters)
|
||||
if content[j - 1].is_ascii_whitespace() || content[j - 1] == b'(' || content[j - 1] == b')'
|
||||
{
|
||||
@@ -1499,7 +1527,7 @@ fn extract_font_name_before_tf(content: &[u8], tf_pos: usize) -> Option<Vec<u8>>
|
||||
}
|
||||
j -= 1;
|
||||
}
|
||||
if j == 0 || content[j - 1] != b'/' {
|
||||
if j <= floor || content[j - 1] != b'/' {
|
||||
return None;
|
||||
}
|
||||
// j-1 is the '/', font name is content[j..name_end]
|
||||
@@ -1514,16 +1542,24 @@ fn extract_font_name_before_tf(content: &[u8], tf_pos: usize) -> Option<Vec<u8>>
|
||||
/// and collect unique non-whitespace bytes from it.
|
||||
///
|
||||
/// Handles both literal strings `(...)` and hex strings `<...>`.
|
||||
fn collect_text_chars_before(content: &[u8], op_pos: usize, unique_chars: &mut HashSet<u8>) {
|
||||
/// `floor` is the start of the previous text/font operator (or 0); lookback
|
||||
/// must not cross it, or a missing `[` before `TJ` rescans the whole prefix.
|
||||
fn collect_text_chars_before(
|
||||
content: &[u8],
|
||||
op_pos: usize,
|
||||
unique_chars: &mut HashSet<u8>,
|
||||
floor: usize,
|
||||
) {
|
||||
// Walk backward past whitespace to find the closing delimiter
|
||||
let mut j = op_pos;
|
||||
while j > 0 {
|
||||
while j > floor {
|
||||
j -= 1;
|
||||
if !content[j].is_ascii_whitespace() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if j == 0 {
|
||||
// All whitespace, or we landed on the previous operator token.
|
||||
if j == floor {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1533,7 +1569,7 @@ fn collect_text_chars_before(content: &[u8], op_pos: usize, unique_chars: &mut H
|
||||
// Literal string: scan backward for matching '('
|
||||
let mut depth = 1i32;
|
||||
let mut k = j;
|
||||
while k > 0 && depth > 0 {
|
||||
while k > floor && depth > 0 {
|
||||
k -= 1;
|
||||
match content[k] {
|
||||
b')' if k == 0 || content[k - 1] != b'\\' => depth += 1,
|
||||
@@ -1552,7 +1588,7 @@ fn collect_text_chars_before(content: &[u8], op_pos: usize, unique_chars: &mut H
|
||||
} else if closing == b'>' {
|
||||
// Hex string: scan backward for '<'
|
||||
let mut k = j;
|
||||
while k > 0 {
|
||||
while k > floor {
|
||||
k -= 1;
|
||||
if content[k] == b'<' {
|
||||
break;
|
||||
@@ -1582,7 +1618,7 @@ fn collect_text_chars_before(content: &[u8], op_pos: usize, unique_chars: &mut H
|
||||
} else if closing == b']' {
|
||||
// TJ array: scan backward for '[' and collect from all strings inside
|
||||
let mut k = j;
|
||||
while k > 0 {
|
||||
while k > floor {
|
||||
k -= 1;
|
||||
if content[k] == b'[' {
|
||||
break;
|
||||
@@ -1766,17 +1802,24 @@ pub(crate) fn analyze_page_images(doc: &Document, page_id: ObjectId) -> (bool, u
|
||||
/// low alphanumeric diversity in raw string operands (unless decodable
|
||||
/// CID/ToUnicode fonts explain that away) — the gate used for
|
||||
/// `pages_with_template_images` and Mixed-type per-page routing.
|
||||
/// 2. Insufficient real text volume, using `DetectionConfig::default()`'s
|
||||
/// `min_text_ops_per_page` (3) — the same threshold Mixed-type per-page
|
||||
/// routing applies via `text_operator_count < config.min_text_ops_per_page
|
||||
/// && has_images` (simplified here since a template image implies
|
||||
/// `has_images`). Deliberately *not* the higher `effective_min_ops`
|
||||
/// floor (`min_text_ops_per_page.max(10)`) that whole-document
|
||||
/// `PdfType::ImageBased`/`Scanned` classification uses for
|
||||
/// `pages_with_text` — that's a cross-page aggregate decision this
|
||||
/// per-page function has no way to replicate exactly, and the lower
|
||||
/// per-page threshold is the one a single page's own signals can
|
||||
/// actually agree with.
|
||||
/// 2. Insufficient real text volume, using the same `effective_min_ops`
|
||||
/// floor (`min_text_ops_per_page.max(10)`) that `pages_with_text`
|
||||
/// applies to image-bearing pages. That floor is a per-page judgment,
|
||||
/// not part of the cross-page aggregate: classification counts a
|
||||
/// template-image page with fewer ops as textless and routes it to OCR,
|
||||
/// so this function must agree. The lower bare threshold (3) let a
|
||||
/// full-page scan carrying a small native masthead — a newspaper
|
||||
/// header, stamp, or date line of ~4 diverse, decodable text ops —
|
||||
/// extract as "a text page" here while whole-document classification
|
||||
/// called the same page scanned, silently dropping the page body from
|
||||
/// OCR routing. `alphanum_low` can't catch that case: masthead chrome
|
||||
/// is real text, so its byte diversity is high.
|
||||
///
|
||||
/// This function always evaluates against `DetectionConfig::default()` —
|
||||
/// it has no config parameter, and the per-page extraction path that calls
|
||||
/// it never carries one. A caller passing a custom `min_text_ops_per_page`
|
||||
/// to `detect_from_document` affects whole-document detection only; the
|
||||
/// two paths agree under the default configuration.
|
||||
///
|
||||
/// `has_vector_text` is true when a page has vector-outlined text (glyphs
|
||||
/// drawn as paths rather than shown via text-showing operators) —
|
||||
@@ -1798,7 +1841,7 @@ pub(crate) fn page_ocr_signals(doc: &Document, page_id: ObjectId) -> (bool, bool
|
||||
let looks_like_scan =
|
||||
analysis.image_count <= 1 && analysis.text_operator_count < 50 && alphanum_low;
|
||||
let insufficient_text =
|
||||
analysis.text_operator_count < DetectionConfig::default().min_text_ops_per_page;
|
||||
analysis.text_operator_count < DetectionConfig::default().min_text_ops_per_page.max(10);
|
||||
looks_like_scan || insufficient_text
|
||||
};
|
||||
|
||||
@@ -2014,6 +2057,51 @@ mod tests {
|
||||
assert_eq!(imgs3, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_content_successive_tj_collects_each_operand() {
|
||||
// Lookback is floored at the previous Tj/TJ/Tf so later operators must
|
||||
// still see their own operands.
|
||||
let content = b"[(Hello)] TJ [(World)] TJ (More) Tj";
|
||||
let mut uchars = HashSet::new();
|
||||
let (ops, _, _, _) =
|
||||
scan_content_for_text_operators(content, &mut uchars, &mut HashSet::new());
|
||||
assert_eq!(ops, 3);
|
||||
for &ch in b"HeloWrdM" {
|
||||
assert!(uchars.contains(&ch), "missing char {}", ch as char);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_content_tj_inside_literal_is_not_an_operator() {
|
||||
// `Tj` followed by space inside a literal must not count as an operator
|
||||
// or pin the lookback floor; the real `Tj` still collects the string.
|
||||
let content = b"BT (Hello Tj World) Tj ET";
|
||||
let mut uchars = HashSet::new();
|
||||
let (ops, _, _, _) =
|
||||
scan_content_for_text_operators(content, &mut uchars, &mut HashSet::new());
|
||||
assert_eq!(ops, 1);
|
||||
for &ch in b"HeloTjWrd" {
|
||||
assert!(uchars.contains(&ch), "missing char {}", ch as char);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_content_malformed_tj_lookback_stays_linear() {
|
||||
// `] TJ` with no `[` used to walk the entire prefix for every operator
|
||||
// (quadratic). 30k repeats is enough that a prefix rescan would dominate
|
||||
// the test runtime; with the floor it is a single linear pass.
|
||||
let n = 30_000usize;
|
||||
let mut content = Vec::with_capacity(n * 5);
|
||||
for _ in 0..n {
|
||||
content.extend_from_slice(b"] TJ\n");
|
||||
}
|
||||
let mut uchars = HashSet::new();
|
||||
let (ops, _, _, _) =
|
||||
scan_content_for_text_operators(&content, &mut uchars, &mut HashSet::new());
|
||||
assert_eq!(ops, n as u32);
|
||||
assert!(uchars.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_dominated_detection() {
|
||||
// Do operators are no longer counted as images by scan_content_for_text_operators.
|
||||
@@ -2772,14 +2860,14 @@ mod tests {
|
||||
fn test_extract_font_name_basic() {
|
||||
// Standard pattern: /F1 12 Tf
|
||||
let content = b"/F1 12 Tf";
|
||||
let name = extract_font_name_before_tf(content, 6); // 'T' is at index 6
|
||||
let name = extract_font_name_before_tf(content, 6, 0); // 'T' is at index 6
|
||||
assert_eq!(name, Some(b"F1".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_font_name_long_name() {
|
||||
let content = b"/ArialMT-Bold 9.5 Tf";
|
||||
let name = extract_font_name_before_tf(content, 18);
|
||||
let name = extract_font_name_before_tf(content, 18, 0);
|
||||
assert_eq!(name, Some(b"ArialMT-Bold".to_vec()));
|
||||
}
|
||||
|
||||
@@ -2921,6 +3009,130 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- masthead-over-scan tests: template image + sparse chrome ----------
|
||||
|
||||
/// Builds a page whose only image is a full-page scan inside a Form
|
||||
/// XObject, plus `masthead_ops` native text-show ops of diverse,
|
||||
/// decodable chrome (newspaper masthead / date line style).
|
||||
fn masthead_scan_page(masthead_lines: &[&str]) -> (Document, ObjectId) {
|
||||
use lopdf::dictionary;
|
||||
let mut doc = Document::with_version("1.4");
|
||||
let pages_id = doc.new_object_id();
|
||||
let page_id = doc.new_object_id();
|
||||
|
||||
let image_id = doc.add_object(Object::Stream(lopdf::Stream::new(
|
||||
dictionary! {
|
||||
"Type" => "XObject",
|
||||
"Subtype" => Object::Name(b"Image".to_vec()),
|
||||
"Width" => Object::Integer(1500),
|
||||
"Height" => Object::Integer(2383),
|
||||
},
|
||||
Vec::new(),
|
||||
)));
|
||||
let form_id = doc.add_object(Object::Stream(lopdf::Stream::new(
|
||||
dictionary! {
|
||||
"Type" => "XObject",
|
||||
"Subtype" => Object::Name(b"Form".to_vec()),
|
||||
"Resources" => dictionary! {
|
||||
"XObject" => dictionary! {
|
||||
"Im0" => Object::Reference(image_id),
|
||||
},
|
||||
},
|
||||
},
|
||||
b"1500 0 0 2383 0 0 cm /Im0 Do".to_vec(),
|
||||
)));
|
||||
let font_id = doc.add_object(dictionary! {
|
||||
"Type" => "Font",
|
||||
"Subtype" => Object::Name(b"Type1".to_vec()),
|
||||
"BaseFont" => Object::Name(b"Helvetica".to_vec()),
|
||||
});
|
||||
|
||||
let mut content = b"q /Fm0 Do Q BT /F1 12 Tf ".to_vec();
|
||||
for line in masthead_lines {
|
||||
content.extend_from_slice(format!("({line}) Tj ").as_bytes());
|
||||
}
|
||||
content.extend_from_slice(b"ET");
|
||||
let content_id =
|
||||
doc.add_object(Object::Stream(lopdf::Stream::new(dictionary! {}, content)));
|
||||
|
||||
doc.objects.insert(
|
||||
page_id,
|
||||
Object::Dictionary(dictionary! {
|
||||
"Type" => "Page",
|
||||
"Parent" => Object::Reference(pages_id),
|
||||
"MediaBox" => vec![0.into(), 0.into(), 1500.into(), 2383.into()],
|
||||
"Resources" => dictionary! {
|
||||
"Font" => dictionary! { "F1" => Object::Reference(font_id) },
|
||||
"XObject" => dictionary! { "Fm0" => Object::Reference(form_id) },
|
||||
},
|
||||
"Contents" => Object::Reference(content_id),
|
||||
}),
|
||||
);
|
||||
doc.objects.insert(
|
||||
pages_id,
|
||||
Object::Dictionary(dictionary! {
|
||||
"Type" => "Pages",
|
||||
"Kids" => vec![Object::Reference(page_id)],
|
||||
"Count" => Object::Integer(1),
|
||||
}),
|
||||
);
|
||||
(doc, page_id)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_masthead_over_form_wrapped_scan_needs_ocr() {
|
||||
// A full-page scan wrapped in a Form XObject with ~4 ops of real,
|
||||
// diverse masthead text. `alphanum_low` can't flag it (the chrome is
|
||||
// genuine text), so the sparse-text floor must: without OCR the page
|
||||
// body is silently lost while classification calls the page scanned.
|
||||
let (doc, page_id) = masthead_scan_page(&[
|
||||
"18",
|
||||
"FINANCIAL EXPRESS",
|
||||
"WWW.FINANCIALEXPRESS.COM",
|
||||
"FRIDAY, DECEMBER 13, 2024",
|
||||
]);
|
||||
let analysis = analyze_page_content(&doc, page_id);
|
||||
assert!(
|
||||
analysis.has_template_image,
|
||||
"sanity: full-page image inside the form must be found"
|
||||
);
|
||||
assert!(
|
||||
analysis.unique_alphanum_chars >= 10,
|
||||
"sanity: masthead text is diverse, alphanum_low cannot fire"
|
||||
);
|
||||
let (needs_ocr, _) = page_ocr_signals(&doc, page_id);
|
||||
assert!(
|
||||
needs_ocr,
|
||||
"template image + text below the pages_with_text floor is a scan"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_text_page_over_background_image_stays_native() {
|
||||
// Counterpart: a real text page over a full-page background image
|
||||
// (letterhead/watermark) has enough text ops to clear the
|
||||
// `pages_with_text` floor and must NOT be routed to OCR.
|
||||
let lines: Vec<String> = (0..12)
|
||||
.map(|i| format!("Paragraph line {i} with ordinary body text"))
|
||||
.collect();
|
||||
let refs: Vec<&str> = lines.iter().map(String::as_str).collect();
|
||||
let (doc, page_id) = masthead_scan_page(&refs);
|
||||
let analysis = analyze_page_content(&doc, page_id);
|
||||
assert!(
|
||||
analysis.has_template_image,
|
||||
"sanity: background image found"
|
||||
);
|
||||
assert!(
|
||||
analysis.text_operator_count >= 10,
|
||||
"sanity: body text clears the floor"
|
||||
);
|
||||
let (needs_ocr, _) = page_ocr_signals(&doc, page_id);
|
||||
assert!(
|
||||
!needs_ocr,
|
||||
"a text page with a background image must stay native"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------- P2 tests: Form XObject font traversal ----------
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
//! Bounded content-stream decoding.
|
||||
//!
|
||||
//! `lopdf::content::Content::decode` materializes every operator before any
|
||||
//! caller can apply a limit. A compact page of `q Q` pairs can therefore
|
||||
//! allocate hundreds of megabytes and abort. Count operators first (without
|
||||
//! allocating `Operation` objects) and skip decode when the cap is exceeded.
|
||||
|
||||
use crate::PdfError;
|
||||
use lopdf::content::Content;
|
||||
|
||||
/// Maximum content-stream operators decoded for a page or a single Form
|
||||
/// XObject. Matches the previous post-decode skip threshold.
|
||||
pub(crate) const MAX_PAGE_OPERATIONS: usize = 1_000_000;
|
||||
|
||||
/// Decode `data` unless it contains more than `max_operations` operators.
|
||||
///
|
||||
/// Returns `Ok(None)` when the stream exceeds the cap, so callers can skip
|
||||
/// extraction without first allocating the operation vector.
|
||||
pub(crate) fn decode_content_bounded(
|
||||
data: &[u8],
|
||||
max_operations: usize,
|
||||
) -> Result<Option<Content>, PdfError> {
|
||||
if content_exceeds_operation_limit(data, max_operations) {
|
||||
return Ok(None);
|
||||
}
|
||||
Content::decode(data)
|
||||
.map(Some)
|
||||
.map_err(|e| PdfError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
fn content_exceeds_operation_limit(data: &[u8], max_operations: usize) -> bool {
|
||||
count_content_operators(data, max_operations.saturating_add(1)) > max_operations
|
||||
}
|
||||
|
||||
/// Count operators using the same token rules as lopdf's content parser,
|
||||
/// stopping at `limit`. Does not allocate `Operation` / `Object` values.
|
||||
fn count_content_operators(data: &[u8], limit: usize) -> usize {
|
||||
let mut i = 0;
|
||||
let mut count = 0;
|
||||
while i < data.len() && count < limit {
|
||||
skip_content_space(data, &mut i);
|
||||
if i >= data.len() {
|
||||
break;
|
||||
}
|
||||
if data[i] == b'%' {
|
||||
skip_comment(data, &mut i);
|
||||
continue;
|
||||
}
|
||||
match data[i] {
|
||||
b'(' => i = skip_literal_string(data, i),
|
||||
b'<' => {
|
||||
if data.get(i + 1) == Some(&b'<') {
|
||||
i += 2;
|
||||
} else {
|
||||
i = skip_hex_string(data, i);
|
||||
}
|
||||
}
|
||||
b'>' => {
|
||||
i += 1;
|
||||
if data.get(i) == Some(&b'>') {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
b'[' | b']' => i += 1,
|
||||
b'/' => skip_name(data, &mut i),
|
||||
b'+' | b'-' | b'.' => skip_number(data, &mut i),
|
||||
b if b.is_ascii_digit() => skip_number(data, &mut i),
|
||||
b if is_operator_byte(b) => {
|
||||
let start = i;
|
||||
i += 1;
|
||||
while i < data.len() && is_operator_byte(data[i]) {
|
||||
i += 1;
|
||||
}
|
||||
let token = &data[start..i];
|
||||
if token == b"true" || token == b"false" || token == b"null" {
|
||||
continue;
|
||||
}
|
||||
count += 1;
|
||||
if token == b"BI" && (i >= data.len() || is_content_space(data[i])) {
|
||||
i = skip_inline_image_after_bi(data, i);
|
||||
}
|
||||
}
|
||||
_ => i += 1,
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
fn is_content_space(b: u8) -> bool {
|
||||
// PDF whitespace (ISO 32000): NUL, tab, LF, FF, CR, space. Names must
|
||||
// stop on these so a following operator is not absorbed into `/Name`.
|
||||
matches!(b, b'\0' | b'\t' | b'\n' | b'\x0c' | b'\r' | b' ')
|
||||
}
|
||||
|
||||
fn is_operator_byte(b: u8) -> bool {
|
||||
b.is_ascii_alphabetic() || matches!(b, b'*' | b'\'' | b'"')
|
||||
}
|
||||
|
||||
fn is_delimiter(b: u8) -> bool {
|
||||
matches!(
|
||||
b,
|
||||
b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'
|
||||
)
|
||||
}
|
||||
|
||||
fn skip_content_space(data: &[u8], i: &mut usize) {
|
||||
while *i < data.len() && is_content_space(data[*i]) {
|
||||
*i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn skip_comment(data: &[u8], i: &mut usize) {
|
||||
while *i < data.len() && data[*i] != b'\n' && data[*i] != b'\r' {
|
||||
*i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn skip_literal_string(data: &[u8], mut i: usize) -> usize {
|
||||
let mut depth = 1i32;
|
||||
i += 1;
|
||||
while i < data.len() && depth > 0 {
|
||||
match data[i] {
|
||||
b'\\' => {
|
||||
i += 1;
|
||||
if i < data.len() {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
b'(' => {
|
||||
depth += 1;
|
||||
i += 1;
|
||||
}
|
||||
b')' => {
|
||||
depth -= 1;
|
||||
i += 1;
|
||||
}
|
||||
_ => i += 1,
|
||||
}
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
fn skip_hex_string(data: &[u8], mut i: usize) -> usize {
|
||||
i += 1;
|
||||
while i < data.len() && data[i] != b'>' {
|
||||
i += 1;
|
||||
}
|
||||
if i < data.len() {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
fn skip_name(data: &[u8], i: &mut usize) {
|
||||
*i += 1;
|
||||
while *i < data.len() && !is_content_space(data[*i]) && !is_delimiter(data[*i]) {
|
||||
*i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn skip_number(data: &[u8], i: &mut usize) {
|
||||
if *i < data.len() && matches!(data[*i], b'+' | b'-') {
|
||||
*i += 1;
|
||||
}
|
||||
while *i < data.len() && data[*i].is_ascii_digit() {
|
||||
*i += 1;
|
||||
}
|
||||
if *i < data.len() && data[*i] == b'.' {
|
||||
*i += 1;
|
||||
while *i < data.len() && data[*i].is_ascii_digit() {
|
||||
*i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// After a `BI` operator, skip inline-image data through `EI`.
|
||||
/// Uses the same PDF whitespace set as `is_content_space`. If `EI` is not
|
||||
/// found, leave the cursor in place so later operators are still counted
|
||||
/// (undercounting would let decode allocate the full vector).
|
||||
fn skip_inline_image_after_bi(data: &[u8], mut i: usize) -> usize {
|
||||
skip_content_space(data, &mut i);
|
||||
let rest = &data[i..];
|
||||
if let Some(pos) = rest.windows(4).position(|w| {
|
||||
is_content_space(w[0]) && w[1] == b'E' && w[2] == b'I' && is_content_space(w[3])
|
||||
}) {
|
||||
return i + pos + 3;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn lopdf_op_count(data: &[u8]) -> usize {
|
||||
Content::decode(data)
|
||||
.map(|c| c.operations.len())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// DoS safety: never report fewer operators than lopdf would allocate.
|
||||
/// Overcount is acceptable (skip a page); undercount would re-open decode.
|
||||
fn assert_count_does_not_undercount(data: &[u8]) {
|
||||
let ours = count_content_operators(data, usize::MAX);
|
||||
match Content::decode(data) {
|
||||
Ok(content) => assert!(
|
||||
ours >= content.operations.len(),
|
||||
"undercount: ours={ours} lopdf={} for {:?}",
|
||||
content.operations.len(),
|
||||
String::from_utf8_lossy(data)
|
||||
),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operator_count_matches_lopdf_for_typical_streams() {
|
||||
let samples: &[&[u8]] = &[
|
||||
b"q 1 0 0 1 0 0 cm BT /F1 12 Tf 72 720 Td (Hello) Tj ET Q",
|
||||
b"q Q q Q",
|
||||
b"BT /F1 12 Tf 12 TL 1 0 0 1 100 512 Tm (first) Tj (struck) ' ET",
|
||||
b"1 0 0 rg 0 0 10 10 re f",
|
||||
b"true false null q",
|
||||
b"% comment\nq Q\n",
|
||||
b"[ (a) 1 (b) ] TJ",
|
||||
b"1 0 0 1 0 0 cm /Im0 Do",
|
||||
];
|
||||
for data in samples {
|
||||
assert_eq!(
|
||||
count_content_operators(data, usize::MAX),
|
||||
lopdf_op_count(data),
|
||||
"count mismatch for {}",
|
||||
String::from_utf8_lossy(data)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strings_and_comments_are_not_operators() {
|
||||
let data = b"(q Q Tj) Tj % q Q\nET";
|
||||
assert_eq!(
|
||||
count_content_operators(data, usize::MAX),
|
||||
lopdf_op_count(data)
|
||||
);
|
||||
assert_eq!(count_content_operators(data, usize::MAX), 2); // Tj, ET
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_image_counts_as_one_operator() {
|
||||
let data = b"BI /W 2 /H 2 /CS /RGB /BPC 8 ID \x00\x01\x02\x03 EI q";
|
||||
assert_eq!(
|
||||
count_content_operators(data, usize::MAX),
|
||||
lopdf_op_count(data)
|
||||
);
|
||||
assert_eq!(count_content_operators(data, usize::MAX), 2); // BI, q
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_image_ei_accepts_pdf_whitespace() {
|
||||
let tab = b"BI /W 1 /H 1 ID \xff\tEI\t q Q";
|
||||
let nul = b"BI /W 1 /H 1 ID \xff\x00EI\x00 q Q";
|
||||
let ff = b"BI /W 1 /H 1 ID \xff\x0cEI\x0c q Q";
|
||||
for data in [tab.as_slice(), nul.as_slice(), ff.as_slice()] {
|
||||
assert_count_does_not_undercount(data);
|
||||
assert!(
|
||||
count_content_operators(data, usize::MAX) >= 3,
|
||||
"BI plus following q Q must remain visible after EI, got {} for {:?}",
|
||||
count_content_operators(data, usize::MAX),
|
||||
String::from_utf8_lossy(data)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_is_skipped_when_operator_cap_is_exceeded() {
|
||||
let mut data = Vec::new();
|
||||
for _ in 0..20 {
|
||||
data.extend_from_slice(b"q Q\n");
|
||||
}
|
||||
assert!(decode_content_bounded(&data, 10).unwrap().is_none());
|
||||
let decoded = decode_content_bounded(&data, 50).unwrap().unwrap();
|
||||
assert_eq!(decoded.operations.len(), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_whitespace_does_not_swallow_following_operator() {
|
||||
// NUL / form-feed end a name (PDF whitespace). Absorbing `q` into
|
||||
// `/x` would undercount and let decode allocate the operator vector.
|
||||
let mut nul_sep = Vec::new();
|
||||
let mut ff_sep = Vec::new();
|
||||
for _ in 0..8_000 {
|
||||
nul_sep.extend_from_slice(b"/x\x00q");
|
||||
ff_sep.extend_from_slice(b"/x\x0cq");
|
||||
}
|
||||
assert_count_does_not_undercount(&nul_sep);
|
||||
assert_count_does_not_undercount(&ff_sep);
|
||||
assert!(count_content_operators(&ff_sep, usize::MAX) >= 8_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_streams_do_not_undercount_vs_lopdf() {
|
||||
let samples: &[&[u8]] = &[
|
||||
b".5 0 0 .5 0 0 cm",
|
||||
b"+1 -2 3.0 rg",
|
||||
b"<0041> Tj",
|
||||
b"(unbalanced",
|
||||
b"BI /W 1 /H 1 ID \xff\xff no EI here q Q q Q",
|
||||
b"q\x00Q\x00q\x00Q",
|
||||
b"/F1\x0c12 Tf (Hi) Tj",
|
||||
b"{ 1 2 add } cvx",
|
||||
];
|
||||
for data in samples {
|
||||
assert_count_does_not_undercount(data);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn million_q_pairs_are_rejected_without_decode() {
|
||||
let mut data = Vec::with_capacity((MAX_PAGE_OPERATIONS + 1) * 2);
|
||||
for _ in 0..=MAX_PAGE_OPERATIONS {
|
||||
data.extend_from_slice(b"q\n");
|
||||
}
|
||||
assert!(content_exceeds_operation_limit(&data, MAX_PAGE_OPERATIONS));
|
||||
assert!(decode_content_bounded(&data, MAX_PAGE_OPERATIONS)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
+107
-26
@@ -19,7 +19,7 @@ use super::fonts::{
|
||||
CMapDecisionCache, FontStyleCache,
|
||||
};
|
||||
use super::underline::UnderlineLine;
|
||||
use super::xobjects::{extract_form_xobject_text, get_page_xobjects, XObjectType};
|
||||
use super::xobjects::{extract_form_xobject_text, get_page_xobjects, FormWalkBudget, XObjectType};
|
||||
use super::{get_number, image_bbox_from_ctm, multiply_matrices};
|
||||
|
||||
/// Strip PDF comments (% to end of line) from content stream bytes.
|
||||
@@ -137,8 +137,11 @@ fn rise_adjusted(tm: &[f32; 6], rise: f32) -> [f32; 6] {
|
||||
]
|
||||
}
|
||||
|
||||
/// Returns `(page_extraction, has_gid_fonts)` where `has_gid_fonts` indicates
|
||||
/// the page uses fonts with unresolvable gid-encoded glyphs.
|
||||
/// Returns `(page_extraction, has_gid_fonts, coords_rotated, skipped_invisible)`
|
||||
/// where `has_gid_fonts` indicates the page uses fonts with unresolvable
|
||||
/// gid-encoded glyphs and `skipped_invisible` reports that invisible (Tr 3)
|
||||
/// text was present but suppressed — callers can use it to decide whether an
|
||||
/// `include_invisible` retry could recover anything at all.
|
||||
pub(crate) fn extract_page_text_items(
|
||||
doc: &Document,
|
||||
page_id: ObjectId,
|
||||
@@ -146,9 +149,8 @@ pub(crate) fn extract_page_text_items(
|
||||
font_cmaps: &FontCMaps,
|
||||
include_invisible: bool,
|
||||
style_cache: &mut FontStyleCache,
|
||||
) -> Result<(PageExtraction, bool, bool), PdfError> {
|
||||
use lopdf::content::Content;
|
||||
|
||||
form_budget: &mut FormWalkBudget,
|
||||
) -> Result<(PageExtraction, bool, bool, bool), PdfError> {
|
||||
let mut items = Vec::new();
|
||||
let mut rects: Vec<PdfRect> = Vec::new();
|
||||
let mut clip_rects: Vec<PdfRect> = Vec::new();
|
||||
@@ -252,22 +254,27 @@ pub(crate) fn extract_page_text_items(
|
||||
// Content::decode parser, causing it to skip operators like ET and Q.
|
||||
let content_data = strip_pdf_comments(&content_data);
|
||||
|
||||
let content = Content::decode(&content_data).map_err(|e| PdfError::Parse(e.to_string()))?;
|
||||
|
||||
const MAX_OPERATIONS: usize = 1_000_000;
|
||||
if content.operations.len() > MAX_OPERATIONS {
|
||||
log::warn!(
|
||||
"page {}: skipping extraction — {} operations exceeds limit ({})",
|
||||
page_num,
|
||||
content.operations.len(),
|
||||
MAX_OPERATIONS
|
||||
);
|
||||
return Ok(((Vec::new(), Vec::new(), Vec::new()), false, false));
|
||||
}
|
||||
let content = match super::content_decode::decode_content_bounded(
|
||||
&content_data,
|
||||
super::content_decode::MAX_PAGE_OPERATIONS,
|
||||
)? {
|
||||
Some(content) => content,
|
||||
None => {
|
||||
log::warn!(
|
||||
"page {}: skipping extraction — content stream exceeds {} operations",
|
||||
page_num,
|
||||
super::content_decode::MAX_PAGE_OPERATIONS
|
||||
);
|
||||
return Ok(((Vec::new(), Vec::new(), Vec::new()), false, false, false));
|
||||
}
|
||||
};
|
||||
|
||||
// Graphics state tracking
|
||||
let mut ctm = [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0]; // Current Transformation Matrix
|
||||
let mut text_rendering_mode: i32 = 0; // 0=fill, 1=stroke, 2=fill+stroke, 3=invisible
|
||||
// Invisible (Tr 3) text was present but suppressed — reported to callers
|
||||
// so an include_invisible retry is attempted only when it can recover.
|
||||
let mut skipped_invisible = false;
|
||||
let mut line_width: f32 = 1.0;
|
||||
#[derive(Clone)]
|
||||
struct SavedGraphicsState {
|
||||
@@ -494,6 +501,14 @@ pub(crate) fn extract_page_text_items(
|
||||
// For Mixed/template PDFs, include_invisible=true extracts
|
||||
// the OCR text layer that sits behind scanned images.
|
||||
if text_rendering_mode == 3 && !include_invisible {
|
||||
if op
|
||||
.operands
|
||||
.first()
|
||||
.and_then(get_operand_bytes)
|
||||
.is_some_and(|raw| !raw.is_empty())
|
||||
{
|
||||
skipped_invisible = true;
|
||||
}
|
||||
if let Some(w_ts) = w_ts_opt {
|
||||
text_matrix[4] += w_ts * text_matrix[0];
|
||||
text_matrix[5] += w_ts * text_matrix[1];
|
||||
@@ -546,7 +561,11 @@ pub(crate) fn extract_page_text_items(
|
||||
y,
|
||||
width,
|
||||
height: rendered_size,
|
||||
font: current_font.clone(),
|
||||
font: crate::extractor::fonts::item_font_name(
|
||||
¤t_font,
|
||||
base_font,
|
||||
)
|
||||
.to_string(),
|
||||
font_size: rendered_size,
|
||||
page: page_num,
|
||||
is_bold: is_bold_font(base_font) || desc_bold,
|
||||
@@ -565,6 +584,16 @@ pub(crate) fn extract_page_text_items(
|
||||
if in_text_block && !op.operands.is_empty() {
|
||||
if let Ok(array) = op.operands[0].as_array() {
|
||||
let font_info = font_widths.get(¤t_font);
|
||||
// Numeric-only TJ arrays (pure kerning) show no
|
||||
// text — they must not trigger the invisible retry.
|
||||
if text_rendering_mode == 3
|
||||
&& !include_invisible
|
||||
&& array
|
||||
.iter()
|
||||
.any(|el| get_operand_bytes(el).is_some_and(|raw| !raw.is_empty()))
|
||||
{
|
||||
skipped_invisible = true;
|
||||
}
|
||||
let is_invisible = (text_rendering_mode == 3 && !include_invisible)
|
||||
|| suppress_glyph_extraction;
|
||||
// Capture first-glyph position for ActualText
|
||||
@@ -720,7 +749,11 @@ pub(crate) fn extract_page_text_items(
|
||||
y,
|
||||
width,
|
||||
height: rendered_size,
|
||||
font: current_font.clone(),
|
||||
font: crate::extractor::fonts::item_font_name(
|
||||
¤t_font,
|
||||
base_font,
|
||||
)
|
||||
.to_string(),
|
||||
font_size: rendered_size,
|
||||
page: page_num,
|
||||
is_bold: is_bold_font(base_font) || desc_bold,
|
||||
@@ -770,6 +803,16 @@ pub(crate) fn extract_page_text_items(
|
||||
)
|
||||
})
|
||||
});
|
||||
if text_rendering_mode == 3
|
||||
&& !include_invisible
|
||||
&& op
|
||||
.operands
|
||||
.first()
|
||||
.and_then(get_operand_bytes)
|
||||
.is_some_and(|raw| !raw.is_empty())
|
||||
{
|
||||
skipped_invisible = true;
|
||||
}
|
||||
if !((text_rendering_mode == 3 && !include_invisible)
|
||||
|| suppress_glyph_extraction
|
||||
|| op.operands.is_empty())
|
||||
@@ -817,7 +860,11 @@ pub(crate) fn extract_page_text_items(
|
||||
y,
|
||||
width,
|
||||
height: rendered_size,
|
||||
font: current_font.clone(),
|
||||
font: crate::extractor::fonts::item_font_name(
|
||||
¤t_font,
|
||||
base_font,
|
||||
)
|
||||
.to_string(),
|
||||
font_size: rendered_size,
|
||||
page: page_num,
|
||||
is_bold: is_bold_font(base_font) || desc_bold,
|
||||
@@ -882,6 +929,7 @@ pub(crate) fn extract_page_text_items(
|
||||
&ctm,
|
||||
&mut cmap_decisions,
|
||||
style_cache,
|
||||
form_budget,
|
||||
);
|
||||
items.extend(form_items);
|
||||
}
|
||||
@@ -969,7 +1017,11 @@ pub(crate) fn extract_page_text_items(
|
||||
y,
|
||||
width,
|
||||
height: rendered_size,
|
||||
font: current_font.clone(),
|
||||
font: crate::extractor::fonts::item_font_name(
|
||||
¤t_font,
|
||||
base_font,
|
||||
)
|
||||
.to_string(),
|
||||
font_size: rendered_size,
|
||||
page: page_num,
|
||||
is_bold: is_bold_font(base_font) || desc_bold,
|
||||
@@ -1251,6 +1303,12 @@ pub(crate) fn extract_page_text_items(
|
||||
}
|
||||
}
|
||||
|
||||
if form_budget.was_truncated() {
|
||||
log::warn!(
|
||||
"page {page_num}: Form XObject expansion truncated (invocation or operation budget reached); nested form text may be incomplete"
|
||||
);
|
||||
}
|
||||
|
||||
// Underline detection reads only painted ink: `re` rects confirmed by
|
||||
// a paint operator plus filled-subpath rects — never clip-only rects,
|
||||
// which draw nothing.
|
||||
@@ -1300,7 +1358,12 @@ pub(crate) fn extract_page_text_items(
|
||||
|
||||
let items = super::merge_text_items(items);
|
||||
let items = super::merge_subscript_items(items);
|
||||
Ok(((items, rects, lines), has_gid_fonts, coords_rotated))
|
||||
Ok((
|
||||
(items, rects, lines),
|
||||
has_gid_fonts,
|
||||
coords_rotated,
|
||||
skipped_invisible,
|
||||
))
|
||||
}
|
||||
|
||||
/// Counts of text operators with horizontal vs rotated combined matrices.
|
||||
@@ -1498,13 +1561,14 @@ mod tests {
|
||||
|
||||
let (doc, page_id) = simple_doc_with_content(content);
|
||||
let font_cmaps = FontCMaps::from_doc(&doc);
|
||||
let ((items, _, _), _, _) = extract_page_text_items(
|
||||
let ((items, _, _), _, _, _) = extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
1,
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut FontStyleCache::new(),
|
||||
&mut FormWalkBudget::new(),
|
||||
)
|
||||
.unwrap();
|
||||
items
|
||||
@@ -1734,9 +1798,10 @@ BT /F1 12 Tf 0 1 -1 0 240 100 Tm (WORLD) Tj ET
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut FontStyleCache::new(),
|
||||
&mut FormWalkBudget::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let ((items, rects, lines), _has_gid, _coords_rotated) = result;
|
||||
let ((items, rects, lines), _has_gid, _coords_rotated, _skipped_invisible) = result;
|
||||
assert!(items.is_empty());
|
||||
assert!(rects.is_empty());
|
||||
assert!(lines.is_empty());
|
||||
@@ -1817,13 +1882,14 @@ BT 30 700 Tm <41> Tj ET";
|
||||
doc.trailer.set("Root", Object::Reference(catalog_id));
|
||||
|
||||
let font_cmaps = FontCMaps::from_doc(&doc);
|
||||
let ((items, _, _), _, _) = extract_page_text_items(
|
||||
let ((items, _, _), _, _, _) = extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
1,
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut FontStyleCache::new(),
|
||||
&mut FormWalkBudget::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let text = items
|
||||
@@ -1887,4 +1953,19 @@ BT 30 700 Tm <41> Tj ET";
|
||||
let output = strip_pdf_comments(input);
|
||||
assert_eq!(output, b"(x\\\\) Tj \nET\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_content_stream_skips_extraction() {
|
||||
let mut content =
|
||||
Vec::with_capacity((super::super::content_decode::MAX_PAGE_OPERATIONS + 1) * 2);
|
||||
for _ in 0..=super::super::content_decode::MAX_PAGE_OPERATIONS {
|
||||
content.extend_from_slice(b"q\n");
|
||||
}
|
||||
content.extend_from_slice(b"BT /F1 12 Tf 72 720 Td (Hello) Tj ET\n");
|
||||
let items = extract_simple_items(&content);
|
||||
assert!(
|
||||
items.is_empty(),
|
||||
"pages over the operator cap must not be decoded"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+138
-16
@@ -225,6 +225,26 @@ pub(crate) fn build_type3_scales(
|
||||
scales
|
||||
}
|
||||
|
||||
/// The name a `TextItem` carries for its font: the `/BaseFont` family name
|
||||
/// ("ABCDEF+CMMI10"), which identifies the actual face, rather than the
|
||||
/// arbitrary per-page resource tag ("F2").
|
||||
///
|
||||
/// Exception: resource names using Distiller's CID convention (`C2_0`,
|
||||
/// `C0_1`) are kept as-is — `text_utils::is_cid_font` keys on that prefix
|
||||
/// for micro-gap joining, and the family name carries no CID marker to
|
||||
/// replace it. This is a known, deliberate wart: `TextItem::font` is the
|
||||
/// face name except for this one producer convention. The clean fix is an
|
||||
/// explicit CID flag on `TextItem`, which touches its ~29 construction
|
||||
/// sites; do that migration when `TextItem` next changes shape, and delete
|
||||
/// this carve-out with it.
|
||||
pub(crate) fn item_font_name<'a>(resource_name: &'a str, base_font: &'a str) -> &'a str {
|
||||
if crate::text_utils::is_cid_font(resource_name) {
|
||||
resource_name
|
||||
} else {
|
||||
base_font
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse font widths from a font dictionary, dispatching by Subtype
|
||||
pub(crate) fn parse_font_widths(
|
||||
doc: &Document,
|
||||
@@ -482,7 +502,11 @@ pub(crate) fn parse_cid_w_array(
|
||||
widths: &mut HashMap<u16, u16>,
|
||||
) {
|
||||
let mut i = 0;
|
||||
let mut assigned = 0usize;
|
||||
while i < w_array.len() {
|
||||
if assigned >= crate::tounicode::MAX_CID_W_EXPANSION {
|
||||
return;
|
||||
}
|
||||
let start_cid = match &w_array[i] {
|
||||
Object::Integer(n) => *n as u16,
|
||||
Object::Real(n) => *n as u16,
|
||||
@@ -501,12 +525,14 @@ pub(crate) fn parse_cid_w_array(
|
||||
Object::Array(arr) => {
|
||||
// [c [w1 w2 ...]] — consecutive widths starting at c
|
||||
for (j, w_obj) in arr.iter().enumerate() {
|
||||
let w = match w_obj {
|
||||
Object::Integer(n) => *n as u16,
|
||||
Object::Real(n) => *n as u16,
|
||||
_ => continue,
|
||||
};
|
||||
widths.insert(start_cid + j as u16, w);
|
||||
if !assign_cid_width(
|
||||
widths,
|
||||
start_cid.wrapping_add(j as u16),
|
||||
w_obj,
|
||||
&mut assigned,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
@@ -514,12 +540,14 @@ pub(crate) fn parse_cid_w_array(
|
||||
// Could be a reference to an array
|
||||
if let Ok(Object::Array(arr)) = doc.get_object(*r) {
|
||||
for (j, w_obj) in arr.iter().enumerate() {
|
||||
let w = match w_obj {
|
||||
Object::Integer(n) => *n as u16,
|
||||
Object::Real(n) => *n as u16,
|
||||
_ => continue,
|
||||
};
|
||||
widths.insert(start_cid + j as u16, w);
|
||||
if !assign_cid_width(
|
||||
widths,
|
||||
start_cid.wrapping_add(j as u16),
|
||||
w_obj,
|
||||
&mut assigned,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
} else {
|
||||
@@ -542,8 +570,8 @@ pub(crate) fn parse_cid_w_array(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for cid in start_cid..=end {
|
||||
widths.insert(cid, w);
|
||||
if !assign_cid_width_range(widths, start_cid, end, w, &mut assigned) {
|
||||
return;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
@@ -561,8 +589,8 @@ pub(crate) fn parse_cid_w_array(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for cid in start_cid..=end {
|
||||
widths.insert(cid, w);
|
||||
if !assign_cid_width_range(widths, start_cid, end, w, &mut assigned) {
|
||||
return;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
@@ -573,6 +601,45 @@ pub(crate) fn parse_cid_w_array(
|
||||
}
|
||||
}
|
||||
|
||||
fn assign_cid_width(
|
||||
widths: &mut HashMap<u16, u16>,
|
||||
cid: u16,
|
||||
w_obj: &Object,
|
||||
assigned: &mut usize,
|
||||
) -> bool {
|
||||
let w = match w_obj {
|
||||
Object::Integer(n) => *n as u16,
|
||||
Object::Real(n) => *n as u16,
|
||||
_ => return true,
|
||||
};
|
||||
if *assigned >= crate::tounicode::MAX_CID_W_EXPANSION {
|
||||
return false;
|
||||
}
|
||||
widths.insert(cid, w);
|
||||
*assigned += 1;
|
||||
true
|
||||
}
|
||||
|
||||
fn assign_cid_width_range(
|
||||
widths: &mut HashMap<u16, u16>,
|
||||
start: u16,
|
||||
end: u16,
|
||||
w: u16,
|
||||
assigned: &mut usize,
|
||||
) -> bool {
|
||||
if start > end {
|
||||
return true;
|
||||
}
|
||||
for cid in start..=end {
|
||||
if *assigned >= crate::tounicode::MAX_CID_W_EXPANSION {
|
||||
return false;
|
||||
}
|
||||
widths.insert(cid, w);
|
||||
*assigned += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Compute the width of a string in text space units,
|
||||
/// given raw bytes and font width info.
|
||||
/// Returns width in text space units (font_units * units_scale * font_size).
|
||||
@@ -1617,6 +1684,17 @@ fn score_text(text: &str) -> i32 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn item_font_name_prefers_family_over_resource_tag() {
|
||||
use super::item_font_name;
|
||||
assert_eq!(item_font_name("F2", "ABCDEF+CMMI10"), "ABCDEF+CMMI10");
|
||||
assert_eq!(item_font_name("T22", "Times-Roman"), "Times-Roman");
|
||||
// Distiller CID-convention resources keep the resource name:
|
||||
// is_cid_font keys on the C2_/C0_ prefix for micro-gap joining.
|
||||
assert_eq!(item_font_name("C2_0", "ABCDEE+SimSun"), "C2_0");
|
||||
assert_eq!(item_font_name("C0_1", "ABCDEE+MSMincho"), "C0_1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type3_scale_resolves_indirect_matrix_and_bbox_numbers() {
|
||||
use lopdf::{dictionary, Document, Object};
|
||||
@@ -2313,4 +2391,48 @@ end",
|
||||
// invalid CMap result — so it must not clear the gid flag.
|
||||
assert!(gid_flagged(Some("<01> <FFFD>\n<02> <FFFD>")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cid_w_array_range_and_consecutive() {
|
||||
use super::parse_cid_w_array;
|
||||
use lopdf::{Document, Object};
|
||||
use std::collections::HashMap;
|
||||
|
||||
let doc = Document::new();
|
||||
let mut widths = HashMap::new();
|
||||
let w = vec![
|
||||
Object::Integer(10),
|
||||
Object::Integer(12),
|
||||
Object::Integer(500),
|
||||
Object::Integer(20),
|
||||
Object::Array(vec![Object::Integer(100), Object::Integer(200)]),
|
||||
];
|
||||
parse_cid_w_array(&doc, &w, &mut widths);
|
||||
assert_eq!(widths.get(&10), Some(&500));
|
||||
assert_eq!(widths.get(&11), Some(&500));
|
||||
assert_eq!(widths.get(&12), Some(&500));
|
||||
assert_eq!(widths.get(&20), Some(&100));
|
||||
assert_eq!(widths.get(&21), Some(&200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cid_w_array_repeated_full_ranges_stay_bounded() {
|
||||
use super::parse_cid_w_array;
|
||||
use crate::tounicode::MAX_CID_W_EXPANSION;
|
||||
use lopdf::{Document, Object};
|
||||
use std::collections::HashMap;
|
||||
|
||||
let doc = Document::new();
|
||||
let mut widths = HashMap::new();
|
||||
let mut w = Vec::new();
|
||||
for _ in 0..5_000 {
|
||||
w.push(Object::Integer(0));
|
||||
w.push(Object::Integer(65535));
|
||||
w.push(Object::Integer(500));
|
||||
}
|
||||
parse_cid_w_array(&doc, &w, &mut widths);
|
||||
assert!(widths.len() <= MAX_CID_W_EXPANSION);
|
||||
assert_eq!(widths.get(&0), Some(&500));
|
||||
assert_eq!(widths.get(&65535), Some(&500));
|
||||
}
|
||||
}
|
||||
|
||||
+954
-62
File diff suppressed because it is too large
Load Diff
+253
-14
@@ -3,6 +3,7 @@
|
||||
//! This module extracts text with position information for structure detection.
|
||||
|
||||
mod base14;
|
||||
mod content_decode;
|
||||
pub(crate) mod content_stream;
|
||||
mod fonts;
|
||||
mod layout;
|
||||
@@ -37,6 +38,7 @@ pub(crate) use layout::group_prefiltered_items_into_lines_with_thresholds_and_re
|
||||
pub(crate) use layout::is_newspaper_layout;
|
||||
pub(crate) use layout::ColumnRegion;
|
||||
pub use layout::{group_into_lines, group_into_lines_preserving_all_text};
|
||||
pub(crate) use xobjects::FormWalkBudget;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
@@ -282,18 +284,22 @@ fn extract_positioned_text_impl(
|
||||
font_cmaps,
|
||||
include_invisible,
|
||||
&mut style_cache,
|
||||
&mut FormWalkBudget::new(),
|
||||
);
|
||||
let ((mut items, mut rects, mut lines), has_gid_fonts, coords_rotated) = match page_result {
|
||||
Ok(extraction) => extraction,
|
||||
Err(error) if required_pages.is_some_and(|required| !required.contains(page_num)) => {
|
||||
debug!(
|
||||
"page {}: skipping context-only extraction error: {}",
|
||||
page_num, error
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
let ((mut items, mut rects, mut lines), has_gid_fonts, coords_rotated, _skipped_invisible) =
|
||||
match page_result {
|
||||
Ok(extraction) => extraction,
|
||||
Err(error)
|
||||
if required_pages.is_some_and(|required| !required.contains(page_num)) =>
|
||||
{
|
||||
debug!(
|
||||
"page {}: skipping context-only extraction error: {}",
|
||||
page_num, error
|
||||
);
|
||||
continue;
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
// Clip to the visible page box: single-page extracts and imposed
|
||||
// spreads keep neighboring pages' content in the stream, positioned
|
||||
// outside the CropBox. Extracting it interleaves invisible text into
|
||||
@@ -884,6 +890,92 @@ fn tracked_run_space_floor(group: &[&TextItem], start: usize) -> Option<(usize,
|
||||
Some((end, floor * fs))
|
||||
}
|
||||
|
||||
/// Fractional font-size band within which `merge_text_items` treats two runs as
|
||||
/// the same size. Shared with `is_small_caps_continuation`, which exists only to
|
||||
/// rescue junctions this band would otherwise break.
|
||||
const MERGE_FONT_SIZE_BAND: f32 = 0.20;
|
||||
|
||||
/// Detect a small-caps continuation: typesetters render small caps as a
|
||||
/// full-size capital immediately followed by shrunken capitals in the same
|
||||
/// font (`(R) Tj` at 9.98pt, then `(OLANDO) Tj` at 6.74pt). Those runs are one
|
||||
/// word, but the font-size band in `merge_text_items` would split them,
|
||||
/// leaving "R" and "OLANDO" as separate items — which then read as separate
|
||||
/// table columns, since column boundaries cluster on item start positions.
|
||||
///
|
||||
/// Gated tightly so it cannot absorb the other reasons a smaller run follows a
|
||||
/// larger one:
|
||||
/// - runs the size band already accepts — excluded by requiring the junction
|
||||
/// to *cross* the band, so within-band pairs keep the normal word-spacing
|
||||
/// logic instead of having their space suppressed
|
||||
/// - superscripts / footnote markers — excluded by requiring an uppercase
|
||||
/// *letter* on both sides, so digits never qualify
|
||||
/// - drop caps — excluded because the body text that follows is mixed case
|
||||
/// - adjacent table cells or separate words — excluded by requiring the runs
|
||||
/// to be visually contiguous (essentially no gap)
|
||||
fn is_small_caps_continuation(
|
||||
text_so_far: &str,
|
||||
first: &TextItem,
|
||||
next: &TextItem,
|
||||
gap: f32,
|
||||
) -> bool {
|
||||
// Must shrink. Real small caps sit near 0.7-0.8 of the full cap height;
|
||||
// anything smaller is a superscript or a different run entirely.
|
||||
if first.font_size <= 0.0 || next.font_size >= first.font_size {
|
||||
return false;
|
||||
}
|
||||
// Only rescue junctions the size band would have broken. Within-band pairs
|
||||
// merge on their own, and suppressing their space would swallow real word
|
||||
// gaps between two similarly-sized uppercase words.
|
||||
if (next.font_size - first.font_size).abs() <= first.font_size * MERGE_FONT_SIZE_BAND {
|
||||
return false;
|
||||
}
|
||||
if next.font_size / first.font_size < 0.55 {
|
||||
return false;
|
||||
}
|
||||
// Visually contiguous: the capital and its small caps touch. A real word
|
||||
// space or a column gap disqualifies.
|
||||
if !(-first.font_size * 0.2..=first.font_size * 0.15).contains(&gap) {
|
||||
return false;
|
||||
}
|
||||
// The continuation must be all-uppercase letters (digits and lowercase
|
||||
// both disqualify), and must contain at least one letter.
|
||||
let mut saw_letter = false;
|
||||
for ch in next.text.chars() {
|
||||
if ch.is_alphabetic() {
|
||||
saw_letter = true;
|
||||
if !ch.is_uppercase() {
|
||||
return false;
|
||||
}
|
||||
} else if ch.is_numeric() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if !saw_letter {
|
||||
return false;
|
||||
}
|
||||
// What we are continuing must itself end in a capital. Check the actual
|
||||
// trailing character rather than skipping back to the nearest letter: after
|
||||
// "ANGELA M. MAZZARELLI1" the run to continue is the footnote marker, not
|
||||
// the "I" before it.
|
||||
let trimmed = text_so_far.trim_end();
|
||||
if trimmed.chars().last().is_some_and(|c| c.is_numeric()) {
|
||||
// One legitimate exception: an ordinal suffix set as a smaller run,
|
||||
// e.g. "JULY 4" + "TH". Only the four English suffixes qualify —
|
||||
// anything else after a digit is a footnote marker or numeric suffix.
|
||||
return matches!(trimmed_suffix(next), "TH" | "ST" | "ND" | "RD");
|
||||
}
|
||||
trimmed
|
||||
.chars()
|
||||
.rev()
|
||||
.find(|c| c.is_alphabetic())
|
||||
.is_some_and(|c| c.is_uppercase())
|
||||
}
|
||||
|
||||
/// The continuation run's text, trimmed — used to spot ordinal suffixes.
|
||||
fn trimmed_suffix(next: &TextItem) -> &str {
|
||||
next.text.trim()
|
||||
}
|
||||
|
||||
pub(crate) fn merge_text_items(items: Vec<TextItem>) -> Vec<TextItem> {
|
||||
if items.is_empty() {
|
||||
return items;
|
||||
@@ -942,8 +1034,16 @@ pub(crate) fn merge_text_items(items: Vec<TextItem>) -> Vec<TextItem> {
|
||||
let mut j = i + 1;
|
||||
while j < group.len() {
|
||||
let next = group[j];
|
||||
// Must be similar font size (within 20%)
|
||||
if (next.font_size - first.font_size).abs() > first.font_size * 0.20 {
|
||||
// A small-caps junction is mid-word: it both survives the
|
||||
// font-size band below and must never take a space.
|
||||
let small_caps_join =
|
||||
is_small_caps_continuation(&text, first, next, next.x - end_x);
|
||||
// Must be similar font size, except for genuine small-caps
|
||||
// runs, where the shrunken capitals are the same word as the
|
||||
// full-size initial (see helper).
|
||||
if (next.font_size - first.font_size).abs() > first.font_size * MERGE_FONT_SIZE_BAND
|
||||
&& !small_caps_join
|
||||
{
|
||||
break;
|
||||
}
|
||||
// Never merge across style boundaries: the merged item
|
||||
@@ -997,7 +1097,7 @@ pub(crate) fn merge_text_items(items: Vec<TextItem>) -> Vec<TextItem> {
|
||||
Some((run_end, floor)) if j <= run_end => floor,
|
||||
_ => threshold,
|
||||
};
|
||||
if needs_bullet_space || gap > effective_threshold {
|
||||
if !small_caps_join && (needs_bullet_space || gap > effective_threshold) {
|
||||
text.push(' ');
|
||||
}
|
||||
text.push_str(&next.text);
|
||||
@@ -3001,6 +3101,145 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Small caps as typesetters emit them: a full-size capital at 9.98pt
|
||||
/// immediately followed by shrunken capitals at 6.74pt, touching.
|
||||
/// Modelled on `199AD3d.pdf` p.5 ("ROLANDO T. ACOSTA, P.J.").
|
||||
#[test]
|
||||
fn small_caps_run_merges_into_one_word() {
|
||||
let items = vec![
|
||||
make_item_fs("R", 144.36, 581.84, 7.20, 9.98),
|
||||
make_item_fs("OLANDO", 151.56, 581.84, 30.56, 6.74),
|
||||
make_item_fs("T. A", 185.45, 581.84, 17.58, 9.98),
|
||||
make_item_fs("COSTA", 203.94, 581.84, 23.15, 6.74),
|
||||
make_item_fs(", P.J.", 227.09, 581.84, 22.56, 9.98),
|
||||
];
|
||||
let merged = merge_text_items(items);
|
||||
assert_eq!(merged.len(), 1, "got {:?}", merged);
|
||||
assert_eq!(merged[0].text, "ROLANDO T. ACOSTA, P.J.");
|
||||
}
|
||||
|
||||
/// The full two-column row: both names must merge independently and the
|
||||
/// 72pt column gap between them must survive as an item boundary.
|
||||
#[test]
|
||||
fn small_caps_merge_does_not_swallow_a_second_column() {
|
||||
let items = vec![
|
||||
// Column 1: "ROLANDO T. ACOSTA, P.J." ending at x=249.65
|
||||
make_item_fs("R", 144.36, 581.84, 7.20, 9.98),
|
||||
make_item_fs("OLANDO", 151.56, 581.84, 30.56, 6.74),
|
||||
make_item_fs("T. A", 185.45, 581.84, 17.58, 9.98),
|
||||
make_item_fs("COSTA", 203.94, 581.84, 23.15, 6.74),
|
||||
make_item_fs(", P.J.", 227.09, 581.84, 22.56, 9.98),
|
||||
// Column 2 starts at x=321.96 — a 72pt gap.
|
||||
make_item_fs("A", 321.96, 581.84, 7.20, 9.98),
|
||||
make_item_fs("NIL", 329.17, 581.84, 12.72, 6.74),
|
||||
make_item_fs("C. S", 345.04, 581.84, 19.59, 9.98),
|
||||
make_item_fs("INGH", 364.62, 581.84, 19.08, 6.74),
|
||||
];
|
||||
let merged = merge_text_items(items);
|
||||
let texts: Vec<&str> = merged.iter().map(|i| i.text.as_str()).collect();
|
||||
assert_eq!(
|
||||
texts,
|
||||
vec!["ROLANDO T. ACOSTA, P.J.", "ANIL C. SINGH"],
|
||||
"column gap should keep the two names apart"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn small_caps_merge_keeps_word_space_between_same_size_capitals() {
|
||||
// Two uppercase words at sizes the merge band already accepts (9.98 and
|
||||
// 9.0, a 10% drop) separated by a real word gap. The small-caps path
|
||||
// must not claim this junction and swallow the space.
|
||||
let items = vec![
|
||||
make_item_fs("SEE", 100.0, 500.0, 18.0, 9.98),
|
||||
make_item_fs("ALSO", 119.2, 500.0, 24.0, 9.0),
|
||||
];
|
||||
let merged = merge_text_items(items);
|
||||
assert_eq!(merged.len(), 1, "got {:?}", merged);
|
||||
assert_eq!(merged[0].text, "SEE ALSO");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_digit_is_not_a_capital_awaiting_small_caps() {
|
||||
// "...MAZZARELLI1" ends in a footnote marker; the backward search for an
|
||||
// uppercase letter must not skip the digit and glue the next run.
|
||||
assert!(!is_small_caps_continuation(
|
||||
"ANGELA M. MAZZARELLI1",
|
||||
&make_item_fs("ANGELA", 100.0, 500.0, 40.0, 9.98),
|
||||
&make_item_fs("SHULMAN", 140.0, 500.0, 30.0, 6.74),
|
||||
0.0,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinal_suffix_after_a_digit_still_merges() {
|
||||
// "TUESDAY, JULY 4" + "TH" is one word in the source; the digit guard
|
||||
// must not block the four English ordinal suffixes.
|
||||
for suffix in ["TH", "ST", "ND", "RD"] {
|
||||
assert!(
|
||||
is_small_caps_continuation(
|
||||
"TUESDAY, JULY 4",
|
||||
&make_item_fs("JULY", 100.0, 500.0, 30.0, 12.0),
|
||||
&make_item_fs(suffix, 130.0, 500.0, 8.0, 8.0),
|
||||
0.0,
|
||||
),
|
||||
"{suffix} should merge after a digit"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn superscript_footnote_marker_is_not_a_small_caps_continuation() {
|
||||
// A digit must never qualify — otherwise footnote markers get glued on
|
||||
// without the superscript handling.
|
||||
assert!(!is_small_caps_continuation(
|
||||
"MAZZARELLI",
|
||||
&make_item_fs("MAZZARELLI", 100.0, 500.0, 50.0, 9.98),
|
||||
&make_item_fs("1", 150.0, 503.0, 3.0, 6.74),
|
||||
0.0,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn drop_cap_is_not_a_small_caps_continuation() {
|
||||
// Mixed-case body text after a large initial is a drop cap, not small
|
||||
// caps.
|
||||
assert!(!is_small_caps_continuation(
|
||||
"T",
|
||||
&make_item_fs("T", 100.0, 500.0, 20.0, 30.0),
|
||||
&make_item_fs("he court held", 120.0, 500.0, 60.0, 10.0),
|
||||
0.0,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_word_is_not_a_small_caps_continuation() {
|
||||
// A real word space disqualifies even when both runs are uppercase.
|
||||
let first = make_item_fs("SEE", 100.0, 500.0, 20.0, 9.98);
|
||||
let next = make_item_fs("ALSO", 128.0, 500.0, 25.0, 6.74);
|
||||
assert!(!is_small_caps_continuation("SEE", &first, &next, 8.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lowercase_continuation_is_not_small_caps() {
|
||||
assert!(!is_small_caps_continuation(
|
||||
"SMALL",
|
||||
&make_item_fs("SMALL", 100.0, 500.0, 30.0, 9.98),
|
||||
&make_item_fs("caps", 130.0, 500.0, 20.0, 6.74),
|
||||
0.0,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn too_small_a_ratio_is_not_small_caps() {
|
||||
// 0.4 ratio is a superscript/sub-run, outside the small-caps band.
|
||||
assert!(!is_small_caps_continuation(
|
||||
"A",
|
||||
&make_item_fs("A", 100.0, 500.0, 7.0, 10.0),
|
||||
&make_item_fs("BC", 107.0, 500.0, 8.0, 4.0),
|
||||
0.0,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_merge_subscript_items_chemical_formula() {
|
||||
// NH₃: "NH" at fs=8 followed by subscript "3" at fs=4.7
|
||||
|
||||
+610
-23
@@ -16,6 +16,76 @@ use super::{get_number, image_bbox_from_ctm, multiply_matrices};
|
||||
|
||||
const MAX_FORM_XOBJECT_DEPTH: u8 = 5;
|
||||
|
||||
/// Upper bound on Form XObject invocations during a single page extraction.
|
||||
/// Depth alone is not enough: an acyclic DAG where each form invokes the next
|
||||
/// N times expands to N^depth work before the depth cap is reached.
|
||||
const MAX_FORM_XOBJECT_INVOCATIONS: usize = 10_000;
|
||||
|
||||
/// Upper bound on content-stream operations walked across all Form XObject
|
||||
/// expansions for a page. Nested forms are decoded independently of the
|
||||
/// page-level operation cap, so this keeps total form work in the same
|
||||
/// ballpark as that page cap.
|
||||
const MAX_FORM_XOBJECT_OPERATIONS: usize = 1_000_000;
|
||||
|
||||
/// Shared budget for Form XObject expansion on a page. Bounds both nested DAG
|
||||
/// expansion and repeated sibling `/Do` invocations of the same form.
|
||||
pub(crate) struct FormWalkBudget {
|
||||
invocations: usize,
|
||||
operations: usize,
|
||||
max_invocations: usize,
|
||||
max_operations: usize,
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
impl FormWalkBudget {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self::with_limits(MAX_FORM_XOBJECT_INVOCATIONS, MAX_FORM_XOBJECT_OPERATIONS)
|
||||
}
|
||||
|
||||
fn with_limits(max_invocations: usize, max_operations: usize) -> Self {
|
||||
Self {
|
||||
invocations: 0,
|
||||
operations: 0,
|
||||
max_invocations,
|
||||
max_operations,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn exhausted(&mut self) -> bool {
|
||||
if self.invocations >= self.max_invocations || self.operations >= self.max_operations {
|
||||
self.truncated = true;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn charge_invocation(&mut self) -> bool {
|
||||
if self.exhausted() {
|
||||
return false;
|
||||
}
|
||||
self.invocations += 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// Charge one walked content-stream operator. Independent of the
|
||||
/// invocation cap so a form that was already admitted can finish its
|
||||
/// stream (up to the operation cap).
|
||||
fn charge_operation(&mut self) -> bool {
|
||||
if self.operations >= self.max_operations {
|
||||
self.truncated = true;
|
||||
return false;
|
||||
}
|
||||
self.operations += 1;
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn was_truncated(&self) -> bool {
|
||||
self.truncated
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum XObjectType {
|
||||
Image,
|
||||
Form(ObjectId),
|
||||
@@ -109,6 +179,7 @@ fn collect_xobjects_from_dict(
|
||||
}
|
||||
|
||||
/// Extract text items from a Form XObject
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn extract_form_xobject_text(
|
||||
doc: &Document,
|
||||
form_id: ObjectId,
|
||||
@@ -117,6 +188,7 @@ pub(crate) fn extract_form_xobject_text(
|
||||
parent_ctm: &[f32; 6],
|
||||
cmap_decisions: &mut CMapDecisionCache,
|
||||
style_cache: &mut FontStyleCache,
|
||||
budget: &mut FormWalkBudget,
|
||||
) -> Vec<TextItem> {
|
||||
extract_form_xobject_text_inner(
|
||||
doc,
|
||||
@@ -127,6 +199,7 @@ pub(crate) fn extract_form_xobject_text(
|
||||
cmap_decisions,
|
||||
style_cache,
|
||||
0,
|
||||
budget,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -140,11 +213,14 @@ fn extract_form_xobject_text_inner(
|
||||
cmap_decisions: &mut CMapDecisionCache,
|
||||
style_cache: &mut FontStyleCache,
|
||||
depth: u8,
|
||||
budget: &mut FormWalkBudget,
|
||||
) -> Vec<TextItem> {
|
||||
use lopdf::content::Content;
|
||||
|
||||
let mut items = Vec::new();
|
||||
|
||||
if !budget.charge_invocation() {
|
||||
return items;
|
||||
}
|
||||
|
||||
// Get the Form XObject stream
|
||||
let Ok(Object::Stream(stream)) = doc.get_object(form_id) else {
|
||||
return items;
|
||||
@@ -156,8 +232,12 @@ fn extract_form_xobject_text_inner(
|
||||
Err(_) => stream.content.clone(),
|
||||
};
|
||||
|
||||
// Decode the content stream
|
||||
let Ok(content) = Content::decode(&content_data) else {
|
||||
// Decode the content stream. Cap before lopdf materializes the operator
|
||||
// vector — the walk budget cannot help if decode itself allocates first.
|
||||
let Ok(Some(content)) = super::content_decode::decode_content_bounded(
|
||||
&content_data,
|
||||
super::content_decode::MAX_PAGE_OPERATIONS,
|
||||
) else {
|
||||
return items;
|
||||
};
|
||||
|
||||
@@ -246,19 +326,55 @@ fn extract_form_xobject_text_inner(
|
||||
let mut current_font = String::new();
|
||||
let mut current_font_size: f32 = 12.0;
|
||||
let mut text_matrix = [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0];
|
||||
// Text line matrix (TLM) — Td/TD/T* move relative to the start of the
|
||||
// current line, not to the position left by the last show operator.
|
||||
let mut line_matrix = [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0];
|
||||
let mut text_leading: f32 = 0.0; // TL parameter (text-space units)
|
||||
let mut char_spacing: f32 = 0.0; // Tc parameter
|
||||
let mut word_spacing: f32 = 0.0; // Tw parameter
|
||||
let mut in_text_block = false;
|
||||
let mut fill_is_white = false;
|
||||
let mut ctm = base_ctm;
|
||||
let mut ctm_stack: Vec<[f32; 6]> = Vec::new();
|
||||
|
||||
// Text state (Tc/Tw/TL/Tf) and the fill colour are part of the graphics
|
||||
// state and must be saved/restored by q/Q alongside the CTM.
|
||||
#[derive(Clone)]
|
||||
struct GraphicsState {
|
||||
ctm: [f32; 6],
|
||||
char_spacing: f32,
|
||||
word_spacing: f32,
|
||||
text_leading: f32,
|
||||
current_font: String,
|
||||
current_font_size: f32,
|
||||
fill_is_white: bool,
|
||||
}
|
||||
let mut ctm_stack: Vec<GraphicsState> = Vec::new();
|
||||
|
||||
for op in &content.operations {
|
||||
if !budget.charge_operation() {
|
||||
break;
|
||||
}
|
||||
match op.operator.as_str() {
|
||||
"q" => {
|
||||
ctm_stack.push(ctm);
|
||||
ctm_stack.push(GraphicsState {
|
||||
ctm,
|
||||
char_spacing,
|
||||
word_spacing,
|
||||
text_leading,
|
||||
current_font: current_font.clone(),
|
||||
current_font_size,
|
||||
fill_is_white,
|
||||
});
|
||||
}
|
||||
"Q" => {
|
||||
if let Some(saved) = ctm_stack.pop() {
|
||||
ctm = saved;
|
||||
ctm = saved.ctm;
|
||||
char_spacing = saved.char_spacing;
|
||||
word_spacing = saved.word_spacing;
|
||||
text_leading = saved.text_leading;
|
||||
current_font = saved.current_font;
|
||||
current_font_size = saved.current_font_size;
|
||||
fill_is_white = saved.fill_is_white;
|
||||
}
|
||||
}
|
||||
"cm" => {
|
||||
@@ -276,7 +392,7 @@ fn extract_form_xobject_text_inner(
|
||||
let xobj_name = String::from_utf8_lossy(name).to_string();
|
||||
match form_xobjects.get(&xobj_name) {
|
||||
Some(XObjectType::Form(nested_id)) => {
|
||||
if depth < MAX_FORM_XOBJECT_DEPTH {
|
||||
if depth < MAX_FORM_XOBJECT_DEPTH && !budget.exhausted() {
|
||||
let nested_items = extract_form_xobject_text_inner(
|
||||
doc,
|
||||
*nested_id,
|
||||
@@ -286,6 +402,7 @@ fn extract_form_xobject_text_inner(
|
||||
cmap_decisions,
|
||||
style_cache,
|
||||
depth + 1,
|
||||
budget,
|
||||
);
|
||||
items.extend(nested_items);
|
||||
}
|
||||
@@ -321,6 +438,7 @@ fn extract_form_xobject_text_inner(
|
||||
"BT" => {
|
||||
in_text_block = true;
|
||||
text_matrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
|
||||
line_matrix = text_matrix;
|
||||
}
|
||||
"ET" => {
|
||||
in_text_block = false;
|
||||
@@ -333,12 +451,33 @@ fn extract_form_xobject_text_inner(
|
||||
current_font_size = get_number(&op.operands[1]).unwrap_or(12.0);
|
||||
}
|
||||
}
|
||||
"TL" => {
|
||||
// Set text leading (used by T*, ', and ")
|
||||
if let Some(tl) = op.operands.first().and_then(get_number) {
|
||||
text_leading = tl;
|
||||
}
|
||||
}
|
||||
"Tc" => {
|
||||
if let Some(tc) = op.operands.first().and_then(get_number) {
|
||||
char_spacing = tc;
|
||||
}
|
||||
}
|
||||
"Tw" => {
|
||||
if let Some(tw) = op.operands.first().and_then(get_number) {
|
||||
word_spacing = tw;
|
||||
}
|
||||
}
|
||||
"Td" | "TD" => {
|
||||
// Move text position: TLM = T(tx,ty) x TLM; Tm = TLM
|
||||
if op.operands.len() >= 2 {
|
||||
let tx = get_number(&op.operands[0]).unwrap_or(0.0);
|
||||
let ty = get_number(&op.operands[1]).unwrap_or(0.0);
|
||||
text_matrix[4] += tx * text_matrix[0] + ty * text_matrix[2];
|
||||
text_matrix[5] += tx * text_matrix[1] + ty * text_matrix[3];
|
||||
line_matrix[4] += tx * line_matrix[0] + ty * line_matrix[2];
|
||||
line_matrix[5] += tx * line_matrix[1] + ty * line_matrix[3];
|
||||
text_matrix = line_matrix;
|
||||
if op.operator == "TD" {
|
||||
text_leading = -ty;
|
||||
}
|
||||
}
|
||||
}
|
||||
"Tm" => {
|
||||
@@ -347,8 +486,20 @@ fn extract_form_xobject_text_inner(
|
||||
text_matrix[i] =
|
||||
get_number(operand).unwrap_or(if i == 0 || i == 3 { 1.0 } else { 0.0 });
|
||||
}
|
||||
line_matrix = text_matrix;
|
||||
}
|
||||
}
|
||||
"T*" => {
|
||||
// Move to start of next line: equivalent to `0 -TL Td`
|
||||
let tl = if text_leading != 0.0 {
|
||||
text_leading
|
||||
} else {
|
||||
current_font_size * 1.2
|
||||
};
|
||||
line_matrix[4] += (-tl) * line_matrix[2];
|
||||
line_matrix[5] += (-tl) * line_matrix[3];
|
||||
text_matrix = line_matrix;
|
||||
}
|
||||
"g" => {
|
||||
if let Some(gray) = op.operands.first().and_then(get_number) {
|
||||
fill_is_white = gray > 0.95;
|
||||
@@ -384,17 +535,33 @@ fn extract_form_xobject_text_inner(
|
||||
_ => fill_is_white = false,
|
||||
}
|
||||
}
|
||||
"Tj" => {
|
||||
if in_text_block && !op.operands.is_empty() {
|
||||
"Tj" | "'" | "\"" => {
|
||||
// `'` = move to next line then show; `"` = set word/char spacing,
|
||||
// move to next line, then show (string is the last operand).
|
||||
if op.operator != "Tj" {
|
||||
if op.operator == "\"" && op.operands.len() >= 3 {
|
||||
word_spacing = get_number(&op.operands[0]).unwrap_or(word_spacing);
|
||||
char_spacing = get_number(&op.operands[1]).unwrap_or(char_spacing);
|
||||
}
|
||||
let tl = if text_leading != 0.0 {
|
||||
text_leading
|
||||
} else {
|
||||
current_font_size * 1.2
|
||||
};
|
||||
line_matrix[4] += (-tl) * line_matrix[2];
|
||||
line_matrix[5] += (-tl) * line_matrix[3];
|
||||
text_matrix = line_matrix;
|
||||
}
|
||||
if let (true, Some(show_operand)) = (in_text_block, op.operands.last()) {
|
||||
if fill_is_white {
|
||||
if let Some(font_info) = font_widths.get(¤t_font) {
|
||||
if let Some(raw_bytes) = get_operand_bytes(&op.operands[0]) {
|
||||
if let Some(raw_bytes) = get_operand_bytes(show_operand) {
|
||||
let w_ts = compute_string_width_ts(
|
||||
raw_bytes,
|
||||
font_info,
|
||||
current_font_size,
|
||||
0.0,
|
||||
0.0,
|
||||
char_spacing,
|
||||
word_spacing,
|
||||
);
|
||||
text_matrix[4] += w_ts * text_matrix[0];
|
||||
text_matrix[5] += w_ts * text_matrix[1];
|
||||
@@ -403,7 +570,7 @@ fn extract_form_xobject_text_inner(
|
||||
continue;
|
||||
}
|
||||
if let Some(text) = extract_text_from_operand(
|
||||
&op.operands[0],
|
||||
show_operand,
|
||||
¤t_font,
|
||||
font_base_names.get(¤t_font).map(|s| s.as_str()),
|
||||
font_cmaps,
|
||||
@@ -419,13 +586,13 @@ fn extract_form_xobject_text_inner(
|
||||
* type3_scales.get(¤t_font).copied().unwrap_or(1.0);
|
||||
let (x, y) = (combined[4], combined[5]);
|
||||
let width = if let Some(font_info) = font_widths.get(¤t_font) {
|
||||
if let Some(raw_bytes) = get_operand_bytes(&op.operands[0]) {
|
||||
if let Some(raw_bytes) = get_operand_bytes(show_operand) {
|
||||
let w_ts = compute_string_width_ts(
|
||||
raw_bytes,
|
||||
font_info,
|
||||
current_font_size,
|
||||
0.0,
|
||||
0.0,
|
||||
char_spacing,
|
||||
word_spacing,
|
||||
);
|
||||
text_matrix[4] += w_ts * text_matrix[0];
|
||||
text_matrix[5] += w_ts * text_matrix[1];
|
||||
@@ -453,7 +620,11 @@ fn extract_form_xobject_text_inner(
|
||||
y,
|
||||
width,
|
||||
height: rendered_size,
|
||||
font: current_font.clone(),
|
||||
font: crate::extractor::fonts::item_font_name(
|
||||
¤t_font,
|
||||
base_font,
|
||||
)
|
||||
.to_string(),
|
||||
font_size: rendered_size,
|
||||
page: page_num,
|
||||
is_bold: is_bold_font(base_font) || desc_bold,
|
||||
@@ -548,8 +719,8 @@ fn extract_form_xobject_text_inner(
|
||||
raw_bytes,
|
||||
fi,
|
||||
current_font_size,
|
||||
0.0,
|
||||
0.0,
|
||||
char_spacing,
|
||||
word_spacing,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -608,7 +779,11 @@ fn extract_form_xobject_text_inner(
|
||||
y,
|
||||
width,
|
||||
height: rendered_size,
|
||||
font: current_font.clone(),
|
||||
font: crate::extractor::fonts::item_font_name(
|
||||
¤t_font,
|
||||
base_font,
|
||||
)
|
||||
.to_string(),
|
||||
font_size: rendered_size,
|
||||
page: page_num,
|
||||
is_bold: is_bold_font(base_font) || desc_bold,
|
||||
@@ -683,3 +858,415 @@ pub(crate) fn get_form_fonts<'a>(
|
||||
|
||||
fonts
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::extractor::content_stream::extract_page_text_items;
|
||||
use lopdf::{dictionary, Dictionary, Stream};
|
||||
|
||||
/// Build an acyclic Form XObject DAG: `levels` form objects, each non-leaf
|
||||
/// invoking the next form `branches` times. The leaf draws a single `(X)`.
|
||||
/// Returns `(doc, root_form_id)`.
|
||||
fn form_dag(branches: usize, levels: usize) -> (Document, ObjectId) {
|
||||
assert!(levels >= 2);
|
||||
let mut doc = Document::new();
|
||||
let font_id = doc.add_object(dictionary! {
|
||||
"Type" => "Font",
|
||||
"Subtype" => "Type1",
|
||||
"BaseFont" => "Helvetica",
|
||||
});
|
||||
let ids: Vec<ObjectId> = (0..levels).map(|_| doc.new_object_id()).collect();
|
||||
for level in 0..levels {
|
||||
let stream = if level + 1 == levels {
|
||||
Stream::new(
|
||||
dictionary! {
|
||||
"Type" => "XObject",
|
||||
"Subtype" => "Form",
|
||||
"BBox" => vec![0.into(), 0.into(), 100.into(), 100.into()],
|
||||
"Resources" => dictionary! {
|
||||
"Font" => dictionary! {
|
||||
"F1" => Object::Reference(font_id),
|
||||
},
|
||||
},
|
||||
},
|
||||
b"BT /F1 10 Tf 10 10 Td (X) Tj ET\n".to_vec(),
|
||||
)
|
||||
} else {
|
||||
let next_name = format!("Fm{}", level + 1);
|
||||
let content = format!("/{next_name} Do\n").repeat(branches);
|
||||
let mut xobjects = Dictionary::new();
|
||||
xobjects.set(next_name, Object::Reference(ids[level + 1]));
|
||||
let mut resources = Dictionary::new();
|
||||
resources.set("XObject", Object::Dictionary(xobjects));
|
||||
let mut dict = dictionary! {
|
||||
"Type" => "XObject",
|
||||
"Subtype" => "Form",
|
||||
"BBox" => vec![0.into(), 0.into(), 100.into(), 100.into()],
|
||||
};
|
||||
dict.set("Resources", Object::Dictionary(resources));
|
||||
Stream::new(dict, content.into_bytes())
|
||||
};
|
||||
doc.set_object(ids[level], Object::Stream(stream));
|
||||
}
|
||||
(doc, ids[0])
|
||||
}
|
||||
|
||||
fn page_invoking_form(mut doc: Document, form_id: ObjectId) -> (Document, ObjectId) {
|
||||
let content_id = doc.add_object(Object::Stream(Stream::new(
|
||||
dictionary! {},
|
||||
b"/Fm0 Do\n".to_vec(),
|
||||
)));
|
||||
let page_id = doc.add_object(dictionary! {
|
||||
"Type" => "Page",
|
||||
"Contents" => Object::Reference(content_id),
|
||||
"Resources" => dictionary! {
|
||||
"XObject" => dictionary! {
|
||||
"Fm0" => Object::Reference(form_id),
|
||||
},
|
||||
},
|
||||
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
|
||||
});
|
||||
let pages_id = doc.add_object(dictionary! {
|
||||
"Type" => "Pages",
|
||||
"Count" => Object::Integer(1),
|
||||
"Kids" => vec![Object::Reference(page_id)],
|
||||
});
|
||||
let catalog_id = doc.add_object(dictionary! {
|
||||
"Type" => "Catalog",
|
||||
"Pages" => Object::Reference(pages_id),
|
||||
});
|
||||
doc.trailer.set("Root", Object::Reference(catalog_id));
|
||||
(doc, page_id)
|
||||
}
|
||||
|
||||
fn extract_form(
|
||||
doc: &Document,
|
||||
form_id: ObjectId,
|
||||
budget: &mut FormWalkBudget,
|
||||
) -> Vec<TextItem> {
|
||||
extract_form_xobject_text(
|
||||
doc,
|
||||
form_id,
|
||||
1,
|
||||
&FontCMaps::from_doc(doc),
|
||||
&[1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
|
||||
&mut CMapDecisionCache::new(),
|
||||
&mut FontStyleCache::new(),
|
||||
budget,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_form_still_extracts_leaf_text() {
|
||||
let (doc, root) = form_dag(1, 3);
|
||||
let items = extract_form(&doc, root, &mut FormWalkBudget::new());
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].text, "X");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acyclic_form_dag_within_budget_keeps_all_leaves() {
|
||||
// 4 sibling invocations across 4 nested levels → 4^4 leaf drawings.
|
||||
// Default budgets are far above 256, so legitimate nesting is intact.
|
||||
let (doc, root) = form_dag(4, 5);
|
||||
let items = extract_form(&doc, root, &mut FormWalkBudget::new());
|
||||
assert_eq!(items.len(), 4usize.pow(4));
|
||||
assert!(items.iter().all(|item| item.text == "X"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acyclic_form_dag_stops_at_invocation_budget() {
|
||||
// Same DAG as above would draw 256 leaves; a tiny invocation cap must
|
||||
// stop expansion rather than walking the full tree.
|
||||
let (doc, root) = form_dag(4, 5);
|
||||
let mut budget = FormWalkBudget::with_limits(20, MAX_FORM_XOBJECT_OPERATIONS);
|
||||
let items = extract_form(&doc, root, &mut budget);
|
||||
assert!(
|
||||
items.len() < 4usize.pow(4),
|
||||
"invocation budget must truncate DAG expansion; got {} items",
|
||||
items.len()
|
||||
);
|
||||
assert!(budget.was_truncated());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_operations_stop_at_budget() {
|
||||
let mut doc = Document::new();
|
||||
let font_id = doc.add_object(dictionary! {
|
||||
"Type" => "Font",
|
||||
"Subtype" => "Type1",
|
||||
"BaseFont" => "Helvetica",
|
||||
});
|
||||
let mut content = b"q Q\n".repeat(50);
|
||||
content.extend_from_slice(b"BT /F1 10 Tf 10 10 Td (X) Tj ET\n");
|
||||
let form_id = doc.add_object(Object::Stream(Stream::new(
|
||||
dictionary! {
|
||||
"Type" => "XObject",
|
||||
"Subtype" => "Form",
|
||||
"BBox" => vec![0.into(), 0.into(), 100.into(), 100.into()],
|
||||
"Resources" => dictionary! {
|
||||
"Font" => dictionary! {
|
||||
"F1" => Object::Reference(font_id),
|
||||
},
|
||||
},
|
||||
},
|
||||
content,
|
||||
)));
|
||||
|
||||
let mut budget = FormWalkBudget::with_limits(MAX_FORM_XOBJECT_INVOCATIONS, 10);
|
||||
let items = extract_form(&doc, form_id, &mut budget);
|
||||
assert!(
|
||||
items.is_empty(),
|
||||
"operation budget must stop before the trailing text show"
|
||||
);
|
||||
assert!(budget.was_truncated());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_level_form_dag_stays_within_production_budget() {
|
||||
// A page-level `/Do` of an 8-wide, 6-level Form DAG would expand to
|
||||
// 8^5 = 32_768 leaf drawings without a budget. The production
|
||||
// invocation cap must keep extraction bounded.
|
||||
let (doc, root) = form_dag(8, 6);
|
||||
let (doc, page_id) = page_invoking_form(doc, root);
|
||||
|
||||
let font_cmaps = FontCMaps::from_doc(&doc);
|
||||
let ((items, _, _), _, _, _) = extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
1,
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut FontStyleCache::new(),
|
||||
&mut FormWalkBudget::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
items.len() <= MAX_FORM_XOBJECT_INVOCATIONS,
|
||||
"page-level Form expansion must stay within the invocation cap; got {}",
|
||||
items.len()
|
||||
);
|
||||
assert!(
|
||||
!items.is_empty(),
|
||||
"budget must still allow some nested form text through"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_form_budget_spans_two_extraction_passes() {
|
||||
// The invisible-layer retry calls extract_page_text_items twice for
|
||||
// the same page; both passes must share one budget.
|
||||
let (doc, root) = form_dag(1, 2);
|
||||
let (doc, page_id) = page_invoking_form(doc, root);
|
||||
let font_cmaps = FontCMaps::from_doc(&doc);
|
||||
// Root + leaf = 2 invocations on the first pass.
|
||||
let mut budget = FormWalkBudget::with_limits(2, MAX_FORM_XOBJECT_OPERATIONS);
|
||||
let ((first, _, _), _, _, _) = extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
1,
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut FontStyleCache::new(),
|
||||
&mut budget,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(first.iter().filter(|item| item.text == "X").count(), 1);
|
||||
assert!(!budget.was_truncated());
|
||||
|
||||
let ((second, _, _), _, _, _) = extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
1,
|
||||
&font_cmaps,
|
||||
true,
|
||||
&mut FontStyleCache::new(),
|
||||
&mut budget,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
second.iter().all(|item| item.text != "X"),
|
||||
"second pass must not get a fresh invocation budget"
|
||||
);
|
||||
assert!(budget.was_truncated());
|
||||
}
|
||||
|
||||
/// Build a document whose page draws *all* of its content through a single
|
||||
/// Form XObject — the shape emitted by print-to-PDF producers like PDFlib,
|
||||
/// where the page stream itself is only `q /X1 Do Q`.
|
||||
fn doc_with_form_content(form_content: &[u8]) -> (Document, ObjectId) {
|
||||
let mut doc = Document::new();
|
||||
let widths: Vec<Object> = (0..=255).map(|_| 600.into()).collect();
|
||||
let font_id = doc.add_object(dictionary! {
|
||||
"Type" => "Font",
|
||||
"Subtype" => "Type1",
|
||||
"BaseFont" => "Helvetica",
|
||||
"FirstChar" => 0,
|
||||
"LastChar" => 255,
|
||||
"Widths" => Object::Array(widths),
|
||||
});
|
||||
let form_id = doc.add_object(Object::Stream(Stream::new(
|
||||
dictionary! {
|
||||
"Type" => "XObject",
|
||||
"Subtype" => "Form",
|
||||
"BBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
|
||||
"Resources" => dictionary! {
|
||||
"Font" => dictionary! { "F1" => Object::Reference(font_id) },
|
||||
},
|
||||
},
|
||||
form_content.to_vec(),
|
||||
)));
|
||||
let content_id = doc.add_object(Object::Stream(Stream::new(
|
||||
dictionary! {},
|
||||
b"q /X1 Do Q".to_vec(),
|
||||
)));
|
||||
let page_id = doc.add_object(dictionary! {
|
||||
"Type" => "Page",
|
||||
"Contents" => Object::Reference(content_id),
|
||||
"Resources" => dictionary! {
|
||||
"XObject" => dictionary! { "X1" => Object::Reference(form_id) },
|
||||
},
|
||||
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
|
||||
});
|
||||
let pages_id = doc.add_object(dictionary! {
|
||||
"Type" => "Pages",
|
||||
"Count" => Object::Integer(1),
|
||||
"Kids" => vec![Object::Reference(page_id)],
|
||||
});
|
||||
let catalog_id = doc.add_object(dictionary! {
|
||||
"Type" => "Catalog",
|
||||
"Pages" => Object::Reference(pages_id),
|
||||
});
|
||||
doc.trailer.set("Root", Object::Reference(catalog_id));
|
||||
(doc, page_id)
|
||||
}
|
||||
|
||||
fn form_items(form_content: &[u8]) -> Vec<TextItem> {
|
||||
let (doc, page_id) = doc_with_form_content(form_content);
|
||||
let font_cmaps = FontCMaps::from_doc(&doc);
|
||||
let ((items, _, _), _, _, _) = extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
1,
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut FontStyleCache::new(),
|
||||
&mut FormWalkBudget::new(),
|
||||
)
|
||||
.unwrap();
|
||||
items
|
||||
}
|
||||
|
||||
fn find<'a>(items: &'a [TextItem], text: &str) -> &'a TextItem {
|
||||
items
|
||||
.iter()
|
||||
.find(|item| item.text == text)
|
||||
.unwrap_or_else(|| {
|
||||
let found: Vec<&String> = items.iter().map(|i| &i.text).collect();
|
||||
panic!("no item {text:?} in {found:?}")
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_star_inside_form_moves_to_next_line() {
|
||||
// T* was previously unhandled inside Form XObjects, so every line after
|
||||
// the first piled onto the preceding baseline and drifted right.
|
||||
let items =
|
||||
form_items(b"BT /F1 12 Tf 12 TL 1 0 0 1 100 700 Tm (first) Tj T* (second) Tj ET");
|
||||
|
||||
let first = find(&items, "first");
|
||||
let second = find(&items, "second");
|
||||
assert!((first.y - 700.0).abs() < 0.1, "first y = {}", first.y);
|
||||
assert!((second.y - 688.0).abs() < 0.1, "second y = {}", second.y);
|
||||
assert!((second.x - 100.0).abs() < 0.1, "second x = {}", second.x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn td_inside_form_is_relative_to_line_start_not_shown_text() {
|
||||
// Td moves relative to the text *line* matrix. Applying it to the
|
||||
// matrix already advanced by Tj marched each line off the right edge.
|
||||
let items = form_items(b"BT /F1 12 Tf 1 0 0 1 100 700 Tm (AAAAA) Tj 0 -12 Td (B) Tj ET");
|
||||
|
||||
let b = find(&items, "B");
|
||||
assert!((b.x - 100.0).abs() < 0.1, "B x = {} (expected 100)", b.x);
|
||||
assert!((b.y - 688.0).abs() < 0.1, "B y = {}", b.y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn td_inside_form_sets_leading_for_later_t_star() {
|
||||
// `TD` sets the leading to -ty as a side effect; a following T* must
|
||||
// reuse it.
|
||||
let items = form_items(
|
||||
b"BT /F1 12 Tf 1 0 0 1 100 700 Tm (one) Tj 0 -15 TD (two) Tj T* (three) Tj ET",
|
||||
);
|
||||
|
||||
assert!((find(&items, "two").y - 685.0).abs() < 0.1);
|
||||
let three = find(&items, "three");
|
||||
assert!((three.y - 670.0).abs() < 0.1, "three y = {}", three.y);
|
||||
assert!((three.x - 100.0).abs() < 0.1, "three x = {}", three.x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quote_operator_inside_form_moves_to_next_line() {
|
||||
let items = form_items(b"BT /F1 12 Tf 12 TL 1 0 0 1 100 700 Tm (first) Tj (second) ' ET");
|
||||
|
||||
let second = find(&items, "second");
|
||||
assert!((second.y - 688.0).abs() < 0.1, "second y = {}", second.y);
|
||||
assert!((second.x - 100.0).abs() < 0.1, "second x = {}", second.x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_quote_operator_inside_form_sets_spacing_and_moves() {
|
||||
// `aw ac (string) "` — set word spacing and char spacing, then T* and show.
|
||||
let items =
|
||||
form_items(b"BT /F1 12 Tf 12 TL 1 0 0 1 100 700 Tm (first) Tj 0 0 (second) \" ET");
|
||||
|
||||
let second = find(&items, "second");
|
||||
assert!((second.y - 688.0).abs() < 0.1, "second y = {}", second.y);
|
||||
assert!((second.x - 100.0).abs() < 0.1, "second x = {}", second.x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_spacing_inside_form_widens_advance() {
|
||||
// Tc was hardcoded to 0 in the form parser, so advance widths drifted.
|
||||
// 2 glyphs x 600/1000 x 12pt = 14.4, plus 2 x Tc(2.0) = 18.4.
|
||||
let items = form_items(b"BT /F1 12 Tf 1 0 0 1 100 700 Tm 2 Tc (AB) Tj ET");
|
||||
|
||||
let ab = find(&items, "AB");
|
||||
assert!((ab.width - 18.4).abs() < 0.1, "AB width = {}", ab.width);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn q_restores_fill_colour_inside_form() {
|
||||
// A white fill set inside q/Q must not leak past the Q — otherwise the
|
||||
// following black text is treated as invisible and dropped entirely.
|
||||
let items = form_items(
|
||||
b"BT /F1 12 Tf 12 TL 1 0 0 1 100 700 Tm q 1 g (hidden) Tj Q T* (visible) Tj ET",
|
||||
);
|
||||
|
||||
assert!(
|
||||
items.iter().any(|item| item.text == "visible"),
|
||||
"text after Q was dropped: {:?}",
|
||||
items.iter().map(|i| &i.text).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(
|
||||
!items.iter().any(|item| item.text == "hidden"),
|
||||
"white-filled text should still be suppressed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn q_restores_text_state_inside_form() {
|
||||
// Tc/TL live in the graphics state; `Q` must roll them back.
|
||||
let items =
|
||||
form_items(b"BT /F1 12 Tf 12 TL 1 0 0 1 100 700 Tm q 30 TL (a) Tj Q T* (b) Tj ET");
|
||||
|
||||
let b = find(&items, "b");
|
||||
assert!(
|
||||
(b.y - 688.0).abs() < 0.1,
|
||||
"b y = {} (leading should restore to 12)",
|
||||
b.y
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+286
-37
@@ -43,6 +43,7 @@ mod text_quality;
|
||||
pub mod text_utils;
|
||||
pub mod tounicode;
|
||||
pub mod types;
|
||||
pub mod vision;
|
||||
|
||||
pub use detector::{
|
||||
detect_pdf_type, detect_pdf_type_mem, detect_pdf_type_mem_with_config,
|
||||
@@ -458,8 +459,44 @@ pub fn extract_pages_markdown_mem(
|
||||
buffer: &[u8],
|
||||
pages: Option<&[u32]>,
|
||||
) -> Result<PagesExtractionResult, PdfError> {
|
||||
extract_pages_markdown_mem_impl(
|
||||
buffer,
|
||||
pages,
|
||||
None,
|
||||
&MarkdownOptions::default(),
|
||||
false,
|
||||
false,
|
||||
)
|
||||
.map(|(result, _)| result)
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
|
||||
pub(crate) fn extract_pages_markdown_mem_for_ocr(
|
||||
buffer: &[u8],
|
||||
pages: Option<&[u32]>,
|
||||
password: Option<&str>,
|
||||
markdown_options: &MarkdownOptions,
|
||||
) -> Result<(PagesExtractionResult, u32), PdfError> {
|
||||
extract_pages_markdown_mem_impl(
|
||||
buffer,
|
||||
pages,
|
||||
password,
|
||||
markdown_options,
|
||||
markdown_options.strip_headers_footers,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
fn extract_pages_markdown_mem_impl(
|
||||
buffer: &[u8],
|
||||
pages: Option<&[u32]>,
|
||||
password: Option<&str>,
|
||||
markdown_options: &MarkdownOptions,
|
||||
strip_repeated_headers_footers: bool,
|
||||
preserve_ocr_candidates: bool,
|
||||
) -> Result<(PagesExtractionResult, u32), PdfError> {
|
||||
validate_pdf_bytes(buffer)?;
|
||||
let (doc, page_count) = load_document_from_mem(buffer)?;
|
||||
let (doc, page_count) = load_document_from_mem_with_password(buffer, password)?;
|
||||
let font_cmaps = FontCMaps::from_doc(&doc);
|
||||
|
||||
// Extract ALL pages to get accurate, document-wide font stats. A malformed
|
||||
@@ -502,6 +539,11 @@ pub fn extract_pages_markdown_mem(
|
||||
|
||||
// Compute font stats from full document (cross-page consistency).
|
||||
let font_stats = markdown::analysis::calculate_font_stats_from_items(&filtered_items);
|
||||
let repeated_header_footer_items = if strip_repeated_headers_footers {
|
||||
repeated_header_footer_item_keys(&all_items, &page_thresholds, &chart_regions, page_count)
|
||||
} else {
|
||||
HashSet::new()
|
||||
};
|
||||
|
||||
// When caller doesn't specify pages, return every page in document order.
|
||||
let all_pages: Vec<u32>;
|
||||
@@ -537,7 +579,10 @@ pub fn extract_pages_markdown_mem(
|
||||
let (page_items, page_number_removal_mask): (Vec<TextItem>, Vec<bool>) = all_items
|
||||
.iter()
|
||||
.zip(&page_number_removal_mask)
|
||||
.filter(|(item, _)| item.page == page_1idx)
|
||||
.filter(|(item, _)| {
|
||||
item.page == page_1idx
|
||||
&& !repeated_header_footer_items.contains(&HeaderFooterItemKey::from(*item))
|
||||
})
|
||||
.map(|(item, remove)| (item.clone(), *remove))
|
||||
.unzip();
|
||||
|
||||
@@ -547,6 +592,12 @@ pub fn extract_pages_markdown_mem(
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let page_lines: Vec<types::PdfLine> = all_lines
|
||||
.iter()
|
||||
.filter(|l| l.page == page_1idx)
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let has_gid = gid_pages.contains(&page_1idx);
|
||||
let has_text_quality_issue = text_quality.pages_needing_ocr.contains(&page_1idx);
|
||||
|
||||
@@ -574,7 +625,7 @@ pub fn extract_pages_markdown_mem(
|
||||
base_font_size: Some(font_stats.most_common_size),
|
||||
include_page_numbers: false,
|
||||
strip_headers_footers: false,
|
||||
..MarkdownOptions::default()
|
||||
..markdown_options.clone()
|
||||
};
|
||||
|
||||
let md = if has_text_quality_issue {
|
||||
@@ -584,7 +635,7 @@ pub fn extract_pages_markdown_mem(
|
||||
page_items,
|
||||
options,
|
||||
&page_rects,
|
||||
&[],
|
||||
&page_lines,
|
||||
markdown::MarkdownDocumentContext {
|
||||
page_thresholds: &page_thresholds,
|
||||
struct_roles: None,
|
||||
@@ -627,20 +678,143 @@ pub fn extract_pages_markdown_mem(
|
||||
|
||||
results.push(PageMarkdown {
|
||||
page: page_0idx,
|
||||
markdown: if needs_ocr { String::new() } else { md },
|
||||
// The public native extractor continues to suppress unreliable
|
||||
// text. The OCR orchestrator retains clean partial text
|
||||
// internally so it can compare/fuse it with OCR before deciding
|
||||
// what is safe to return.
|
||||
markdown: if needs_ocr && !preserve_ocr_candidates {
|
||||
String::new()
|
||||
} else {
|
||||
md
|
||||
},
|
||||
needs_ocr,
|
||||
ocr_reason,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(PagesExtractionResult {
|
||||
pages: results,
|
||||
pages_with_tables: complexity.pages_with_tables,
|
||||
pages_with_columns: complexity.pages_with_columns,
|
||||
pages_needing_ocr,
|
||||
ocr_reasons_by_page: page_ocr_reasons_vec(ocr_reasons_by_page),
|
||||
is_complex: complexity.is_complex,
|
||||
})
|
||||
Ok((
|
||||
PagesExtractionResult {
|
||||
pages: results,
|
||||
pages_with_tables: complexity.pages_with_tables,
|
||||
pages_with_columns: complexity.pages_with_columns,
|
||||
pages_needing_ocr,
|
||||
ocr_reasons_by_page: page_ocr_reasons_vec(ocr_reasons_by_page),
|
||||
is_complex: complexity.is_complex,
|
||||
},
|
||||
page_count,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct HeaderFooterItemKey {
|
||||
page: u32,
|
||||
x: u32,
|
||||
y: u32,
|
||||
text: String,
|
||||
}
|
||||
|
||||
impl From<&TextItem> for HeaderFooterItemKey {
|
||||
fn from(item: &TextItem) -> Self {
|
||||
Self {
|
||||
page: item.page,
|
||||
x: item.x.to_bits(),
|
||||
y: item.y.to_bits(),
|
||||
text: item.text.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn repeated_header_footer_item_keys(
|
||||
items: &[TextItem],
|
||||
page_thresholds: &HashMap<u32, f32>,
|
||||
chart_regions: &HashMap<u32, Vec<(f32, f32, f32, f32)>>,
|
||||
page_count: u32,
|
||||
) -> HashSet<HeaderFooterItemKey> {
|
||||
let candidates = items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
matches!(
|
||||
item.item_type,
|
||||
types::ItemType::Text | types::ItemType::FormField
|
||||
)
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
let lines = extractor::group_prefiltered_items_into_lines_with_thresholds_and_charts(
|
||||
candidates,
|
||||
page_thresholds,
|
||||
&HashSet::new(),
|
||||
chart_regions,
|
||||
);
|
||||
let all_items: HashSet<_> = lines
|
||||
.iter()
|
||||
.flat_map(|line| line.items.iter().map(HeaderFooterItemKey::from))
|
||||
.collect();
|
||||
let kept = markdown::strip_repeated_header_footer_lines(lines, page_count);
|
||||
let kept_items: HashSet<_> = kept
|
||||
.iter()
|
||||
.flat_map(|line| line.items.iter().map(HeaderFooterItemKey::from))
|
||||
.collect();
|
||||
all_items.difference(&kept_items).cloned().collect()
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "ocr", not(target_arch = "wasm32")))]
|
||||
mod ocr_header_footer_tests {
|
||||
use super::*;
|
||||
|
||||
fn item(page: u32, text: &str, y: f32) -> TextItem {
|
||||
TextItem {
|
||||
text: text.to_string(),
|
||||
x: 10.0,
|
||||
y,
|
||||
width: 120.0,
|
||||
height: 10.0,
|
||||
font: "Test".to_string(),
|
||||
font_size: 10.0,
|
||||
page,
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
is_underline: false,
|
||||
is_strikeout: false,
|
||||
item_type: types::ItemType::Text,
|
||||
mcid: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_pipeline_prefilters_document_wide_repeated_headers() {
|
||||
let mut items = Vec::new();
|
||||
let mut thresholds = HashMap::new();
|
||||
for page in 1..=3 {
|
||||
items.push(item(page, "Repeated report header", 800.0));
|
||||
for line in 0..12 {
|
||||
items.push(item(
|
||||
page,
|
||||
&format!("Page {page} paragraph {line} unique content"),
|
||||
700.0 - line as f32 * 40.0,
|
||||
));
|
||||
}
|
||||
thresholds.insert(page, 0.1);
|
||||
}
|
||||
|
||||
let removed = repeated_header_footer_item_keys(&items, &thresholds, &HashMap::new(), 3);
|
||||
assert_eq!(removed.len(), 2);
|
||||
for page in 1..=3 {
|
||||
assert_eq!(
|
||||
removed.contains(&HeaderFooterItemKey::from(&item(
|
||||
page,
|
||||
"Repeated report header",
|
||||
800.0,
|
||||
))),
|
||||
page > 1,
|
||||
);
|
||||
assert!(!removed.contains(&HeaderFooterItemKey::from(&item(
|
||||
page,
|
||||
&format!("Page {page} paragraph 5 unique content"),
|
||||
500.0,
|
||||
))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Path-based wrapper for [`extract_pages_markdown_mem`].
|
||||
@@ -757,6 +931,23 @@ pub struct PageRegionResult {
|
||||
pub regions: Vec<RegionText>,
|
||||
}
|
||||
|
||||
/// Minimum alphanumeric mass an invisible (Tr 3) text layer must carry for
|
||||
/// the OCR-layer fallback in [`extract_text_in_regions_mem`] to adopt it. A
|
||||
/// real OCR layer carries far more; a stray watermark or artifact does not.
|
||||
const OCR_LAYER_MIN_ALNUM: usize = 40;
|
||||
|
||||
/// Alphanumeric mass of extracted items, ignoring raster placeholders.
|
||||
/// `[Image: ...]` items (ItemType::Image) are synthesized for image
|
||||
/// XObjects — they mark that pixels exist, not that text was read, so they
|
||||
/// must not count as coverage.
|
||||
fn non_placeholder_alnum(items: &[TextItem]) -> usize {
|
||||
items
|
||||
.iter()
|
||||
.filter(|it| !matches!(it.item_type, types::ItemType::Image))
|
||||
.map(|it| it.text.chars().filter(|c| c.is_alphanumeric()).count())
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// Extract text within bounding-box regions from a PDF in memory.
|
||||
///
|
||||
/// This is designed for hybrid OCR pipelines: a layout model detects regions
|
||||
@@ -809,8 +1000,11 @@ pub fn extract_text_in_regions_mem(
|
||||
let height = get_page_height(&doc, page_id).unwrap_or(792.0);
|
||||
page_heights.insert(*page_num, height);
|
||||
|
||||
// Extract text items for this page
|
||||
let ((mut items, _rects, _lines), has_gid, coords_rotated) =
|
||||
// Extract text items for this page. The Form XObject budget is shared
|
||||
// with the invisible-layer retry below so one page cannot consume two
|
||||
// full expansion budgets.
|
||||
let mut form_budget = extractor::FormWalkBudget::new();
|
||||
let ((mut items, _rects, _lines), mut has_gid, mut coords_rotated, skipped_invisible) =
|
||||
extractor::content_stream::extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
@@ -818,7 +1012,54 @@ pub fn extract_text_in_regions_mem(
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut style_cache,
|
||||
&mut form_budget,
|
||||
)?;
|
||||
// OCR-layer fallback: scanned pages often carry their text as an
|
||||
// invisible (Tr 3) layer behind the page raster. The visible-only
|
||||
// pass sees nothing there but `[Image: ...]` placeholders, so every
|
||||
// region on the page reports needs_ocr even though the exact text is
|
||||
// embedded in the PDF — and this extractor then disagrees with the
|
||||
// markdown path, which already retries Mixed PDFs with the invisible
|
||||
// layer included. Retry page-scoped, and only when (a) the first
|
||||
// pass actually SKIPPED invisible text — blank pages and image-only
|
||||
// scans without an OCR layer must not pay a second content-stream
|
||||
// parse (review catch) — and (b) the page has NO visible text item
|
||||
// at all (punctuation counts, whitespace-only artifacts don't): an
|
||||
// invisible OCR layer transcribes the raster, so any visible glyph
|
||||
// has an invisible twin there and adoption would duplicate it
|
||||
// (review catches — strict gate, no fuzzy dedupe). Adopt the retry
|
||||
// only when it contributes real, non-garbage text.
|
||||
let has_visible_text = items.iter().any(|it| {
|
||||
!matches!(it.item_type, types::ItemType::Image) && !it.text.trim().is_empty()
|
||||
});
|
||||
if skipped_invisible && !has_visible_text {
|
||||
if let Ok(((inv_items, _inv_rects, _inv_lines), inv_gid, inv_rotated, _)) =
|
||||
extractor::content_stream::extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
*page_num,
|
||||
&font_cmaps,
|
||||
true,
|
||||
&mut style_cache,
|
||||
&mut form_budget,
|
||||
)
|
||||
{
|
||||
let inv_alnum = non_placeholder_alnum(&inv_items);
|
||||
// Judge the WHOLE recovered layer, not a prefix — a broken
|
||||
// OCR layer can hide its garbage past any fixed sample size
|
||||
// (review catch).
|
||||
let sample: String = inv_items
|
||||
.iter()
|
||||
.filter(|it| !matches!(it.item_type, types::ItemType::Image))
|
||||
.map(|it| it.text.as_str())
|
||||
.collect();
|
||||
if inv_alnum >= OCR_LAYER_MIN_ALNUM && !is_garbage_text(&sample) {
|
||||
items = inv_items;
|
||||
has_gid = inv_gid;
|
||||
coords_rotated = inv_rotated;
|
||||
}
|
||||
}
|
||||
}
|
||||
let threshold = text_utils::fix_letterspaced_items(&mut items);
|
||||
if threshold > 0.10 {
|
||||
page_thresholds.insert(*page_num, threshold);
|
||||
@@ -975,7 +1216,7 @@ pub fn extract_tables_in_regions_mem(
|
||||
let height = get_page_height(&doc, page_id).unwrap_or(792.0);
|
||||
page_heights.insert(*page_num, height);
|
||||
|
||||
let ((mut items, rects, lines), has_gid, coords_rotated) =
|
||||
let ((mut items, rects, lines), has_gid, coords_rotated, _skipped_invisible) =
|
||||
extractor::content_stream::extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
@@ -983,6 +1224,7 @@ pub fn extract_tables_in_regions_mem(
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut style_cache,
|
||||
&mut extractor::FormWalkBudget::new(),
|
||||
)?;
|
||||
let threshold = text_utils::fix_letterspaced_items(&mut items);
|
||||
if threshold > 0.10 {
|
||||
@@ -1286,7 +1528,7 @@ pub fn detect_vector_grid_in_region_mem(
|
||||
let needed_pages = HashSet::from([page_1idx]);
|
||||
let font_cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed_pages));
|
||||
let page_h = get_page_height(&doc, page_id).unwrap_or(792.0);
|
||||
let ((mut items, rects, lines), _has_gid, coords_rotated) =
|
||||
let ((mut items, rects, lines), _has_gid, coords_rotated, _skipped_invisible) =
|
||||
extractor::content_stream::extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
@@ -1294,6 +1536,7 @@ pub fn detect_vector_grid_in_region_mem(
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut extractor::FontStyleCache::new(),
|
||||
&mut extractor::FormWalkBudget::new(),
|
||||
)?;
|
||||
text_utils::fix_letterspaced_items(&mut items);
|
||||
|
||||
@@ -1480,15 +1723,17 @@ mod vector_grid_tests {
|
||||
let &page_id = pages.get(&1).unwrap();
|
||||
let needed: HashSet<u32> = HashSet::from([1]);
|
||||
let cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed));
|
||||
let ((items, rects, _lines), _has_gid, _rotated) = extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
1,
|
||||
&cmaps,
|
||||
false,
|
||||
&mut crate::extractor::FontStyleCache::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let ((items, rects, _lines), _has_gid, _rotated, _skipped_invisible) =
|
||||
extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
1,
|
||||
&cmaps,
|
||||
false,
|
||||
&mut crate::extractor::FontStyleCache::new(),
|
||||
&mut crate::extractor::FormWalkBudget::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (rect_tables, _) = detect_tables_from_rects(&items, &rects, 1);
|
||||
assert_eq!(rect_tables.len(), 1, "expected one rect-detected table");
|
||||
@@ -1522,15 +1767,17 @@ mod vector_grid_tests {
|
||||
let &page_id = pages.get(&page_num).unwrap();
|
||||
let needed: HashSet<u32> = HashSet::from([page_num]);
|
||||
let cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed));
|
||||
let ((items, rects, _lines), _has_gid, _rotated) = extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
page_num,
|
||||
&cmaps,
|
||||
false,
|
||||
&mut crate::extractor::FontStyleCache::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let ((items, rects, _lines), _has_gid, _rotated, _skipped_invisible) =
|
||||
extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
page_num,
|
||||
&cmaps,
|
||||
false,
|
||||
&mut crate::extractor::FontStyleCache::new(),
|
||||
&mut crate::extractor::FormWalkBudget::new(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let (rect_tables, _) = detect_tables_from_rects(&items, &rects, page_num);
|
||||
rect_tables
|
||||
@@ -2258,7 +2505,7 @@ pub fn extract_tables_with_structure_cells_mem(
|
||||
let height = get_page_height(&doc, page_id).unwrap_or(792.0);
|
||||
page_heights.insert(*page_num, height);
|
||||
|
||||
let ((mut items, _rects, _lines), _has_gid, coords_rotated) =
|
||||
let ((mut items, _rects, _lines), _has_gid, coords_rotated, _skipped_invisible) =
|
||||
extractor::content_stream::extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
@@ -2266,6 +2513,7 @@ pub fn extract_tables_with_structure_cells_mem(
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut style_cache,
|
||||
&mut extractor::FormWalkBudget::new(),
|
||||
)?;
|
||||
let threshold = text_utils::fix_letterspaced_items(&mut items);
|
||||
if threshold > 0.10 {
|
||||
@@ -3060,7 +3308,7 @@ fn detect_tsr_quality_issue(
|
||||
let mut needed: HashSet<u32> = HashSet::new();
|
||||
needed.insert(page_1idx);
|
||||
let font_cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed));
|
||||
let ((mut items, _rects, _lines), _has_gid, coords_rotated) =
|
||||
let ((mut items, _rects, _lines), _has_gid, coords_rotated, _skipped_invisible) =
|
||||
extractor::content_stream::extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
@@ -3068,6 +3316,7 @@ fn detect_tsr_quality_issue(
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut extractor::FontStyleCache::new(),
|
||||
&mut extractor::FormWalkBudget::new(),
|
||||
)?;
|
||||
let adaptive_threshold = text_utils::fix_letterspaced_items(&mut items);
|
||||
let coords = if coords_rotated {
|
||||
|
||||
@@ -203,9 +203,42 @@ pub(crate) fn is_code_like(text: &str) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// True when a line's text is essentially all monospace (≥90% by character
|
||||
/// count). Code lines are wholly monospace; anything less is prose carrying
|
||||
/// mono-styled fragments — a URL sidebar, or a sentence quoting an inline
|
||||
/// code literal — and fencing it would split paragraphs mid-sentence.
|
||||
/// Any-item matching was safe only while items carried opaque font resource
|
||||
/// names that never matched the monospace patterns; items now carry real
|
||||
/// family names.
|
||||
pub(crate) fn line_is_monospace(line: &crate::types::TextLine) -> bool {
|
||||
let mut monospace_chars = 0usize;
|
||||
let mut total_chars = 0usize;
|
||||
for item in &line.items {
|
||||
let text = item.text.trim();
|
||||
let chars = text.chars().count();
|
||||
total_chars += chars;
|
||||
// Hyperlinks and underlined text set in a mono face are link
|
||||
// styling, not code — a URL sidebar must not fence lyric lines.
|
||||
let looks_like_link = item.is_underline
|
||||
|| matches!(item.item_type, crate::types::ItemType::Link(_))
|
||||
|| text.contains("://")
|
||||
|| text.starts_with("www.");
|
||||
if is_monospace_font(&item.font) && !looks_like_link {
|
||||
monospace_chars += chars;
|
||||
}
|
||||
}
|
||||
total_chars > 0 && monospace_chars * 10 >= total_chars * 9
|
||||
}
|
||||
|
||||
/// Check if font name indicates monospace
|
||||
pub(crate) fn is_monospace_font(font_name: &str) -> bool {
|
||||
let lower = font_name.to_lowercase();
|
||||
// "Monotype" is a foundry prefix on proportional faces (Monotype
|
||||
// Corsiva, Monotype Garamond) — it must not satisfy the generic "mono"
|
||||
// token below.
|
||||
if lower.contains("monotype") {
|
||||
return false;
|
||||
}
|
||||
let patterns = [
|
||||
"courier",
|
||||
"consolas",
|
||||
@@ -230,6 +263,17 @@ pub(crate) fn is_monospace_font(font_name: &str) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn monotype_foundry_faces_are_not_monospace() {
|
||||
// "Monotype" is a foundry prefix on proportional faces; the generic
|
||||
// "mono" token must not classify them as code fonts.
|
||||
assert!(!is_monospace_font("MonotypeCorsiva"));
|
||||
assert!(!is_monospace_font("ABCDEF+Monotype-Garamond"));
|
||||
assert!(is_monospace_font("RobotoMono-Regular"));
|
||||
assert!(is_monospace_font("PTMono"));
|
||||
assert!(is_monospace_font("Courier"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_list_item_plain_bullet() {
|
||||
assert_eq!(format_list_item("● Item"), "- Item");
|
||||
|
||||
+52
-28
@@ -11,9 +11,7 @@ use super::analysis::{
|
||||
detect_header_level, font_size_rarity, has_dot_leaders, is_heading_fragment, is_toc_entry_line,
|
||||
is_toc_marker_heading,
|
||||
};
|
||||
use super::classify::{
|
||||
format_list_item, is_caption_line, is_list_item, is_monospace_font, starts_with_bullet_marker,
|
||||
};
|
||||
use super::classify::{format_list_item, is_caption_line, is_list_item, starts_with_bullet_marker};
|
||||
use super::heading::classify_heading_sequences;
|
||||
use super::postprocess::clean_markdown;
|
||||
use super::preprocess::{merge_drop_caps, merge_heading_lines};
|
||||
@@ -771,7 +769,27 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
let mut in_list = false;
|
||||
let mut in_paragraph = false;
|
||||
let mut last_list_x: Option<f32> = None;
|
||||
// Code lines accumulate here and the fence is emitted only when the
|
||||
// block flushes with content — an empty ``` ``` pair can never appear.
|
||||
fn flush_code_block(output: &mut String, pending_code: &mut String) {
|
||||
let trimmed = pending_code.trim();
|
||||
// A fragment too short to be code — a lone ® or stray glyph set in
|
||||
// a mono face — reads better as plain text than as a fenced block.
|
||||
if trimmed.chars().count() < 3 {
|
||||
if !trimmed.is_empty() {
|
||||
output.push_str(trimmed);
|
||||
output.push_str("\n\n");
|
||||
}
|
||||
} else {
|
||||
output.push_str("```\n");
|
||||
output.push_str(pending_code);
|
||||
output.push_str("```\n");
|
||||
}
|
||||
pending_code.clear();
|
||||
}
|
||||
|
||||
let mut in_code_block = false;
|
||||
let mut pending_code = String::new();
|
||||
let mut prev_had_dot_leaders = false;
|
||||
let mut paragraph_in_wrapped_bold_run = false;
|
||||
let mut toc_suppress_page: Option<u32> = None;
|
||||
@@ -805,7 +823,7 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
// Flush current page's remaining tables and images
|
||||
if current_page > 0 {
|
||||
if in_code_block {
|
||||
output.push_str("```\n");
|
||||
flush_code_block(&mut output, &mut pending_code);
|
||||
in_code_block = false;
|
||||
}
|
||||
flush_page_tables_and_images(
|
||||
@@ -867,6 +885,14 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
PositionedBlockKind::Image => inserted_images.contains(&(current_page, idx)),
|
||||
};
|
||||
if positioned_block_precedes_line(block, line) && !already_inserted {
|
||||
// Code lines buffer until their block closes; flush them
|
||||
// first so this block cannot jump ahead of code that
|
||||
// precedes it in reading order. A code line after the
|
||||
// block reopens a new fence naturally.
|
||||
if in_code_block {
|
||||
flush_code_block(&mut output, &mut pending_code);
|
||||
in_code_block = false;
|
||||
}
|
||||
if in_paragraph {
|
||||
output.push_str("\n\n");
|
||||
in_paragraph = false;
|
||||
@@ -937,15 +963,22 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
// These should be on their own line followed by a paragraph break
|
||||
let struct_role = struct_roles.and_then(|roles| resolve_line_struct_role(line, roles));
|
||||
|
||||
// Determine if this line is code (struct-tree or font-based) for block accumulation
|
||||
// Determine if this line is code (struct-tree or font-based) for
|
||||
// block accumulation. Font-based detection only opens a block at a
|
||||
// paragraph boundary: a mono-set line that continues an open prose
|
||||
// paragraph is the producer smearing an inline code literal's style
|
||||
// across a wrapped line (HTML-to-PDF exports do this), and fencing
|
||||
// it would cut the sentence in three.
|
||||
let is_code_line = struct_role
|
||||
.as_ref()
|
||||
.is_some_and(|r| matches!(r, StructRole::Code))
|
||||
|| (options.detect_code && line.items.iter().any(|i| is_monospace_font(&i.font)));
|
||||
|| (options.detect_code
|
||||
&& (in_code_block || !in_paragraph)
|
||||
&& super::classify::line_is_monospace(line));
|
||||
|
||||
// Close code block when transitioning to non-code
|
||||
if in_code_block && !is_code_line {
|
||||
output.push_str("```\n");
|
||||
flush_code_block(&mut output, &mut pending_code);
|
||||
in_code_block = false;
|
||||
}
|
||||
|
||||
@@ -1179,12 +1212,9 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
in_paragraph = false;
|
||||
paragraph_in_wrapped_bold_run = false;
|
||||
}
|
||||
if !in_code_block {
|
||||
output.push_str("```\n");
|
||||
in_code_block = true;
|
||||
}
|
||||
output.push_str(plain_trimmed);
|
||||
output.push('\n');
|
||||
in_code_block = true;
|
||||
pending_code.push_str(plain_trimmed);
|
||||
pending_code.push('\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -1209,7 +1239,7 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
|
||||
// Close any trailing code block
|
||||
if in_code_block {
|
||||
output.push_str("```\n");
|
||||
flush_code_block(&mut output, &mut pending_code);
|
||||
}
|
||||
|
||||
// Flush current page and any remaining pages with tables/images
|
||||
@@ -1370,7 +1400,7 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
|
||||
&& !is_toc_entry_line(plain_trimmed)
|
||||
&& !is_heading_fragment(plain_trimmed)
|
||||
&& toc_suppress_page != Some(line.page)
|
||||
&& !(options.detect_code && line.items.iter().any(|i| is_monospace_font(&i.font)))
|
||||
&& !(options.detect_code && super::classify::line_is_monospace(line))
|
||||
{
|
||||
let line_font_size = line.items.first().map(|i| i.font_size).unwrap_or(base_size);
|
||||
if let Some(header_level) = detect_header_level(
|
||||
@@ -1471,19 +1501,13 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
|
||||
}
|
||||
}
|
||||
|
||||
// Detect code blocks by font
|
||||
if options.detect_code {
|
||||
let is_mono = line.items.iter().any(|i| is_monospace_font(&i.font));
|
||||
if is_mono {
|
||||
if in_paragraph {
|
||||
output.push_str("\n\n");
|
||||
in_paragraph = false;
|
||||
paragraph_in_wrapped_bold_run = false;
|
||||
}
|
||||
// Use plain text for code blocks
|
||||
output.push_str(&format!("```\n{}\n```\n", plain_trimmed));
|
||||
continue;
|
||||
}
|
||||
// Detect code blocks by font. Only at a paragraph boundary — a
|
||||
// mono-set line continuing an open prose paragraph is an inline
|
||||
// code literal's style smeared across a wrapped line, not code.
|
||||
if options.detect_code && !in_paragraph && super::classify::line_is_monospace(line) {
|
||||
// Use plain text for code blocks
|
||||
output.push_str(&format!("```\n{}\n```\n", plain_trimmed));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular text - join lines within same paragraph with space
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+351
-7
@@ -9,6 +9,7 @@
|
||||
pub(crate) mod analysis;
|
||||
mod classify;
|
||||
mod convert;
|
||||
mod furniture;
|
||||
mod heading;
|
||||
mod postprocess;
|
||||
mod preprocess;
|
||||
@@ -453,6 +454,110 @@ fn merged_retry_skips_body_font(detected_columns: bool, has_chart_regions: bool)
|
||||
detected_columns && !has_chart_regions
|
||||
}
|
||||
|
||||
/// Identity of a piece of page furniture: the same trimmed text drawn at the
|
||||
/// same position (quantized to 0.5pt) — page numbers excluded by construction
|
||||
/// because their text differs per page.
|
||||
type FurnitureKey = (String, i32, i32);
|
||||
|
||||
fn furniture_key(item: &TextItem) -> FurnitureKey {
|
||||
(
|
||||
item.text.trim().to_string(),
|
||||
(item.x * 2.0).round() as i32,
|
||||
(item.y * 2.0).round() as i32,
|
||||
)
|
||||
}
|
||||
|
||||
/// Minimum distinct pages an identical (text, position) must appear on before
|
||||
/// it counts as a running header/footer rather than coincidence.
|
||||
const RUNNING_FURNITURE_MIN_PAGES: usize = 3;
|
||||
|
||||
/// Fraction of each page's vertical content extent, at the top and at the
|
||||
/// bottom, where running furniture may live. Repetition alone is not enough:
|
||||
/// a form template repeated per record carries identical labels at identical
|
||||
/// mid-page coordinates on every page, and those are real table cells. What
|
||||
/// makes a header/footer is repetition *at the page edge*.
|
||||
const RUNNING_FURNITURE_BAND: f32 = 0.2;
|
||||
|
||||
/// Collect the keys of items that repeat verbatim at the same position on at
|
||||
/// least [`RUNNING_FURNITURE_MIN_PAGES`] distinct pages, restricted to the
|
||||
/// top/bottom [`RUNNING_FURNITURE_BAND`] of each page's content extent —
|
||||
/// running headers and footers. Single- and two-page documents produce an
|
||||
/// empty set.
|
||||
fn running_furniture_keys(items: &[TextItem]) -> HashSet<FurnitureKey> {
|
||||
// Vertical content extent per page, so the edge bands adapt to the
|
||||
// document's real margins instead of assuming a media box.
|
||||
let mut page_extent: HashMap<u32, (f32, f32)> = HashMap::new();
|
||||
for item in items {
|
||||
if item.text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let entry = page_extent.entry(item.page).or_insert((item.y, item.y));
|
||||
entry.0 = entry.0.min(item.y);
|
||||
entry.1 = entry.1.max(item.y);
|
||||
}
|
||||
|
||||
let mut pages_by_key: HashMap<FurnitureKey, HashSet<u32>> = HashMap::new();
|
||||
for item in items {
|
||||
if item.text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Some(&(min_y, max_y)) = page_extent.get(&item.page) else {
|
||||
continue;
|
||||
};
|
||||
// A page whose text has no vertical span gives no evidence of where
|
||||
// its edges are — without this guard, a zero band would classify its
|
||||
// every item as edge furniture.
|
||||
let extent = max_y - min_y;
|
||||
if extent <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let band = extent * RUNNING_FURNITURE_BAND;
|
||||
if item.y > min_y + band && item.y < max_y - band {
|
||||
continue; // mid-page: never furniture, however often it repeats
|
||||
}
|
||||
pages_by_key
|
||||
.entry(furniture_key(item))
|
||||
.or_default()
|
||||
.insert(item.page);
|
||||
}
|
||||
pages_by_key
|
||||
.into_iter()
|
||||
.filter(|(_, pages)| pages.len() >= RUNNING_FURNITURE_MIN_PAGES)
|
||||
.map(|(key, _)| key)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Reject a heuristic table whose items are almost entirely running
|
||||
/// headers/footers. A wrapped document title repeated at the bottom of every
|
||||
/// page aligns well enough to read as a grid, but it is page furniture, not
|
||||
/// data — vetoing the table lets the text flow as prose instead. Real tables
|
||||
/// carry per-page content, so even a repeated *header row* stays under the
|
||||
/// threshold once its body rows differ.
|
||||
fn is_running_furniture_table(
|
||||
detection_items: &[TextItem],
|
||||
table: &crate::tables::Table,
|
||||
running: &HashSet<FurnitureKey>,
|
||||
) -> bool {
|
||||
if running.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let mut total = 0usize;
|
||||
let mut furniture = 0usize;
|
||||
for &idx in &table.item_indices {
|
||||
let Some(item) = detection_items.get(idx) else {
|
||||
continue;
|
||||
};
|
||||
if item.text.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
total += 1;
|
||||
if running.contains(&furniture_key(item)) {
|
||||
furniture += 1;
|
||||
}
|
||||
}
|
||||
total > 0 && (furniture as f32) >= (total as f32) * 0.8
|
||||
}
|
||||
|
||||
/// Reject a heuristic table only when its cells are overwhelmingly parallel
|
||||
/// prose fragments. This is deliberately narrower than disabling body-font
|
||||
/// detection for the whole page: numeric, compact, headed, and otherwise
|
||||
@@ -530,7 +635,12 @@ fn is_parallel_prose_table(table: &crate::tables::Table) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
let is_parallel = !has_compact_header
|
||||
// A compact header row is evidence for a real table — unless cross-row
|
||||
// prose continuations outnumber the rows, which no genuine table
|
||||
// produces: the "header" is then just two short line fragments at the
|
||||
// top of parallel prose columns.
|
||||
let header_blocks = has_compact_header && continuation_fragments <= table.cells.len();
|
||||
let is_parallel = !header_blocks
|
||||
&& non_empty >= 5
|
||||
// Independent prose columns have asynchronous line/paragraph breaks;
|
||||
// a fully populated grid is positive evidence for a real descriptive
|
||||
@@ -1040,6 +1150,14 @@ pub fn to_markdown(text: &str, options: MarkdownOptions) -> String {
|
||||
output
|
||||
}
|
||||
|
||||
/// Applies the document-wide repeated header/footer classifier to grouped lines.
|
||||
pub(crate) fn strip_repeated_header_footer_lines(
|
||||
lines: Vec<crate::types::TextLine>,
|
||||
page_count: u32,
|
||||
) -> Vec<crate::types::TextLine> {
|
||||
furniture::strip_header_footer_lines(lines, page_count)
|
||||
}
|
||||
|
||||
/// Convert positioned text items to markdown with structure detection
|
||||
pub fn to_markdown_from_items(items: Vec<TextItem>, options: MarkdownOptions) -> String {
|
||||
to_markdown_from_items_with_rects(items, options, &[])
|
||||
@@ -1188,6 +1306,12 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
let mut table_items: HashSet<usize> = HashSet::new();
|
||||
let mut page_tables: HashMap<u32, Vec<PositionedMarkdown>> = HashMap::new();
|
||||
|
||||
// Running headers/footers repeat verbatim at the same position on many
|
||||
// pages. When such a block wraps a long title over aligned lines, the
|
||||
// heuristic detector reads it as a table. Knowing which items are page
|
||||
// furniture is a document-wide question, so answer it once here.
|
||||
let running_furniture = running_furniture_keys(&text_items);
|
||||
|
||||
// Pre-group items by page with their global indices (O(n) instead of O(pages*n))
|
||||
let mut page_groups: HashMap<u32, Vec<(usize, &TextItem)>> = HashMap::new();
|
||||
for (global_idx, item) in text_items.iter().enumerate() {
|
||||
@@ -1256,7 +1380,6 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
chart_page_prose_column_split(&page_layout_items)
|
||||
.filter(|&split_x| chart_spans_prose_split(region, split_x))
|
||||
});
|
||||
let chart_prose_columns = chart_prose_split.is_some();
|
||||
|
||||
// Check for side-by-side table layout using the original items. Sparse
|
||||
// numeric cells need table context before they can be distinguished
|
||||
@@ -1497,10 +1620,16 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
if subset_items.len() < min_items {
|
||||
return;
|
||||
}
|
||||
// Keep body-font detection available on chart pages: a real
|
||||
// table can share the prose anchors. Reject only candidates
|
||||
// whose cells prove they are parallel prose fragments.
|
||||
let reject_parallel_prose = chart_prose_columns && !was_split;
|
||||
// Reject candidates whose cells prove they are parallel
|
||||
// prose fragments — the shape produced when the body-font
|
||||
// pass projects a multi-column text page onto one table
|
||||
// grid (two-column reference sections are the classic
|
||||
// case). The check needs internal transition evidence
|
||||
// (unterminated cells flowing into lowercase starts in
|
||||
// the same column), so genuine tables with long cells
|
||||
// pass. Band-split retries stay exempt: they exist for
|
||||
// tables that only assemble after recombining bands.
|
||||
let reject_parallel_prose = !was_split;
|
||||
let tables = detect_tables_with_page_width(
|
||||
subset_items,
|
||||
base_size,
|
||||
@@ -1517,6 +1646,15 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if is_running_furniture_table(subset_items, &table, &running_furniture) {
|
||||
log::debug!(
|
||||
"page {}: rejected {}x{} running header/footer table hypothesis",
|
||||
page,
|
||||
table.rows.len(),
|
||||
table.columns.len()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for &idx in &table.item_indices {
|
||||
if let Some(&band_idx) = index_map.get(idx) {
|
||||
if let Some(&page_idx) = band_index_map.get(band_idx) {
|
||||
@@ -1703,6 +1841,15 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if is_running_furniture_table(&chart_free, table, &running_furniture) {
|
||||
log::debug!(
|
||||
"page {}: rejected {}x{} merged-band running header/footer table hypothesis",
|
||||
page,
|
||||
table.rows.len(),
|
||||
table.columns.len()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for &idx in &table.item_indices {
|
||||
if let Some(&page_idx) = chart_free_map
|
||||
.get(idx)
|
||||
@@ -1970,7 +2117,7 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
|
||||
// Strip repeated headers/footers before conversion
|
||||
let lines = if options.strip_headers_footers {
|
||||
preprocess::strip_repeated_lines(lines, document_page_count)
|
||||
furniture::strip_header_footer_lines(lines, document_page_count)
|
||||
} else {
|
||||
lines
|
||||
};
|
||||
@@ -2058,6 +2205,174 @@ mod tests {
|
||||
assert!(md.contains("- Second item"));
|
||||
}
|
||||
|
||||
fn furniture_item(text: &str, x: f32, y: f32, page: u32) -> TextItem {
|
||||
let mut it = make_item(x, y, page);
|
||||
it.text = text.into();
|
||||
it
|
||||
}
|
||||
|
||||
/// Items repeating verbatim at the same position on 3+ pages are running
|
||||
/// furniture; the same text on fewer pages, or at different positions, is
|
||||
/// not.
|
||||
#[test]
|
||||
fn running_furniture_requires_three_pages_at_same_position() {
|
||||
let mut items = Vec::new();
|
||||
for page in 1..=3 {
|
||||
// Body content so each page has a real vertical extent.
|
||||
items.push(furniture_item("body", 85.0, 700.0, page));
|
||||
items.push(furniture_item("TITULAR DEL", 85.0, 68.0, page));
|
||||
}
|
||||
// Same text but only two pages.
|
||||
for page in 1..=2 {
|
||||
items.push(furniture_item("SECRETARÍA", 200.0, 68.0, page));
|
||||
}
|
||||
// Same text on three pages but at drifting positions.
|
||||
for (page, x) in [(1, 300.0), (2, 320.0), (3, 340.0)] {
|
||||
items.push(furniture_item("MÉXICO", x, 68.0, page));
|
||||
}
|
||||
|
||||
let running = running_furniture_keys(&items);
|
||||
assert!(running.contains(&furniture_key(&furniture_item(
|
||||
"TITULAR DEL",
|
||||
85.0,
|
||||
68.0,
|
||||
1
|
||||
))));
|
||||
assert!(!running.contains(&furniture_key(&furniture_item(
|
||||
"SECRETARÍA",
|
||||
200.0,
|
||||
68.0,
|
||||
1
|
||||
))));
|
||||
assert!(!running.contains(&furniture_key(&furniture_item("MÉXICO", 300.0, 68.0, 1))));
|
||||
}
|
||||
|
||||
/// A table made of running-footer items is vetoed; a table whose body rows
|
||||
/// carry per-page content is kept even when its header row repeats.
|
||||
#[test]
|
||||
fn running_furniture_table_veto() {
|
||||
// The footer block, present identically on pages 1-3.
|
||||
let mut items = Vec::new();
|
||||
for page in 1..=3 {
|
||||
items.push(furniture_item("PROPOSICIÓN CON PUNTO", 85.0, 78.5, page));
|
||||
items.push(furniture_item("EL SENADO", 286.6, 78.5, page));
|
||||
items.push(furniture_item("TITULAR DEL", 85.0, 68.0, page));
|
||||
items.push(furniture_item("A TRAVÉS DE LA", 243.4, 68.0, page));
|
||||
}
|
||||
// A real table on page 1: repeated header row, per-page data rows.
|
||||
let header = [
|
||||
furniture_item("Year", 85.0, 500.0, 1),
|
||||
furniture_item("Total", 200.0, 500.0, 1),
|
||||
];
|
||||
let data = [
|
||||
furniture_item("2023", 85.0, 488.0, 1),
|
||||
furniture_item("1,204", 200.0, 488.0, 1),
|
||||
furniture_item("2024", 85.0, 476.0, 1),
|
||||
furniture_item("1,377", 200.0, 476.0, 1),
|
||||
];
|
||||
// Header repeats on every page (like a continued table's header).
|
||||
for page in 2..=3 {
|
||||
items.push(furniture_item("Year", 85.0, 500.0, page));
|
||||
items.push(furniture_item("Total", 200.0, 500.0, page));
|
||||
}
|
||||
items.extend(header.iter().cloned());
|
||||
items.extend(data.iter().cloned());
|
||||
|
||||
let running = running_furniture_keys(&items);
|
||||
|
||||
let table_of = |detection_items: &[TextItem]| crate::tables::Table {
|
||||
columns: vec![],
|
||||
rows: vec![],
|
||||
cells: vec![],
|
||||
item_indices: (0..detection_items.len()).collect(),
|
||||
kind: crate::tables::TableKind::Data,
|
||||
};
|
||||
|
||||
// Footer-only candidate: every item is furniture -> vetoed.
|
||||
let footer_items: Vec<TextItem> = (1..=1)
|
||||
.flat_map(|page| {
|
||||
vec![
|
||||
furniture_item("PROPOSICIÓN CON PUNTO", 85.0, 78.5, page),
|
||||
furniture_item("EL SENADO", 286.6, 78.5, page),
|
||||
furniture_item("TITULAR DEL", 85.0, 68.0, page),
|
||||
furniture_item("A TRAVÉS DE LA", 243.4, 68.0, page),
|
||||
]
|
||||
})
|
||||
.collect();
|
||||
assert!(is_running_furniture_table(
|
||||
&footer_items,
|
||||
&table_of(&footer_items),
|
||||
&running
|
||||
));
|
||||
|
||||
// Real table: header row repeats across pages, body rows do not ->
|
||||
// 2 furniture of 6 items (33%) stays under the 80% threshold.
|
||||
let real_items: Vec<TextItem> =
|
||||
header.iter().cloned().chain(data.iter().cloned()).collect();
|
||||
assert!(!is_running_furniture_table(
|
||||
&real_items,
|
||||
&table_of(&real_items),
|
||||
&running
|
||||
));
|
||||
}
|
||||
|
||||
/// A form template repeated per record carries identical labels at
|
||||
/// identical mid-page coordinates on every page — those are real table
|
||||
/// cells, not furniture. Only the page-edge bands qualify.
|
||||
#[test]
|
||||
fn mid_page_repetition_is_not_furniture() {
|
||||
let mut items = Vec::new();
|
||||
for page in 1..=4 {
|
||||
// Content spanning the page: y 60 (bottom) to 740 (top).
|
||||
items.push(furniture_item("body top", 85.0, 740.0, page));
|
||||
items.push(furniture_item("body bottom", 85.0, 60.0, page));
|
||||
// Form labels repeated dead centre on every page.
|
||||
items.push(furniture_item("Name of creditor", 85.0, 400.0, page));
|
||||
items.push(furniture_item("Amount of claim", 300.0, 400.0, page));
|
||||
// A genuine footer inside the bottom band.
|
||||
items.push(furniture_item("FORM 78 — page footer", 85.0, 70.0, page));
|
||||
}
|
||||
|
||||
let running = running_furniture_keys(&items);
|
||||
assert!(
|
||||
!running.contains(&furniture_key(&furniture_item(
|
||||
"Name of creditor",
|
||||
85.0,
|
||||
400.0,
|
||||
1
|
||||
))),
|
||||
"mid-page form labels must not be furniture"
|
||||
);
|
||||
assert!(running.contains(&furniture_key(&furniture_item(
|
||||
"FORM 78 — page footer",
|
||||
85.0,
|
||||
70.0,
|
||||
1
|
||||
))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn running_furniture_empty_on_short_documents() {
|
||||
let mut items = Vec::new();
|
||||
for page in 1..=2 {
|
||||
items.push(furniture_item("body", 85.0, 700.0, page));
|
||||
items.push(furniture_item("FOOTER", 85.0, 68.0, page));
|
||||
}
|
||||
assert!(running_furniture_keys(&items).is_empty());
|
||||
}
|
||||
|
||||
/// A page whose text has no vertical span (a single line) gives no
|
||||
/// evidence of where its edges are; its items never become furniture.
|
||||
#[test]
|
||||
fn zero_span_page_contributes_no_furniture() {
|
||||
let mut items = Vec::new();
|
||||
for page in 1..=4 {
|
||||
items.push(furniture_item("ROW LABEL", 85.0, 400.0, page));
|
||||
items.push(furniture_item("ROW VALUE", 300.0, 400.0, page));
|
||||
}
|
||||
assert!(running_furniture_keys(&items).is_empty());
|
||||
}
|
||||
|
||||
fn make_item(x: f32, y: f32, page: u32) -> TextItem {
|
||||
TextItem {
|
||||
text: "A".into(),
|
||||
@@ -2369,6 +2684,35 @@ mod tests {
|
||||
);
|
||||
assert!(!is_parallel_prose_table(&data));
|
||||
|
||||
// A compact header row atop parallel prose columns: cross-row prose
|
||||
// continuations outnumber the rows, so the header cannot save the
|
||||
// candidate — this is page prose with two short fragments on top.
|
||||
let headed_parallel_prose = crate::tables::Table::new(
|
||||
vec![90.0, 340.0],
|
||||
vec![340.0, 320.0, 300.0, 280.0, 260.0],
|
||||
vec![
|
||||
vec!["June 2023".into(), "Page 5".into()],
|
||||
vec![
|
||||
"the committee reviewed the proposal and decided that the".into(),
|
||||
"funding for the second phase would continue subject to the".into(),
|
||||
],
|
||||
vec![
|
||||
"implementation schedule should be extended by another".into(),
|
||||
"quarterly reviews established during the first phase of the".into(),
|
||||
],
|
||||
vec![
|
||||
"six months to accommodate the revised procurement rules".into(),
|
||||
"".into(),
|
||||
],
|
||||
vec![
|
||||
"adopted at the previous meeting of the governing board".into(),
|
||||
"participating institutions across the partner regions".into(),
|
||||
],
|
||||
],
|
||||
(0..10).collect(),
|
||||
);
|
||||
assert!(is_parallel_prose_table(&headed_parallel_prose));
|
||||
|
||||
let headed_text_table = crate::tables::Table::new(
|
||||
vec![90.0, 340.0],
|
||||
vec![320.0, 300.0, 280.0],
|
||||
|
||||
+386
-2
@@ -13,7 +13,11 @@ pub(crate) fn clean_markdown(mut text: String, options: &MarkdownOptions) -> Str
|
||||
text = collapse_dot_leaders(&text);
|
||||
}
|
||||
|
||||
// Fix hyphenation first (before other processing)
|
||||
// Collapse runs of spaces first: double-spaced breaks ("de- fendant")
|
||||
// must look like single-spaced ones before the hyphenation passes.
|
||||
collapse_consecutive_spaces(&mut text);
|
||||
|
||||
// Fix hyphenation (before other processing)
|
||||
if options.fix_hyphenation {
|
||||
text = fix_hyphenation(&text);
|
||||
}
|
||||
@@ -143,7 +147,213 @@ fn fix_hyphenation(text: &str) -> String {
|
||||
})
|
||||
.to_string();
|
||||
|
||||
result
|
||||
dehyphenate_line_breaks(&result)
|
||||
}
|
||||
|
||||
/// What a line-break hyphen pair should become. Policy output only — how the
|
||||
/// decision is rendered (plain text vs. inside split emphasis markers) is the
|
||||
/// caller's business.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
||||
enum Join {
|
||||
/// The fragments are one word: "de- fendant" -> "defendant".
|
||||
Plain,
|
||||
/// The fragments are a hyphenated compound: "six- month" -> "six-month".
|
||||
Hyphen,
|
||||
/// No evidence (or a suspended hyphen): leave the break as it is.
|
||||
Keep,
|
||||
}
|
||||
|
||||
/// Decide what a line-break hyphen pair becomes, given the document's
|
||||
/// vocabulary evidence. The whole dehyphenation policy lives here so it can
|
||||
/// be reasoned about (and tested) apart from the Markdown scanning around it;
|
||||
/// see [`dehyphenate_line_breaks`] for the rule rationale.
|
||||
fn join_decision(
|
||||
a: &str,
|
||||
b: &str,
|
||||
words: &std::collections::HashSet<String>,
|
||||
hyphenated: &std::collections::HashSet<String>,
|
||||
) -> Join {
|
||||
// Word-length sanity: syllable fragments are short, and even long German
|
||||
// compounds stay under this. Fragments beyond it are already fused
|
||||
// reading-order noise (interleaved columns); joining would compound the
|
||||
// damage.
|
||||
if a.chars().count() + b.chars().count() > 40 {
|
||||
return Join::Keep;
|
||||
}
|
||||
let key_plain = format!("{}{}", a.to_lowercase(), b.to_lowercase());
|
||||
let key_hyphen = format!("{}-{}", a.to_lowercase(), b.to_lowercase());
|
||||
if words.contains(&key_plain) {
|
||||
Join::Plain
|
||||
} else if hyphenated.contains(&key_hyphen) || b.chars().next().is_some_and(|c| c.is_uppercase())
|
||||
{
|
||||
Join::Hyphen
|
||||
} else if b.chars().count() >= 4
|
||||
&& words.contains(&a.to_lowercase())
|
||||
&& words.contains(&b.to_lowercase())
|
||||
{
|
||||
// No direct evidence, but both fragments are themselves words the
|
||||
// document uses ("commercial- type"): hyphenated compounds are made
|
||||
// of words, while syllable fragments ("evi", "judg", "mo") are not.
|
||||
//
|
||||
// The continuation must be at least four letters. Suspended hyphens
|
||||
// ("mid- and long-term", "klein- und mittelgroß") put a conjunction
|
||||
// after the hyphen, and conjunctions are near-universally one to
|
||||
// three letters in any language — the length floor keeps this rule
|
||||
// off them without a hard-coded conjunction list.
|
||||
Join::Hyphen
|
||||
} else {
|
||||
// No evidence at all: leave the break as it is. An unconditional join
|
||||
// here covered only ~1% more breaks on a vocabulary-rich document,
|
||||
// but it was the sole rule able to corrupt output — fusing
|
||||
// interleaved-column fragments ("com- real" -> "comreal") into
|
||||
// unrecoverable tokens. A visible break is honest; a silent fusion
|
||||
// is not.
|
||||
Join::Keep
|
||||
}
|
||||
}
|
||||
|
||||
/// Rejoin words hyphenated at the original line breaks.
|
||||
///
|
||||
/// Justified print breaks words at syllables; after paragraph lines are
|
||||
/// joined with spaces those breaks survive as "de- fendant" (and, when an
|
||||
/// emphasis span was split with the word, "Bap-** **tist"). Whether the
|
||||
/// hyphen itself belongs in the word cannot be decided locally — "de-
|
||||
/// fendant" is one word but "Third- Party" is a hyphenated compound — so the
|
||||
/// document is its own dictionary:
|
||||
///
|
||||
/// 1. fragments appear elsewhere joined plain ("defendant") — join plain;
|
||||
/// 2. appear elsewhere hyphenated ("six-month"), or the continuation is
|
||||
/// capitalized ("Hinds- Radix", "Third- Party") — keep the hyphen;
|
||||
/// 3. both fragments are words the document uses and the continuation
|
||||
/// has four or more letters ("commercial- type" where "commercial"
|
||||
/// and "type" appear elsewhere) — a compound, keep the hyphen. The
|
||||
/// length floor keeps this rule off suspended hyphens ("mid- and
|
||||
/// long-term", "klein- und mittelgroß"): conjunctions are one to
|
||||
/// three letters in essentially every language, so no conjunction
|
||||
/// list is needed;
|
||||
/// 4. no evidence — leave the break untouched. Evidence covers ~99% of
|
||||
/// breaks on vocabulary-rich documents, and an unconditional join was
|
||||
/// the one rule able to corrupt output (fusing interleaved-column
|
||||
/// fragments into unrecoverable tokens).
|
||||
///
|
||||
/// Every rule is either document evidence or script-agnostic typography;
|
||||
/// deliberately no hard-coded word lists beyond the three suspension
|
||||
/// conjunctions (a curated suffix list was tried and removed — it was
|
||||
/// English-only, its membership was unfalsifiable, and it could invent
|
||||
/// hyphens: "proto- type" -> "proto-type").
|
||||
///
|
||||
/// Table rows and fenced code blocks are left untouched.
|
||||
fn dehyphenate_line_breaks(text: &str) -> String {
|
||||
use once_cell::sync::Lazy;
|
||||
use std::collections::HashSet;
|
||||
|
||||
const WORD: &str = r"\p{L}";
|
||||
|
||||
// "de- fendant"
|
||||
static BREAK_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(&format!("({WORD}{{2,}})- ({WORD}{{2,}})")).unwrap());
|
||||
// "Bap-** **tist" — an emphasis span split together with the word. The
|
||||
// regex crate has no backreferences, so both markers are captured and
|
||||
// compared in the replacement closure.
|
||||
static BREAK_EMPH_RE: Lazy<Regex> = Lazy::new(|| {
|
||||
Regex::new(&format!(
|
||||
r"({WORD}{{2,}})-(\*{{1,2}}) (\*{{1,2}})({WORD}{{2,}})"
|
||||
))
|
||||
.unwrap()
|
||||
});
|
||||
static PLAIN_WORD_RE: Lazy<Regex> = Lazy::new(|| Regex::new(&format!("{WORD}{{3,}}")).unwrap());
|
||||
static HYPHENATED_RE: Lazy<Regex> =
|
||||
Lazy::new(|| Regex::new(&format!("({WORD}{{2,}})-({WORD}{{2,}})")).unwrap());
|
||||
|
||||
// The document is its own dictionary: whole words and hyphenated
|
||||
// compounds as they appear away from line breaks. Two exclusions keep the
|
||||
// evidence sound:
|
||||
// - the break pairs themselves are scrubbed first, otherwise every
|
||||
// broken word donates its own fragments ("evi", "dence") and the
|
||||
// compound rule would see them as words;
|
||||
// - fenced code blocks are skipped: identifiers are a different
|
||||
// language, and one like `comreal` must not justify fusing prose.
|
||||
// Table rows stay — cells carry genuine document vocabulary.
|
||||
let mut prose = String::with_capacity(text.len());
|
||||
let mut in_code = false;
|
||||
for line in text.split('\n') {
|
||||
if line.trim_start().starts_with("```") {
|
||||
in_code = !in_code;
|
||||
continue;
|
||||
}
|
||||
if !in_code {
|
||||
prose.push_str(line);
|
||||
prose.push('\n');
|
||||
}
|
||||
}
|
||||
let scrubbed = BREAK_EMPH_RE.replace_all(&prose, " ");
|
||||
let scrubbed = BREAK_RE.replace_all(&scrubbed, " ");
|
||||
let mut words: HashSet<String> = HashSet::new();
|
||||
let mut hyphenated: HashSet<String> = HashSet::new();
|
||||
for m in PLAIN_WORD_RE.find_iter(&scrubbed) {
|
||||
words.insert(m.as_str().to_lowercase());
|
||||
}
|
||||
for caps in HYPHENATED_RE.captures_iter(&scrubbed) {
|
||||
hyphenated.insert(format!(
|
||||
"{}-{}",
|
||||
caps[1].to_lowercase(),
|
||||
caps[2].to_lowercase()
|
||||
));
|
||||
}
|
||||
|
||||
let join = |a: &str, b: &str| join_decision(a, b, &words, &hyphenated);
|
||||
|
||||
let mut in_code_block = false;
|
||||
let mut out = String::with_capacity(text.len());
|
||||
for (i, line) in text.split('\n').enumerate() {
|
||||
if i > 0 {
|
||||
out.push('\n');
|
||||
}
|
||||
if line.trim_start().starts_with("```") {
|
||||
in_code_block = !in_code_block;
|
||||
}
|
||||
if in_code_block || line.trim_start().starts_with('|') {
|
||||
out.push_str(line);
|
||||
continue;
|
||||
}
|
||||
// A break can chain ("unconsti- tu- tional"); each pass joins one
|
||||
// junction, and every successful join removes a break, so running
|
||||
// until the line is stable is bounded by the number of breaks.
|
||||
let mut current = line.to_string();
|
||||
loop {
|
||||
let next = BREAK_EMPH_RE
|
||||
.replace_all(¤t, |caps: ®ex::Captures| {
|
||||
// Mismatched markers aren't a split span; a Keep decision
|
||||
// preserves the original spacing and markers.
|
||||
if caps[2] != caps[3] {
|
||||
return caps[0].to_string();
|
||||
}
|
||||
let (a, b) = (&caps[1], &caps[4]);
|
||||
match join(a, b) {
|
||||
Join::Plain => format!("{a}{b}"),
|
||||
Join::Hyphen => format!("{a}-{b}"),
|
||||
Join::Keep => caps[0].to_string(),
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
let next = BREAK_RE
|
||||
.replace_all(&next, |caps: ®ex::Captures| {
|
||||
let (a, b) = (&caps[1], &caps[2]);
|
||||
match join(a, b) {
|
||||
Join::Plain => format!("{a}{b}"),
|
||||
Join::Hyphen => format!("{a}-{b}"),
|
||||
Join::Keep => caps[0].to_string(),
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
if next == current {
|
||||
break;
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
out.push_str(¤t);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Remove isolated page-number expressions from Markdown.
|
||||
@@ -384,6 +594,180 @@ mod tests {
|
||||
assert_eq!(t, "version 3 .14 released");
|
||||
}
|
||||
|
||||
// --- dehyphenate_line_breaks ---
|
||||
|
||||
#[test]
|
||||
fn line_break_joins_plain_on_vocabulary_evidence() {
|
||||
// "defendant" appears whole elsewhere, so the broken form joins plain.
|
||||
let text = "The defendant appeared. The de- fendant argued.";
|
||||
assert_eq!(
|
||||
dehyphenate_line_breaks(text),
|
||||
"The defendant appeared. The defendant argued."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_break_keeps_hyphen_on_hyphenated_evidence() {
|
||||
// "six-month" appears hyphenated elsewhere, so the broken form keeps it.
|
||||
let text = "A six-month term. After a six- month delay.";
|
||||
assert_eq!(
|
||||
dehyphenate_line_breaks(text),
|
||||
"A six-month term. After a six-month delay."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_evidence_leaves_the_break_untouched() {
|
||||
// Without document evidence a join cannot be distinguished from
|
||||
// interleaved-column noise; the visible break is kept.
|
||||
let text = "The evi- dence was clear.";
|
||||
assert_eq!(dehyphenate_line_breaks(text), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_break_keeps_hyphen_before_capitalized_continuation() {
|
||||
// Broken compounds: "Third-Party", "Hinds-Radix".
|
||||
let text = "The Third- Party complaint by Hinds- Radix.";
|
||||
assert_eq!(
|
||||
dehyphenate_line_breaks(text),
|
||||
"The Third-Party complaint by Hinds-Radix."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyrillic_words_join_on_evidence() {
|
||||
// The word class is Unicode-wide, not a hard-coded Latin subset.
|
||||
let text = "Это решение важно. Это реше- ние суда.";
|
||||
assert_eq!(
|
||||
dehyphenate_line_breaks(text),
|
||||
"Это решение важно. Это решение суда."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_spaced_breaks_join_through_clean_markdown() {
|
||||
// Space collapsing runs before hyphenation, so a break that arrives
|
||||
// with two spaces ("de- fendant") still rejoins.
|
||||
let options = MarkdownOptions::default();
|
||||
let out = clean_markdown(
|
||||
"The defendant appeared. The de- fendant argued.".to_string(),
|
||||
&options,
|
||||
);
|
||||
assert_eq!(
|
||||
out.trim_end(),
|
||||
"The defendant appeared. The defendant argued."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_block_identifiers_are_not_vocabulary_evidence() {
|
||||
// A fused identifier in code must not justify fusing unrelated prose.
|
||||
let text = "```\nlet comreal = 1;\n```\nThe com- real estate story.";
|
||||
assert_eq!(dehyphenate_line_breaks(text), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fused_column_noise_is_not_joined() {
|
||||
// Interleaved-column garbage arrives already fused; joining across
|
||||
// its breaks would compound the damage. Real syllable fragments are
|
||||
// short; fragments this long are left exactly as they are.
|
||||
let text = "spreadswerenegativeintheearlytomid- seriouslyflawedduetoappraisallags";
|
||||
assert_eq!(dehyphenate_line_breaks(text), text);
|
||||
// Long German compounds stay under the length gate and join on
|
||||
// vocabulary evidence.
|
||||
let german =
|
||||
"Das Bundesausbildungsförderungsgesetz. Das Bundesausbildungsförderungs- gesetz gilt.";
|
||||
assert_eq!(
|
||||
dehyphenate_line_breaks(german),
|
||||
"Das Bundesausbildungsförderungsgesetz. Das Bundesausbildungsförderungsgesetz gilt."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_evidence_compounds_stay_visibly_broken() {
|
||||
// No hard-coded suffix list: without document evidence even a likely
|
||||
// compound keeps its visible break. A curated list was tried and
|
||||
// removed — English-only, unfalsifiable membership, and able to
|
||||
// invent hyphens ("proto- type" -> "proto-type").
|
||||
let text = "Their world- class support and proto- type systems.";
|
||||
assert_eq!(dehyphenate_line_breaks(text), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn both_fragments_being_words_keeps_the_hyphen() {
|
||||
// "commercial-type insurance": no evidence either way, but both
|
||||
// fragments are words the document uses, so this is a compound.
|
||||
let text = "Any commercial firm of this type offering commercial- type insurance.";
|
||||
assert_eq!(
|
||||
dehyphenate_line_breaks(text),
|
||||
"Any commercial firm of this type offering commercial-type insurance."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suspended_hyphen_is_preserved() {
|
||||
// "mid- to long-term": joining would fuse unrelated words. No
|
||||
// conjunction list is involved — conjunctions are 1-3 letters in
|
||||
// essentially every language, and the compound rule requires a
|
||||
// 4-letter continuation, so suspended hyphens fall through to Keep.
|
||||
let text = "Planned over the mid- to long-term horizon, in- and out-of-possession.";
|
||||
assert_eq!(dehyphenate_line_breaks(text), text);
|
||||
// Same construction in German, which a hard-coded English list
|
||||
// would have missed. "klein" appears standalone so it IS in the
|
||||
// vocabulary — only the length floor (continuation "und" has three
|
||||
// letters) keeps the compound rule from fusing "klein-und".
|
||||
let german = "Das klein geschriebene Wort und die klein- und mittelgroßen Betriebe.";
|
||||
assert_eq!(dehyphenate_line_breaks(german), german);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_emphasis_span_joins_inside_markers() {
|
||||
// Vocabulary evidence ("Baptist", "Consolidated" elsewhere) drives
|
||||
// the join; the split emphasis markers collapse with it.
|
||||
let text = "The Baptist and Consolidated cases. By **Bap-** **tist** pastors and *Consoli-* *dated* Edison.";
|
||||
assert_eq!(
|
||||
dehyphenate_line_breaks(text),
|
||||
"The Baptist and Consolidated cases. By **Baptist** pastors and *Consolidated* Edison."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatched_emphasis_markers_are_left_alone() {
|
||||
let text = "Odd **Bap-** *tist* markers.";
|
||||
assert_eq!(dehyphenate_line_breaks(text), text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chained_breaks_join_stepwise_with_evidence() {
|
||||
// A word broken twice joins across passes when each junction has
|
||||
// vocabulary evidence for its intermediate form.
|
||||
let text = "The word unconstitutional, and unconstitu appears too: unconsti- tu- tional.";
|
||||
assert_eq!(
|
||||
dehyphenate_line_breaks(text),
|
||||
"The word unconstitutional, and unconstitu appears too: unconstitutional."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_rows_and_code_blocks_are_untouched() {
|
||||
let text =
|
||||
"The defendant.\n|de- fendant|value|\n```\nlet x = de- fendant;\n```\nThe de- fendant won.";
|
||||
assert_eq!(
|
||||
dehyphenate_line_breaks(text),
|
||||
"The defendant.\n|de- fendant|value|\n```\nlet x = de- fendant;\n```\nThe defendant won."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accented_words_join() {
|
||||
// Spanish syllable break with accented continuation, evidence-backed.
|
||||
let text = "Una resolución firme. La resolu- ción fue clara.";
|
||||
assert_eq!(
|
||||
dehyphenate_line_breaks(text),
|
||||
"Una resolución firme. La resolución fue clara."
|
||||
);
|
||||
}
|
||||
|
||||
// --- fix_hyphenation ---
|
||||
|
||||
#[test]
|
||||
|
||||
+1
-384
@@ -1,6 +1,6 @@
|
||||
//! Line preprocessing: heading merging, drop cap handling, and repeated line removal.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::structure_tree::StructRole;
|
||||
use crate::types::{TextItem, TextLine};
|
||||
@@ -229,342 +229,6 @@ pub(crate) fn merge_drop_caps(lines: Vec<TextLine>, base_size: f32) -> Vec<TextL
|
||||
result
|
||||
}
|
||||
|
||||
/// Normalize whitespace in a string for comparison: trim and collapse internal runs of whitespace.
|
||||
fn normalize_whitespace(s: &str) -> String {
|
||||
s.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// Normalize text for frequency comparison: collapse whitespace and strip leading/trailing
|
||||
/// digit sequences (page numbers). E.g., "Chapter 3 — Page 5" and "Chapter 3 — Page 6"
|
||||
/// both normalize to "Chapter 3 — Page".
|
||||
fn normalize_for_comparison(s: &str) -> String {
|
||||
let ws = normalize_whitespace(s);
|
||||
let trimmed = ws
|
||||
.trim_start_matches(|c: char| c.is_ascii_digit())
|
||||
.trim_start();
|
||||
let trimmed = trimmed
|
||||
.trim_end_matches(|c: char| c.is_ascii_digit())
|
||||
.trim_end();
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
/// Returns true if the line looks like a list item or heading (should not be stripped).
|
||||
fn is_structural_line(text: &str) -> bool {
|
||||
let t = text.trim_start();
|
||||
t.starts_with('#')
|
||||
|| t.starts_with("- ")
|
||||
|| t.starts_with("* ")
|
||||
|| t.starts_with("• ")
|
||||
|| t.chars()
|
||||
.next()
|
||||
.map(|c| c.is_ascii_digit())
|
||||
.unwrap_or(false)
|
||||
&& (t.contains(". ") || t.contains(") "))
|
||||
}
|
||||
|
||||
/// Returns true if a line consists entirely of a single repeated character
|
||||
/// (e.g., "----------", "**************", "============").
|
||||
fn is_decorative_separator(text: &str) -> bool {
|
||||
let mut chars = text.chars();
|
||||
let first = match chars.next() {
|
||||
Some(c) => c,
|
||||
None => return false,
|
||||
};
|
||||
chars.all(|c| c == first)
|
||||
}
|
||||
|
||||
/// Strip lines that repeat on many distinct pages (running headers/footers).
|
||||
///
|
||||
/// A line is considered a repeated header/footer if:
|
||||
/// 1. Its normalized text appears on `>= max(3, page_count * 30%)` distinct pages
|
||||
/// 2. It is at least 10 characters long
|
||||
/// 3. It doesn't look like a structural element (heading, list item)
|
||||
/// 4. It consistently appears in the top or bottom N distinct Y positions
|
||||
/// 5. Its Y positions across pages have low variance (consistent placement),
|
||||
/// distinguishing true headers/footers from table content that happens to
|
||||
/// land near page margins
|
||||
/// 6. It is not a decorative separator (repeated single character)
|
||||
///
|
||||
/// Additionally, TextLines at the same Y position on a page are grouped into
|
||||
/// "Y-bands." When any member of a Y-band is stripped, all siblings in that
|
||||
/// band are also stripped. This handles split column headers where individual
|
||||
/// fragments may not independently meet the frequency threshold.
|
||||
///
|
||||
/// Page numbers are stripped from line text before comparison, so headers like
|
||||
/// "Chapter 3 — Page 5" and "Chapter 3 — Page 6" are treated as the same text.
|
||||
pub(crate) fn strip_repeated_lines(lines: Vec<TextLine>, page_count: u32) -> Vec<TextLine> {
|
||||
if lines.is_empty() || page_count < 3 {
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Compute Y range per page (min_y, max_y)
|
||||
let mut page_y_range: HashMap<u32, (f32, f32)> = HashMap::new();
|
||||
for line in &lines {
|
||||
let entry = page_y_range.entry(line.page).or_insert((line.y, line.y));
|
||||
if line.y < entry.0 {
|
||||
entry.0 = line.y;
|
||||
}
|
||||
if line.y > entry.1 {
|
||||
entry.1 = line.y;
|
||||
}
|
||||
}
|
||||
|
||||
// Build sorted Y values per page, so we can check line rank (position from edge)
|
||||
let mut page_sorted_ys: HashMap<u32, Vec<f32>> = HashMap::new();
|
||||
for line in &lines {
|
||||
page_sorted_ys.entry(line.page).or_default().push(line.y);
|
||||
}
|
||||
for ys in page_sorted_ys.values_mut() {
|
||||
ys.sort_by(|a, b| a.total_cmp(b));
|
||||
ys.dedup();
|
||||
}
|
||||
|
||||
// A line is in the page margin if it's among the first or last N distinct
|
||||
// Y positions on that page. This is more robust than a percentage-based zone
|
||||
// because it catches actual edge lines regardless of how much content fills
|
||||
// the page. N=5 accommodates multi-line headers/footers and repeated form
|
||||
// column headers (e.g., 5-row IRS form headers) that sit just inside the
|
||||
// page margin.
|
||||
const EDGE_LINE_COUNT: usize = 5;
|
||||
|
||||
/// Returns true if the given Y position is among the first or last N distinct
|
||||
/// Y positions on the specified page.
|
||||
fn is_y_at_edge(y: f32, page: u32, page_sorted_ys: &HashMap<u32, Vec<f32>>, n: usize) -> bool {
|
||||
let ys = match page_sorted_ys.get(&page) {
|
||||
Some(ys) => ys,
|
||||
None => return false,
|
||||
};
|
||||
if ys.len() <= n * 2 {
|
||||
// Page has very few lines — everything is near the edge
|
||||
return true;
|
||||
}
|
||||
// Check if this Y is among the first or last N
|
||||
let pos = match ys.iter().position(|&py| (py - y).abs() < 0.1) {
|
||||
Some(p) => p,
|
||||
None => return false,
|
||||
};
|
||||
pos < n || pos >= ys.len() - n
|
||||
}
|
||||
|
||||
// Average page span for normalizing Y variance
|
||||
let avg_span = {
|
||||
let total: f32 = page_y_range.values().map(|(lo, hi)| hi - lo).sum();
|
||||
if page_y_range.is_empty() {
|
||||
1.0
|
||||
} else {
|
||||
(total / page_y_range.len() as f32).max(1.0)
|
||||
}
|
||||
};
|
||||
|
||||
// Build Y-bands: group line indices by (page, quantized_y).
|
||||
// Lines at the same Y position (within ~0.1pt) on the same page form a band.
|
||||
let mut y_bands: HashMap<(u32, i32), Vec<usize>> = HashMap::new();
|
||||
for (idx, line) in lines.iter().enumerate() {
|
||||
let y_bucket = (line.y * 10.0).round() as i32;
|
||||
y_bands.entry((line.page, y_bucket)).or_default().push(idx);
|
||||
}
|
||||
|
||||
// Build frequency maps using normalize_for_comparison.
|
||||
// Individual line text -> distinct pages
|
||||
let mut freq: HashMap<String, HashSet<u32>> = HashMap::new();
|
||||
let mut y_positions: HashMap<String, Vec<f32>> = HashMap::new();
|
||||
for line in &lines {
|
||||
if !is_y_at_edge(line.y, line.page, &page_sorted_ys, EDGE_LINE_COUNT) {
|
||||
continue;
|
||||
}
|
||||
let text = line.text();
|
||||
let normalized = normalize_for_comparison(&text);
|
||||
if normalized.len() < 10 || is_decorative_separator(&normalized) {
|
||||
continue;
|
||||
}
|
||||
freq.entry(normalized.clone())
|
||||
.or_default()
|
||||
.insert(line.page);
|
||||
y_positions.entry(normalized).or_default().push(line.y);
|
||||
}
|
||||
|
||||
// Coalesced row text -> distinct pages (for multi-member Y-bands).
|
||||
// This catches split column headers where individual fragments don't meet
|
||||
// the frequency threshold but the combined row does.
|
||||
let mut band_freq: HashMap<String, HashSet<u32>> = HashMap::new();
|
||||
let mut band_y_positions: HashMap<String, Vec<f32>> = HashMap::new();
|
||||
for (&(page, _), indices) in &y_bands {
|
||||
if indices.len() < 2 {
|
||||
continue; // single-line bands are already in the individual map
|
||||
}
|
||||
let band_y = lines[indices[0]].y;
|
||||
if !is_y_at_edge(band_y, page, &page_sorted_ys, EDGE_LINE_COUNT) {
|
||||
continue;
|
||||
}
|
||||
let mut sorted_indices = indices.clone();
|
||||
sorted_indices.sort();
|
||||
let coalesced: String = sorted_indices
|
||||
.iter()
|
||||
.map(|&i| lines[i].text())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let normalized = normalize_for_comparison(&coalesced);
|
||||
if normalized.len() < 10 || is_decorative_separator(&normalized) {
|
||||
continue;
|
||||
}
|
||||
band_freq
|
||||
.entry(normalized.clone())
|
||||
.or_default()
|
||||
.insert(page);
|
||||
band_y_positions.entry(normalized).or_default().push(band_y);
|
||||
}
|
||||
|
||||
// Compute threshold
|
||||
let threshold = 3u32.max(page_count * 30 / 100);
|
||||
|
||||
// Check Y-position consistency: headers/footers appear at the same position
|
||||
// on every page, table content varies. Require normalized stddev < 5% of
|
||||
// average page span.
|
||||
let has_consistent_y = |text: &str, positions: &HashMap<String, Vec<f32>>| -> bool {
|
||||
let pos = match positions.get(text) {
|
||||
Some(p) if p.len() >= 2 => p,
|
||||
_ => return true, // single occurrence — allow
|
||||
};
|
||||
let n = pos.len() as f32;
|
||||
let mean = pos.iter().sum::<f32>() / n;
|
||||
let variance = pos.iter().map(|y| (y - mean).powi(2)).sum::<f32>() / n;
|
||||
let stddev = variance.sqrt();
|
||||
stddev / avg_span < 0.05
|
||||
};
|
||||
|
||||
// Identify candidates from individual frequency map
|
||||
let candidates: HashSet<String> = freq
|
||||
.into_iter()
|
||||
.filter(|(text, pages)| {
|
||||
pages.len() as u32 >= threshold
|
||||
&& !is_structural_line(text)
|
||||
&& has_consistent_y(text, &y_positions)
|
||||
})
|
||||
.map(|(text, _)| text)
|
||||
.collect();
|
||||
|
||||
// Identify candidates from coalesced band frequency map
|
||||
let band_candidates: HashSet<String> = band_freq
|
||||
.into_iter()
|
||||
.filter(|(text, pages)| {
|
||||
pages.len() as u32 >= threshold
|
||||
&& !is_structural_line(text)
|
||||
&& has_consistent_y(text, &band_y_positions)
|
||||
})
|
||||
.map(|(text, _)| text)
|
||||
.collect();
|
||||
|
||||
if candidates.is_empty() && band_candidates.is_empty() {
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Build removal set.
|
||||
// A line is removed if it's at an edge position and:
|
||||
// (a) its individual text matches a candidate, OR
|
||||
// (b) its Y-band's coalesced text matches a band candidate, OR
|
||||
// (c) any sibling in its Y-band was removed (propagation).
|
||||
//
|
||||
// The first occurrence (lowest page number) of each repeated header/footer
|
||||
// is kept so that document titles, column headers, etc. appear once.
|
||||
let mut removal_set: HashSet<usize> = HashSet::new();
|
||||
|
||||
// Track which page first shows each candidate (to preserve first occurrence)
|
||||
let mut first_page_individual: HashMap<String, u32> = HashMap::new();
|
||||
for (idx, line) in lines.iter().enumerate() {
|
||||
if !is_y_at_edge(line.y, line.page, &page_sorted_ys, EDGE_LINE_COUNT) {
|
||||
continue;
|
||||
}
|
||||
let text = line.text();
|
||||
let normalized = normalize_for_comparison(&text);
|
||||
if candidates.contains(&normalized) {
|
||||
let first = first_page_individual.entry(normalized).or_insert(line.page);
|
||||
if line.page > *first {
|
||||
removal_set.insert(idx);
|
||||
} else if line.page == *first {
|
||||
// Keep this occurrence (first page)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Track first page for band candidates
|
||||
let mut first_page_band: HashMap<String, u32> = HashMap::new();
|
||||
// First pass: find first page for each band candidate
|
||||
for (&(page, _), indices) in &y_bands {
|
||||
if indices.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let band_y = lines[indices[0]].y;
|
||||
if !is_y_at_edge(band_y, page, &page_sorted_ys, EDGE_LINE_COUNT) {
|
||||
continue;
|
||||
}
|
||||
let mut sorted_indices = indices.clone();
|
||||
sorted_indices.sort();
|
||||
let coalesced: String = sorted_indices
|
||||
.iter()
|
||||
.map(|&i| lines[i].text())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let normalized = normalize_for_comparison(&coalesced);
|
||||
if band_candidates.contains(&normalized) {
|
||||
let first = first_page_band.entry(normalized).or_insert(page);
|
||||
if page < *first {
|
||||
*first = page;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Second pass: mark for removal (skip first page)
|
||||
for (&(page, _), indices) in &y_bands {
|
||||
if indices.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
let band_y = lines[indices[0]].y;
|
||||
if !is_y_at_edge(band_y, page, &page_sorted_ys, EDGE_LINE_COUNT) {
|
||||
continue;
|
||||
}
|
||||
let mut sorted_indices = indices.clone();
|
||||
sorted_indices.sort();
|
||||
let coalesced: String = sorted_indices
|
||||
.iter()
|
||||
.map(|&i| lines[i].text())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let normalized = normalize_for_comparison(&coalesced);
|
||||
if band_candidates.contains(&normalized) {
|
||||
let first = first_page_band.get(&normalized).copied().unwrap_or(0);
|
||||
if page > first {
|
||||
for &idx in &sorted_indices {
|
||||
removal_set.insert(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// (c) Y-band sibling propagation: if any member is removed, remove all
|
||||
// members (provided the band is at an edge position).
|
||||
for (&(page, _), indices) in &y_bands {
|
||||
let band_y = lines[indices[0]].y;
|
||||
if !is_y_at_edge(band_y, page, &page_sorted_ys, EDGE_LINE_COUNT) {
|
||||
continue;
|
||||
}
|
||||
if indices.iter().any(|idx| removal_set.contains(idx)) {
|
||||
for &idx in indices {
|
||||
removal_set.insert(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if removal_set.is_empty() {
|
||||
return lines;
|
||||
}
|
||||
|
||||
lines
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter(|(idx, _)| !removal_set.contains(idx))
|
||||
.map(|(_, line)| line)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -679,53 +343,6 @@ mod tests {
|
||||
assert_eq!(result.len(), 2, "should merge font-based heading lines");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_repeated_keeps_first_occurrence() {
|
||||
// Simulate a repeated page header on 10 pages.
|
||||
// Each page has a running header at y=750 and many unique body lines.
|
||||
let mut lines = Vec::new();
|
||||
for page in 1..=10u32 {
|
||||
// Header at top
|
||||
lines.push(make_line(
|
||||
"VOICE OF SOUTH MARION May fifteen twenty twenty five",
|
||||
10.0,
|
||||
page,
|
||||
750.0,
|
||||
None,
|
||||
));
|
||||
// Body content — unique text per line per page (no digits to strip)
|
||||
for j in 0..20u32 {
|
||||
lines.push(make_line(
|
||||
&format!(
|
||||
"parcel r-{:04}-{:03} owner smith address oak street",
|
||||
page * 100 + j,
|
||||
page
|
||||
),
|
||||
10.0,
|
||||
page,
|
||||
600.0 - j as f32 * 15.0,
|
||||
None,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let result = strip_repeated_lines(lines, 10);
|
||||
|
||||
// The header should appear exactly once (page 1)
|
||||
let header_count = result
|
||||
.iter()
|
||||
.filter(|l| l.text().contains("VOICE OF SOUTH MARION"))
|
||||
.count();
|
||||
assert_eq!(header_count, 1, "repeated header should be kept once");
|
||||
|
||||
// First occurrence should be on page 1
|
||||
let first_header = result
|
||||
.iter()
|
||||
.find(|l| l.text().contains("VOICE OF SOUTH MARION"))
|
||||
.unwrap();
|
||||
assert_eq!(first_header.page, 1, "first occurrence should be on page 1");
|
||||
}
|
||||
|
||||
fn make_bold_line(text: &str, page: u32, y: f32) -> TextLine {
|
||||
let mut item = make_item(text, 12.0, None);
|
||||
item.is_bold = true;
|
||||
|
||||
+296
@@ -85,6 +85,107 @@ 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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -362,6 +463,101 @@ 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(),
|
||||
@@ -502,6 +698,99 @@ 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> {
|
||||
@@ -704,6 +993,11 @@ 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>()?;
|
||||
@@ -713,6 +1007,8 @@ 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)?)?;
|
||||
|
||||
+335
-15
@@ -1,6 +1,6 @@
|
||||
//! Rectangle-based table detection using union-find clustering.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use log::debug;
|
||||
|
||||
@@ -78,19 +78,111 @@ pub(crate) fn rects_overlap(a: &(f32, f32, f32, f32), b: &(f32, f32, f32, f32),
|
||||
!(a_right < b_left || b_right < a_left || a_top < b_bottom || b_top < a_bottom)
|
||||
}
|
||||
|
||||
fn grid_coord(value: f32, cell: f32) -> i32 {
|
||||
(value / cell).floor().clamp(-1_000_000.0, 1_000_000.0) as i32
|
||||
}
|
||||
|
||||
/// Inclusive grid range. `None` if the rect covers more cells than we will
|
||||
/// materialize — those rects are clustered via a bounded fallback.
|
||||
fn grid_span(lo: f32, hi: f32, cell: f32) -> Option<std::ops::RangeInclusive<i32>> {
|
||||
let a = grid_coord(lo.min(hi), cell);
|
||||
let b = grid_coord(lo.max(hi), cell);
|
||||
let span = b.saturating_sub(a);
|
||||
if span > 64 {
|
||||
return None;
|
||||
}
|
||||
Some(a..=b)
|
||||
}
|
||||
|
||||
fn union_bucket_pairs(
|
||||
uf: &mut UnionFind,
|
||||
rects: &[(f32, f32, f32, f32)],
|
||||
bucket: &[usize],
|
||||
tolerance: f32,
|
||||
) {
|
||||
let m = bucket.len();
|
||||
let mut pairs = 0usize;
|
||||
'cell: for a in 0..m {
|
||||
let i = bucket[a];
|
||||
if uf.component_size(i) >= MAX_CLUSTER_RECTS {
|
||||
continue;
|
||||
}
|
||||
for &j in &bucket[a + 1..] {
|
||||
if pairs >= MAX_CLUSTER_PAIRS_PER_CELL {
|
||||
break 'cell;
|
||||
}
|
||||
if uf.component_size(j) >= MAX_CLUSTER_RECTS {
|
||||
continue;
|
||||
}
|
||||
pairs += 1;
|
||||
if rects_overlap(&rects[i], &rects[j], tolerance) {
|
||||
uf.union(i, j);
|
||||
if uf.component_size(i) >= MAX_CLUSTER_RECTS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn union_rect_against_bands(
|
||||
uf: &mut UnionFind,
|
||||
rects: &[(f32, f32, f32, f32)],
|
||||
i: usize,
|
||||
bands: &BTreeMap<i32, Vec<usize>>,
|
||||
lo: i32,
|
||||
hi: i32,
|
||||
tolerance: f32,
|
||||
) {
|
||||
if uf.component_size(i) >= MAX_CLUSTER_RECTS {
|
||||
return;
|
||||
}
|
||||
let mut pairs = 0usize;
|
||||
let mut seen = HashSet::new();
|
||||
for (_, bucket) in bands.range(lo..=hi) {
|
||||
for &j in bucket {
|
||||
if !seen.insert(j) {
|
||||
continue;
|
||||
}
|
||||
if pairs >= MAX_CLUSTER_PAIRS_PER_CELL {
|
||||
return;
|
||||
}
|
||||
if i == j || uf.component_size(j) >= MAX_CLUSTER_RECTS {
|
||||
continue;
|
||||
}
|
||||
pairs += 1;
|
||||
if rects_overlap(&rects[i], &rects[j], tolerance) {
|
||||
uf.union(i, j);
|
||||
if uf.component_size(i) >= MAX_CLUSTER_RECTS {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum component size for rect clustering. No real table has thousands
|
||||
/// of cell rects — once a component exceeds this, it is a vector drawing or
|
||||
/// page-spanning clipping path. We skip overlap checks for rects already in
|
||||
/// an oversized component, keeping the original O(n²) loop but making it
|
||||
/// effectively O(n) for pathological pages.
|
||||
/// an oversized component.
|
||||
const MAX_CLUSTER_RECTS: usize = 2000;
|
||||
|
||||
/// Pairwise-disjoint rects never merge, so a component-size cap does not
|
||||
/// stop an all-pairs loop. Rects are hashed into this many points of grid
|
||||
/// and compared only against others in the same cell.
|
||||
const CLUSTER_GRID_CELL: f32 = 64.0;
|
||||
|
||||
/// All-pairs AABB tests allowed inside one grid cell. A real table cell is
|
||||
/// tens of points wide, so a 64-pt cell holds a handful of neighbors — not
|
||||
/// thousands of stacked drawings.
|
||||
const MAX_CLUSTER_PAIRS_PER_CELL: usize = 16_384;
|
||||
|
||||
/// Cluster rects by spatial overlap using union-find.
|
||||
/// Returns groups of rect indices; only groups with ≥ `min_size` rects are returned.
|
||||
///
|
||||
/// Skips overlap checks for rects whose component has already exceeded
|
||||
/// [`MAX_CLUSTER_RECTS`], so pages with tens of thousands of vector-drawing
|
||||
/// rects complete in milliseconds instead of minutes.
|
||||
/// Overlap tests run inside a uniform grid so far-apart rects are never
|
||||
/// compared, and each cell is pair-capped so a dense stack cannot go
|
||||
/// quadratic or starve an independent table in another cell.
|
||||
pub(crate) fn cluster_rects(
|
||||
rects: &[(f32, f32, f32, f32)],
|
||||
tolerance: f32,
|
||||
@@ -98,23 +190,144 @@ pub(crate) fn cluster_rects(
|
||||
) -> Vec<Vec<usize>> {
|
||||
let n = rects.len();
|
||||
let mut uf = UnionFind::new(n);
|
||||
let cell = CLUSTER_GRID_CELL.max(tolerance * 4.0);
|
||||
|
||||
for i in 0..n {
|
||||
// If rect i is already in an oversized component, no point comparing
|
||||
// it against further rects — the component won't be used for table
|
||||
// detection anyway.
|
||||
let mut grid: HashMap<(i32, i32), Vec<usize>> = HashMap::new();
|
||||
let mut large: Vec<usize> = Vec::new();
|
||||
for (idx, &(x, y, w, h)) in rects.iter().enumerate() {
|
||||
match (
|
||||
grid_span(x - tolerance, x + w + tolerance, cell),
|
||||
grid_span(y - tolerance, y + h + tolerance, cell),
|
||||
) {
|
||||
(Some(xs), Some(ys)) => {
|
||||
for gx in xs {
|
||||
for gy in ys.clone() {
|
||||
grid.entry((gx, gy)).or_default().push(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => large.push(idx),
|
||||
}
|
||||
}
|
||||
|
||||
let mut keys: Vec<_> = grid.keys().copied().collect();
|
||||
keys.sort_unstable();
|
||||
let mut keys_by_y: BTreeMap<i32, Vec<i32>> = BTreeMap::new();
|
||||
for &key in &keys {
|
||||
union_bucket_pairs(&mut uf, rects, &grid[&key], tolerance);
|
||||
keys_by_y.entry(key.1).or_default().push(key.0);
|
||||
}
|
||||
|
||||
// Oversized spans skip insert. Range-query occupied cells they cover so
|
||||
// later X-ranges are not starved and we do not scan unrelated rows.
|
||||
for &i in &large {
|
||||
if uf.component_size(i) >= MAX_CLUSTER_RECTS {
|
||||
continue;
|
||||
}
|
||||
for j in (i + 1)..n {
|
||||
if rects_overlap(&rects[i], &rects[j], tolerance) {
|
||||
uf.union(i, j);
|
||||
// Check if the merged component just exceeded the cap —
|
||||
// if so, no need to test more pairs for rect i.
|
||||
let (x, y, w, h) = rects[i];
|
||||
let x_lo = grid_coord(x - tolerance, cell);
|
||||
let x_hi = grid_coord(x + w + tolerance, cell);
|
||||
let y_lo = grid_coord(y - tolerance, cell);
|
||||
let y_hi = grid_coord(y + h + tolerance, cell);
|
||||
for (&gy, gxs) in keys_by_y.range(y_lo..=y_hi) {
|
||||
let start = gxs.partition_point(|&gx| gx < x_lo);
|
||||
for &gx in &gxs[start..] {
|
||||
if gx > x_hi {
|
||||
break;
|
||||
}
|
||||
let bucket = &grid[&(gx, gy)];
|
||||
let mut pairs = 0usize;
|
||||
for &j in bucket {
|
||||
if pairs >= MAX_CLUSTER_PAIRS_PER_CELL {
|
||||
break;
|
||||
}
|
||||
if uf.component_size(j) >= MAX_CLUSTER_RECTS {
|
||||
continue;
|
||||
}
|
||||
pairs += 1;
|
||||
if rects_overlap(&rects[i], &rects[j], tolerance) {
|
||||
uf.union(i, j);
|
||||
if uf.component_size(i) >= MAX_CLUSTER_RECTS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if uf.component_size(i) >= MAX_CLUSTER_RECTS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if uf.component_size(i) >= MAX_CLUSTER_RECTS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Oversized-vs-oversized: band on the short axis so stacked or side-by-side
|
||||
// page-spanning rules stay linear. Wide vs tall pairs are matched by
|
||||
// querying the tall X-index; dual-oversized rects occupy every coarse-Y
|
||||
// cell they span.
|
||||
let mut large_x: BTreeMap<i32, Vec<usize>> = BTreeMap::new();
|
||||
let mut large_y: BTreeMap<i32, Vec<usize>> = BTreeMap::new();
|
||||
let mut large_coarse_y: BTreeMap<i32, Vec<usize>> = BTreeMap::new();
|
||||
let mut wide: Vec<usize> = Vec::new();
|
||||
let mut dual: Vec<usize> = Vec::new();
|
||||
for &i in &large {
|
||||
let (x, y, w, h) = rects[i];
|
||||
let xs = grid_span(x - tolerance, x + w + tolerance, cell);
|
||||
let ys = grid_span(y - tolerance, y + h + tolerance, cell);
|
||||
match (xs, ys) {
|
||||
(Some(xs), _) => {
|
||||
for gx in xs {
|
||||
large_x.entry(gx).or_default().push(i);
|
||||
}
|
||||
}
|
||||
(_, Some(ys)) => {
|
||||
wide.push(i);
|
||||
for gy in ys {
|
||||
large_y.entry(gy).or_default().push(i);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
dual.push(i);
|
||||
let coarse = cell * 64.0;
|
||||
match grid_span(y - tolerance, y + h + tolerance, coarse) {
|
||||
Some(ys) => {
|
||||
for gy in ys {
|
||||
large_coarse_y.entry(gy).or_default().push(i);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
large_coarse_y.entry(i32::MIN).or_default().push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for bands in [&large_x, &large_y, &large_coarse_y] {
|
||||
for bucket in bands.values() {
|
||||
union_bucket_pairs(&mut uf, rects, bucket, tolerance);
|
||||
}
|
||||
}
|
||||
// Cross-orientation is |wide|×|tall| if every wide rule spans the page.
|
||||
// Skip that pass when the product cannot be a table (a few rules).
|
||||
let tall_n = large
|
||||
.len()
|
||||
.saturating_sub(wide.len())
|
||||
.saturating_sub(dual.len());
|
||||
let cross_n =
|
||||
(wide.len() + dual.len()).saturating_mul(tall_n) + dual.len().saturating_mul(wide.len());
|
||||
if cross_n > 0 && cross_n <= MAX_CLUSTER_PAIRS_PER_CELL {
|
||||
for &i in wide.iter().chain(&dual) {
|
||||
let (x, _, w, _) = rects[i];
|
||||
let x_lo = grid_coord(x - tolerance, cell);
|
||||
let x_hi = grid_coord(x + w + tolerance, cell);
|
||||
union_rect_against_bands(&mut uf, rects, i, &large_x, x_lo, x_hi, tolerance);
|
||||
}
|
||||
for &i in &dual {
|
||||
let (_, y, _, h) = rects[i];
|
||||
let y_lo = grid_coord(y - tolerance, cell);
|
||||
let y_hi = grid_coord(y + h + tolerance, cell);
|
||||
union_rect_against_bands(&mut uf, rects, i, &large_y, y_lo, y_hi, tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3810,6 +4023,113 @@ mod tests {
|
||||
assert_eq!(groups[0].len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cluster_rects_overlapping_grid_still_clusters() {
|
||||
// Neighboring cells overlap; the grid must still union the whole table.
|
||||
let mut rects = Vec::new();
|
||||
for row in 0..4 {
|
||||
for col in 0..4 {
|
||||
rects.push((col as f32 * 9.0, row as f32 * 9.0, 10.0, 10.0));
|
||||
}
|
||||
}
|
||||
let groups = cluster_rects(&rects, 0.0, 1);
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].len(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cluster_rects_many_disjoint_stays_subquadratic() {
|
||||
// Pairwise-disjoint rects never merge, so a component-size cap does
|
||||
// not stop all-pairs overlap tests. Spread in X so they land in
|
||||
// different grid cells; 8k is enough that n² tests would dominate.
|
||||
let n = 8_000usize;
|
||||
let rects: Vec<(f32, f32, f32, f32)> =
|
||||
(0..n).map(|i| (i as f32 * 20.0, 0.0, 10.0, 10.0)).collect();
|
||||
let groups = cluster_rects(&rects, 0.0, 2);
|
||||
assert!(groups.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cluster_rects_stacked_disjoint_does_not_starve_later_table() {
|
||||
// Same X, spread in Y: a spatial grid must still union an overlapping
|
||||
// pair in another region of the page.
|
||||
let n = 8_000usize;
|
||||
let mut rects: Vec<(f32, f32, f32, f32)> =
|
||||
(0..n).map(|i| (0.0, i as f32 * 20.0, 10.0, 10.0)).collect();
|
||||
rects.push((500.0, 0.0, 10.0, 10.0));
|
||||
rects.push((508.0, 0.0, 10.0, 10.0));
|
||||
let groups = cluster_rects(&rects, 0.0, 2);
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cluster_rects_oversized_span_still_unions() {
|
||||
// Wider than 64 grid cells; must still union the small overlapping rect.
|
||||
let rects = vec![(0.0, 0.0, 5000.0, 10.0), (4900.0, 0.0, 10.0, 10.0)];
|
||||
let groups = cluster_rects(&rects, 0.0, 1);
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cluster_rects_many_oversized_spans_all_get_a_pass() {
|
||||
// More than 32 huge rects: the last one must still union its overlap.
|
||||
let mut rects: Vec<(f32, f32, f32, f32)> = (0..40)
|
||||
.map(|i| (0.0, i as f32 * 20.0, 5000.0, 10.0))
|
||||
.collect();
|
||||
rects.push((4900.0, 39.0 * 20.0, 10.0, 10.0));
|
||||
let groups = cluster_rects(&rects, 0.0, 2);
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cluster_rects_oversized_not_starved_by_earlier_disjoint() {
|
||||
// 9k earlier disjoint drawings would exhaust an index-order cap of
|
||||
// 8,192 before the overlapping cell is visited.
|
||||
let mut rects: Vec<(f32, f32, f32, f32)> = (0..9_000)
|
||||
.map(|i| (10_000.0, i as f32 * 20.0, 10.0, 10.0))
|
||||
.collect();
|
||||
let wide = rects.len();
|
||||
rects.push((0.0, 0.0, 5000.0, 10.0));
|
||||
let target = rects.len();
|
||||
rects.push((4900.0, 0.0, 10.0, 10.0));
|
||||
let groups = cluster_rects(&rects, 0.0, 2);
|
||||
assert!(
|
||||
groups
|
||||
.iter()
|
||||
.any(|g| g.contains(&wide) && g.contains(&target)),
|
||||
"wide rule and far-end cell must share a cluster"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cluster_rects_wide_and_tall_oversized_union() {
|
||||
let rects = vec![(0.0, 0.0, 5000.0, 10.0), (0.0, 0.0, 10.0, 5000.0)];
|
||||
let groups = cluster_rects(&rects, 0.0, 2);
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cluster_rects_dual_oversized_spans_coarse_y() {
|
||||
let rects = vec![(0.0, 0.0, 5000.0, 5000.0), (0.0, 4500.0, 5000.0, 5000.0)];
|
||||
let groups = cluster_rects(&rects, 0.0, 2);
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cluster_rects_many_wide_and_tall_stays_subquadratic() {
|
||||
let mut rects = Vec::with_capacity(4_000);
|
||||
for i in 0..2_000 {
|
||||
rects.push((0.0, i as f32 * 20.0, 5000.0, 10.0));
|
||||
rects.push((i as f32 * 20.0, 0.0, 10.0, 5000.0));
|
||||
}
|
||||
let _groups = cluster_rects(&rects, 0.0, 2);
|
||||
}
|
||||
|
||||
// --- snap_edges ---
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -442,6 +442,16 @@ fn clean_table_cells(cells: &[Vec<String>]) -> (Vec<Vec<String>>, Vec<String>) {
|
||||
fn is_footnote_row(text: &str) -> bool {
|
||||
let trimmed = text.trim();
|
||||
|
||||
// Japanese documents commonly use the reference mark followed by an
|
||||
// ASCII or full-width number (for example `※1` / `※1`). These rows often
|
||||
// sit immediately below a wide table and must not be merged into its last
|
||||
// data row as wrapped first-column content.
|
||||
if let Some(rest) = trimmed.strip_prefix('※') {
|
||||
return rest.chars().next().is_some_and(|character| {
|
||||
character.is_ascii_digit() || ('0'..='9').contains(&character)
|
||||
});
|
||||
}
|
||||
|
||||
// Check for common footnote patterns
|
||||
// (1), (2), etc.
|
||||
if trimmed.starts_with('(') && trimmed.len() >= 2 {
|
||||
@@ -503,6 +513,13 @@ mod tests {
|
||||
assert!(is_footnote_row("NOTES: uppercase"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_footnote_row_reference_mark_number() {
|
||||
assert!(is_footnote_row("※1 explanation"));
|
||||
assert!(is_footnote_row("※1 説明"));
|
||||
assert!(!is_footnote_row("※ general marker"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_footnote_row_plain_text_false() {
|
||||
assert!(!is_footnote_row("Regular cell text"));
|
||||
|
||||
+2
-2
@@ -604,7 +604,7 @@ mod tests {
|
||||
let items: Vec<(usize, &TextItem)> = vec![];
|
||||
assert_eq!(
|
||||
find_column_boundaries(&items, TableDetectionMode::SmallFont),
|
||||
vec![]
|
||||
Vec::<f32>::new()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -661,7 +661,7 @@ mod tests {
|
||||
#[test]
|
||||
fn test_find_row_boundaries_empty() {
|
||||
let items: Vec<(usize, &TextItem)> = vec![];
|
||||
assert_eq!(find_row_boundaries(&items), vec![]);
|
||||
assert_eq!(find_row_boundaries(&items), Vec::<f32>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+211
-29
@@ -540,18 +540,14 @@ impl ToUnicodeCMap {
|
||||
|
||||
/// Remap a CMap that references pre-subsetting GIDs to sequential post-subsetting GIDs.
|
||||
/// Collects all source CIDs, sorts them, and reassigns to 1, 2, 3, ...
|
||||
///
|
||||
/// Range expansion stops after `MAX_CID_W_EXPANSION` CID visits, counting
|
||||
/// overwrites, so repeated full-width `bfrange`s cannot re-expand the
|
||||
/// 16-bit domain. Later overlapping ranges that would have introduced new
|
||||
/// CIDs after that many visits are truncated.
|
||||
pub fn remap_to_sequential(&self) -> ToUnicodeCMap {
|
||||
let mut cid_to_unicode: HashMap<u16, String> = HashMap::new();
|
||||
|
||||
// Expand ranges first
|
||||
for &(start, end, base) in &self.ranges {
|
||||
for cid in start..=end {
|
||||
let unicode_cp = base + (cid - start) as u32;
|
||||
if let Some(ch) = char::from_u32(unicode_cp) {
|
||||
cid_to_unicode.insert(cid, ch.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
expand_bfranges_for_remap(&self.ranges, &mut cid_to_unicode, MAX_CID_W_EXPANSION);
|
||||
|
||||
// char_map entries override range entries
|
||||
for (&cid, unicode) in &self.char_map {
|
||||
@@ -576,6 +572,33 @@ impl ToUnicodeCMap {
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand `bfrange` entries into individual CID→Unicode inserts.
|
||||
/// Returns how many CIDs were visited. Counts overwrites so a repeated
|
||||
/// full-width range cannot keep working after `max_assignments`.
|
||||
fn expand_bfranges_for_remap(
|
||||
ranges: &[(u16, u16, u32)],
|
||||
cid_to_unicode: &mut HashMap<u16, String>,
|
||||
max_assignments: usize,
|
||||
) -> usize {
|
||||
let mut assigned = 0usize;
|
||||
'ranges: for &(start, end, base) in ranges {
|
||||
if start > end {
|
||||
continue;
|
||||
}
|
||||
for cid in start..=end {
|
||||
if assigned >= max_assignments {
|
||||
break 'ranges;
|
||||
}
|
||||
assigned += 1;
|
||||
let unicode_cp = base + (cid - start) as u32;
|
||||
if let Some(ch) = char::from_u32(unicode_cp) {
|
||||
cid_to_unicode.insert(cid, ch.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
assigned
|
||||
}
|
||||
|
||||
/// Parse a hex string to u16
|
||||
fn parse_hex_u16(hex: &str) -> Option<u16> {
|
||||
u16::from_str_radix(hex.trim(), 16).ok()
|
||||
@@ -1561,23 +1584,31 @@ fn parse_encoding_cmap_stream(data: &[u8]) -> Option<EncodingCMap> {
|
||||
}
|
||||
|
||||
let mut map = HashMap::new();
|
||||
let mut assigned = 0usize;
|
||||
let mut pos = 0;
|
||||
while let Some(start) = text[pos..].find("begincidchar") {
|
||||
let section_start = pos + start + "begincidchar".len();
|
||||
if let Some(end) = text[section_start..].find("endcidchar") {
|
||||
let section = &text[section_start..section_start + end];
|
||||
parse_cidchar_section(section, &mut map, &mut src_hex_lengths);
|
||||
if !parse_cidchar_section(section, &mut map, &mut src_hex_lengths, &mut assigned) {
|
||||
break;
|
||||
}
|
||||
pos = section_start + end;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
pos = 0;
|
||||
while let Some(start) = text[pos..].find("begincidrange") {
|
||||
while assigned < MAX_CID_W_EXPANSION {
|
||||
let Some(start) = text[pos..].find("begincidrange") else {
|
||||
break;
|
||||
};
|
||||
let section_start = pos + start + "begincidrange".len();
|
||||
if let Some(end) = text[section_start..].find("endcidrange") {
|
||||
let section = &text[section_start..section_start + end];
|
||||
parse_cidrange_section(section, &mut map, &mut src_hex_lengths);
|
||||
if !parse_cidrange_section(section, &mut map, &mut src_hex_lengths, &mut assigned) {
|
||||
break;
|
||||
}
|
||||
pos = section_start + end;
|
||||
} else {
|
||||
break;
|
||||
@@ -1612,7 +1643,8 @@ fn parse_cidchar_section(
|
||||
section: &str,
|
||||
map: &mut HashMap<u16, u16>,
|
||||
src_hex_lengths: &mut Vec<usize>,
|
||||
) {
|
||||
assigned: &mut usize,
|
||||
) -> bool {
|
||||
let mut chars = section.chars().peekable();
|
||||
loop {
|
||||
while chars.peek().is_some_and(|c| c.is_whitespace()) {
|
||||
@@ -1643,16 +1675,20 @@ fn parse_cidchar_section(
|
||||
}
|
||||
}
|
||||
if let (Some(code), Ok(cid)) = (parse_hex_u16(&src_hex), cid_str.parse::<u16>()) {
|
||||
map.insert(code, cid);
|
||||
if !assign_encoding_cid(map, code, cid, assigned) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn parse_cidrange_section(
|
||||
section: &str,
|
||||
map: &mut HashMap<u16, u16>,
|
||||
src_hex_lengths: &mut Vec<usize>,
|
||||
) {
|
||||
assigned: &mut usize,
|
||||
) -> bool {
|
||||
let mut chars = section.chars().peekable();
|
||||
loop {
|
||||
while chars.peek().is_some_and(|c| c.is_whitespace()) {
|
||||
@@ -1703,12 +1739,34 @@ fn parse_cidrange_section(
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
if start > end {
|
||||
continue;
|
||||
}
|
||||
let mut cid = start_cid;
|
||||
for code in start..=end {
|
||||
map.insert(code, cid);
|
||||
if !assign_encoding_cid(map, code, cid, assigned) {
|
||||
return false;
|
||||
}
|
||||
cid = cid.saturating_add(1);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn assign_encoding_cid(
|
||||
map: &mut HashMap<u16, u16>,
|
||||
code: u16,
|
||||
cid: u16,
|
||||
assigned: &mut usize,
|
||||
) -> bool {
|
||||
// Count overwrites: unique-key coverage alone would not stop a repeated
|
||||
// full-width range from re-inserting all 65,536 codes.
|
||||
if *assigned >= MAX_CID_W_EXPANSION {
|
||||
return false;
|
||||
}
|
||||
map.insert(code, cid);
|
||||
*assigned += 1;
|
||||
true
|
||||
}
|
||||
|
||||
fn parse_binary_cmap_encoding(data: &[u8]) -> Result<EncodingCMap, String> {
|
||||
@@ -1832,6 +1890,13 @@ fn merge_cmaps(mut base: ToUnicodeCMap, overlay: ToUnicodeCMap) -> ToUnicodeCMap
|
||||
base
|
||||
}
|
||||
|
||||
/// Shared 16-bit CID expansion cap (65,536).
|
||||
/// Encoding `begincidrange`, `/W` width assignment, and ToUnicode sequential
|
||||
/// remap count every insert, including overwrites, so a repeated full-width
|
||||
/// range cannot keep working after the domain is filled. The `/W` unicode
|
||||
/// heuristic caps unique CIDs with the same number.
|
||||
pub(crate) const MAX_CID_W_EXPANSION: usize = 65_536;
|
||||
|
||||
/// Check if a CIDFont's /W (widths) array contains CID values that look like
|
||||
/// Unicode codepoints rather than low-value GIDs.
|
||||
///
|
||||
@@ -1843,20 +1908,23 @@ pub(crate) fn cid_values_look_like_unicode(cid_font_dict: &lopdf::Dictionary) ->
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
// The /W array format: [cid [w1 w2 ...]] or [cid_start cid_end w]
|
||||
// We extract all CID values (the first element of each group).
|
||||
let mut cids: Vec<u16> = Vec::new();
|
||||
// The /W array format: [cid [w1 w2 ...]] or [cid_start cid_end w].
|
||||
// Collect unique CIDs only: repeating a full-width range must not grow a
|
||||
// temporary vector (or the sort) with the range length on every copy.
|
||||
let mut seen = HashSet::new();
|
||||
let mut i = 0;
|
||||
while i < w_arr.len() {
|
||||
while i < w_arr.len() && seen.len() < MAX_CID_W_EXPANSION {
|
||||
if let Ok(cid) = w_arr[i].as_i64() {
|
||||
cids.push(cid as u16);
|
||||
// Skip the width data
|
||||
let start = cid as u16;
|
||||
if i + 1 < w_arr.len() {
|
||||
match &w_arr[i + 1] {
|
||||
Object::Array(widths) => {
|
||||
// [cid [w1 w2 ...]] — CIDs are cid, cid+1, ..., cid+len-1
|
||||
for j in 1..widths.len() {
|
||||
cids.push((cid as u16).wrapping_add(j as u16));
|
||||
for j in 0..widths.len() {
|
||||
if seen.len() >= MAX_CID_W_EXPANSION {
|
||||
break;
|
||||
}
|
||||
seen.insert(start.wrapping_add(j as u16));
|
||||
}
|
||||
i += 2;
|
||||
}
|
||||
@@ -1864,9 +1932,7 @@ pub(crate) fn cid_values_look_like_unicode(cid_font_dict: &lopdf::Dictionary) ->
|
||||
// [cid_start cid_end w] — range of CIDs
|
||||
if i + 2 < w_arr.len() {
|
||||
if let Ok(cid_end) = w_arr[i + 1].as_i64() {
|
||||
for c in (cid as u16)..=(cid_end as u16) {
|
||||
cids.push(c);
|
||||
}
|
||||
record_unique_cid_range(start, cid_end as u16, &mut seen);
|
||||
}
|
||||
i += 3;
|
||||
} else {
|
||||
@@ -1875,6 +1941,7 @@ pub(crate) fn cid_values_look_like_unicode(cid_font_dict: &lopdf::Dictionary) ->
|
||||
}
|
||||
}
|
||||
} else {
|
||||
seen.insert(start);
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
@@ -1882,10 +1949,11 @@ pub(crate) fn cid_values_look_like_unicode(cid_font_dict: &lopdf::Dictionary) ->
|
||||
}
|
||||
}
|
||||
|
||||
if cids.is_empty() {
|
||||
if seen.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut cids: Vec<u16> = seen.into_iter().collect();
|
||||
cids.sort_unstable();
|
||||
let median = cids[cids.len() / 2];
|
||||
// Unicode text CIDs are typically >= 0x20 (space) with letters at 0x41+.
|
||||
@@ -1894,6 +1962,18 @@ pub(crate) fn cid_values_look_like_unicode(cid_font_dict: &lopdf::Dictionary) ->
|
||||
median >= 0x41
|
||||
}
|
||||
|
||||
fn record_unique_cid_range(start: u16, end: u16, seen: &mut HashSet<u16>) {
|
||||
if start > end {
|
||||
return;
|
||||
}
|
||||
for cid in start..=end {
|
||||
if seen.len() >= MAX_CID_W_EXPANSION {
|
||||
return;
|
||||
}
|
||||
seen.insert(cid);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a ToUnicodeCMap from predefined CID→Unicode mapping based on CIDSystemInfo.
|
||||
///
|
||||
/// Supports Adobe-Korea1 (Korean) character collection. Can be extended for
|
||||
@@ -2867,6 +2947,33 @@ endbfrange
|
||||
assert!(remapped.ranges.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remap_to_sequential_repeated_full_bfranges_stay_bounded() {
|
||||
// 5,000 copies of `<0003> <ffff>` must stop after 65,536 CID visits,
|
||||
// not 5,000 × ~65,533 expansions.
|
||||
let ranges = vec![(3u16, 65535u16, 0x41u32); 5_000];
|
||||
let mut map = std::collections::HashMap::new();
|
||||
let assigned = expand_bfranges_for_remap(&ranges, &mut map, MAX_CID_W_EXPANSION);
|
||||
assert_eq!(assigned, MAX_CID_W_EXPANSION);
|
||||
assert!(map.len() <= MAX_CID_W_EXPANSION);
|
||||
|
||||
let mut body = String::new();
|
||||
let mut remaining = 5_000usize;
|
||||
while remaining > 0 {
|
||||
let n = remaining.min(100);
|
||||
body.push_str(&format!("{n} beginbfrange\n"));
|
||||
for _ in 0..n {
|
||||
body.push_str("<0003> <ffff> <0041>\n");
|
||||
}
|
||||
body.push_str("endbfrange\n");
|
||||
remaining -= n;
|
||||
}
|
||||
let data = format!("1 begincodespacerange\n<0000> <ffff>\nendcodespacerange\n{body}");
|
||||
let cmap = ToUnicodeCMap::parse(data.as_bytes()).unwrap();
|
||||
let remapped = cmap.remap_to_sequential();
|
||||
assert_eq!(remapped.lookup(1), Some("A".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_min_source_cid() {
|
||||
let cmap_content = r#"
|
||||
@@ -3298,4 +3405,79 @@ endbfrange
|
||||
"An indirect /Subtype naming CIDFontType2 must still reach the remap"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cid_values_look_like_unicode_letter_range() {
|
||||
let mut dict = lopdf::Dictionary::new();
|
||||
dict.set(
|
||||
"W",
|
||||
Object::Array(vec![
|
||||
Object::Integer(0x41),
|
||||
Object::Integer(0x5A),
|
||||
Object::Integer(500),
|
||||
]),
|
||||
);
|
||||
assert!(cid_values_look_like_unicode(&dict));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cid_values_look_like_unicode_low_gids() {
|
||||
let mut dict = lopdf::Dictionary::new();
|
||||
dict.set(
|
||||
"W",
|
||||
Object::Array(vec![
|
||||
Object::Integer(0),
|
||||
Object::Array(vec![Object::Integer(500); 10]),
|
||||
]),
|
||||
);
|
||||
assert!(!cid_values_look_like_unicode(&dict));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cid_values_look_like_unicode_repeated_full_ranges_stay_bounded() {
|
||||
// Repeating `[0 65535 w]` must not materialize 65,536 CIDs per copy.
|
||||
let mut w = Vec::new();
|
||||
for _ in 0..5_000 {
|
||||
w.push(Object::Integer(0));
|
||||
w.push(Object::Integer(65535));
|
||||
w.push(Object::Integer(500));
|
||||
}
|
||||
let mut dict = lopdf::Dictionary::new();
|
||||
dict.set("W", Object::Array(w));
|
||||
assert!(cid_values_look_like_unicode(&dict));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoding_cidrange_maps_a_normal_range() {
|
||||
let data = b"1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n\
|
||||
1 begincidrange\n<0041> <0043> 65\nendcidrange\n";
|
||||
let enc = parse_encoding_cmap_stream(data).unwrap();
|
||||
assert_eq!(enc.map.get(&0x41), Some(&65));
|
||||
assert_eq!(enc.map.get(&0x42), Some(&66));
|
||||
assert_eq!(enc.map.get(&0x43), Some(&67));
|
||||
assert_eq!(enc.map.len(), 3);
|
||||
assert_eq!(enc.code_byte_length, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoding_cidrange_repeated_full_ranges_stay_bounded() {
|
||||
// 5,000 copies of `<0000> <ffff> 0` must not re-expand the 16-bit
|
||||
// domain on every declaration.
|
||||
let mut body = String::new();
|
||||
let mut remaining = 5_000usize;
|
||||
while remaining > 0 {
|
||||
let n = remaining.min(100);
|
||||
body.push_str(&format!("{n} begincidrange\n"));
|
||||
for _ in 0..n {
|
||||
body.push_str("<0000> <ffff> 0\n");
|
||||
}
|
||||
body.push_str("endcidrange\n");
|
||||
remaining -= n;
|
||||
}
|
||||
let data = format!("1 begincodespacerange\n<0000> <FFFF>\nendcodespacerange\n{body}");
|
||||
let enc = parse_encoding_cmap_stream(data.as_bytes()).unwrap();
|
||||
assert!(enc.map.len() <= MAX_CID_W_EXPANSION);
|
||||
assert_eq!(enc.map.get(&0), Some(&0));
|
||||
assert_eq!(enc.map.get(&65535), Some(&65535));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
//! Public contracts between rendering, OCR, and orchestration.
|
||||
|
||||
use std::error::Error;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::{RenderOptions, RenderedPage};
|
||||
|
||||
/// Selects when OCR may run.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum OcrMode {
|
||||
/// Never run OCR. This is the default and preserves existing behavior.
|
||||
#[default]
|
||||
Off,
|
||||
/// Run OCR only on pages selected by pdf-inspector's OCR routing signals.
|
||||
Auto,
|
||||
/// Run OCR on every selected page, including pages with native text.
|
||||
Force,
|
||||
}
|
||||
|
||||
/// Controls whether missing model artifacts may be fetched.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum ModelDownloadPolicy {
|
||||
/// Fetch a pinned artifact only after OCR has actually been selected.
|
||||
#[default]
|
||||
IfMissing,
|
||||
/// Never access the network; require an override or a warm model cache.
|
||||
Offline,
|
||||
}
|
||||
|
||||
/// OCR engine configuration independent of a particular runtime.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct OcrOptions {
|
||||
/// Page-level routing behavior.
|
||||
pub mode: OcrMode,
|
||||
/// Drop recognition spans below this confidence threshold.
|
||||
pub minimum_confidence: f32,
|
||||
/// Optional directory containing an offline model set.
|
||||
pub model_directory: Option<PathBuf>,
|
||||
/// Whether a missing pinned artifact may be downloaded.
|
||||
pub model_downloads: ModelDownloadPolicy,
|
||||
}
|
||||
|
||||
impl Default for OcrOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mode: OcrMode::Off,
|
||||
minimum_confidence: 0.0,
|
||||
model_directory: None,
|
||||
model_downloads: ModelDownloadPolicy::IfMissing,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OcrOptions {
|
||||
/// Creates OCR options with OCR disabled.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Sets page-level OCR routing.
|
||||
pub fn mode(mut self, mode: OcrMode) -> Self {
|
||||
self.mode = mode;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the minimum accepted recognition confidence.
|
||||
pub fn minimum_confidence(mut self, minimum_confidence: f32) -> Self {
|
||||
self.minimum_confidence = minimum_confidence;
|
||||
self
|
||||
}
|
||||
|
||||
/// Uses an explicit model directory, suitable for offline packaging.
|
||||
pub fn model_directory(mut self, directory: impl Into<PathBuf>) -> Self {
|
||||
self.model_directory = Some(directory.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the missing-model download policy.
|
||||
pub fn model_downloads(mut self, policy: ModelDownloadPolicy) -> Self {
|
||||
self.model_downloads = policy;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A point in bitmap space, measured from the top-left in pixels.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct ImagePoint {
|
||||
/// Horizontal pixel coordinate.
|
||||
pub x: f32,
|
||||
/// Vertical pixel coordinate, increasing downward.
|
||||
pub y: f32,
|
||||
}
|
||||
|
||||
impl ImagePoint {
|
||||
/// Creates a bitmap-space point.
|
||||
pub fn new(x: f32, y: f32) -> Self {
|
||||
Self { x, y }
|
||||
}
|
||||
}
|
||||
|
||||
/// Four-point polygon in bitmap coordinates.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct ImageQuad {
|
||||
/// Polygon points in engine-provided order.
|
||||
pub points: [ImagePoint; 4],
|
||||
}
|
||||
|
||||
impl ImageQuad {
|
||||
/// Creates a four-point bitmap polygon.
|
||||
pub fn new(points: [ImagePoint; 4]) -> Self {
|
||||
Self { points }
|
||||
}
|
||||
}
|
||||
|
||||
/// Stable identity for an inference model used in output provenance.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ModelIdentity {
|
||||
/// Model family/name, for example `pp-ocrv6-small`.
|
||||
pub name: String,
|
||||
/// Immutable model or artifact-set revision.
|
||||
pub revision: String,
|
||||
}
|
||||
|
||||
impl ModelIdentity {
|
||||
/// Creates a model identity.
|
||||
pub fn new(name: impl Into<String>, revision: impl Into<String>) -> Self {
|
||||
Self {
|
||||
name: name.into(),
|
||||
revision: revision.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One positioned OCR recognition result in bitmap coordinates.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct OcrSpan {
|
||||
/// Recognized text.
|
||||
pub text: String,
|
||||
/// Detection polygon in the original rendered page's pixel space.
|
||||
pub polygon: ImageQuad,
|
||||
/// Recognition confidence in the inclusive range 0–1.
|
||||
pub confidence: f32,
|
||||
/// Optional text-line orientation in clockwise degrees.
|
||||
pub orientation_degrees: Option<f32>,
|
||||
}
|
||||
|
||||
/// OCR output for one 1-indexed page.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct OcrPage {
|
||||
/// 1-indexed PDF page number.
|
||||
pub page_number: u32,
|
||||
/// Positioned recognition spans.
|
||||
pub spans: Vec<OcrSpan>,
|
||||
/// Mean confidence across accepted spans, when available.
|
||||
pub mean_confidence: Option<f32>,
|
||||
/// Exact model identity used for this result.
|
||||
pub model: ModelIdentity,
|
||||
/// OCR wall time for this page.
|
||||
pub processing_time_ms: u64,
|
||||
/// Non-fatal engine warnings.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// How final page content was sourced.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum PageContentSource {
|
||||
/// Trusted native PDF text only.
|
||||
Native,
|
||||
/// OCR output only.
|
||||
Ocr,
|
||||
/// Native and OCR spans were fused.
|
||||
Fused,
|
||||
}
|
||||
|
||||
/// Per-page local processing timings.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub struct VisionTimings {
|
||||
/// Rasterization wall time.
|
||||
pub render_ms: u64,
|
||||
/// OCR wall time.
|
||||
pub ocr_ms: u64,
|
||||
/// Native/OCR fusion and assembly wall time.
|
||||
pub assembly_ms: u64,
|
||||
}
|
||||
|
||||
/// Source and model metadata retained for one processed page.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PageProvenance {
|
||||
/// 1-indexed PDF page number.
|
||||
pub page_number: u32,
|
||||
/// Final page-content source.
|
||||
pub source: PageContentSource,
|
||||
/// OCR model, when OCR ran.
|
||||
pub ocr_model: Option<ModelIdentity>,
|
||||
/// Render resolution used for local vision.
|
||||
pub render_dpi: Option<f32>,
|
||||
/// Mean accepted OCR confidence, when available.
|
||||
pub ocr_confidence: Option<f32>,
|
||||
/// Stage timings.
|
||||
pub timings: VisionTimings,
|
||||
/// Non-fatal warnings surfaced to downstream users.
|
||||
pub warnings: Vec<String>,
|
||||
/// True when this lightweight local path detected a case better suited to
|
||||
/// Firecrawl's hosted document pipeline.
|
||||
pub hosted_recommended: bool,
|
||||
}
|
||||
|
||||
/// Converts selected PDF pages into renderer-neutral owned bitmaps.
|
||||
pub trait PageRenderer: Send + Sync {
|
||||
/// Renderer-specific failure type.
|
||||
type Error: Error + Send + Sync + 'static;
|
||||
|
||||
/// Renders selected 1-indexed pages in the same order as `pages`.
|
||||
fn render_pages(
|
||||
&self,
|
||||
pdf_bytes: &[u8],
|
||||
pages: &[u32],
|
||||
password: Option<&str>,
|
||||
options: &RenderOptions,
|
||||
) -> Result<Vec<RenderedPage>, Self::Error>;
|
||||
}
|
||||
|
||||
/// Recognizes positioned text from rendered pages.
|
||||
pub trait OcrEngine: Send + Sync {
|
||||
/// Engine-specific failure type.
|
||||
type Error: Error + Send + Sync + 'static;
|
||||
|
||||
/// Exact model identity used by this engine instance.
|
||||
fn model(&self) -> &ModelIdentity;
|
||||
|
||||
/// Recognizes pages in batch and returns results in input order.
|
||||
fn recognize(
|
||||
&self,
|
||||
pages: &[RenderedPage],
|
||||
options: &OcrOptions,
|
||||
) -> Result<Vec<OcrPage>, Self::Error>;
|
||||
|
||||
/// Number of pages this engine can process concurrently in one
|
||||
/// `recognize` call. The pipeline sizes its page batches from this so a
|
||||
/// parallel engine is not starved by small chunks; `1` means sequential.
|
||||
fn preferred_page_concurrency(&self) -> usize {
|
||||
1
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ocr_defaults_never_enable_recognition() {
|
||||
let options = OcrOptions::default();
|
||||
assert_eq!(options.mode, OcrMode::Off);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn offline_model_override_is_explicit() {
|
||||
let options = OcrOptions::new()
|
||||
.mode(OcrMode::Auto)
|
||||
.model_directory("/models/pp-ocr")
|
||||
.model_downloads(ModelDownloadPolicy::Offline);
|
||||
assert_eq!(options.mode, OcrMode::Auto);
|
||||
assert_eq!(options.model_downloads, ModelDownloadPolicy::Offline);
|
||||
assert_eq!(
|
||||
options.model_directory,
|
||||
Some(PathBuf::from("/models/pp-ocr"))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
//! HTTPS acquisition for pinned local model artifacts.
|
||||
|
||||
use std::fmt;
|
||||
use std::io::Read;
|
||||
use std::time::Duration;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use super::{ModelArtifact, ModelDownloader};
|
||||
|
||||
/// Default end-to-end timeout for one model artifact request.
|
||||
pub const DEFAULT_MODEL_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
/// Streaming HTTPS downloader used by lazy model resolution.
|
||||
#[derive(Clone)]
|
||||
pub struct HttpModelDownloader {
|
||||
agent: ureq::Agent,
|
||||
}
|
||||
|
||||
impl fmt::Debug for HttpModelDownloader {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter
|
||||
.debug_struct("HttpModelDownloader")
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for HttpModelDownloader {
|
||||
fn default() -> Self {
|
||||
Self::new(DEFAULT_MODEL_DOWNLOAD_TIMEOUT)
|
||||
}
|
||||
}
|
||||
|
||||
impl HttpModelDownloader {
|
||||
/// Creates an HTTPS-only downloader with an end-to-end request timeout.
|
||||
pub fn new(timeout: Duration) -> Self {
|
||||
let config = ureq::Agent::config_builder()
|
||||
.https_only(true)
|
||||
.timeout_global(Some(timeout))
|
||||
.user_agent(concat!("pdf-inspector/", env!("CARGO_PKG_VERSION")))
|
||||
.build();
|
||||
Self {
|
||||
agent: ureq::Agent::new_with_config(config),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelDownloader for HttpModelDownloader {
|
||||
type Error = HttpModelDownloadError;
|
||||
|
||||
fn open(&self, artifact: &ModelArtifact) -> Result<Box<dyn Read + Send>, Self::Error> {
|
||||
let response = self.agent.get(artifact.url).call()?;
|
||||
if let Some(actual) = response.body().content_length() {
|
||||
if actual != artifact.size {
|
||||
return Err(HttpModelDownloadError::ContentLength {
|
||||
expected: artifact.size,
|
||||
actual,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// One extra byte lets ModelStore report an exact size mismatch while
|
||||
// preventing a malicious or broken server from filling the disk.
|
||||
let limit = artifact.size.saturating_add(1);
|
||||
Ok(Box::new(response.into_body().into_reader().take(limit)))
|
||||
}
|
||||
}
|
||||
|
||||
/// Failures before a response stream reaches [`super::ModelStore`].
|
||||
#[derive(Debug, Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum HttpModelDownloadError {
|
||||
/// DNS, TLS, redirect, HTTP status, or response-stream setup failed.
|
||||
#[error(transparent)]
|
||||
Request(#[from] ureq::Error),
|
||||
/// The server declared a size that disagrees with the pinned manifest.
|
||||
#[error("server declared {actual} bytes; manifest requires {expected}")]
|
||||
ContentLength {
|
||||
/// Pinned artifact size.
|
||||
expected: u64,
|
||||
/// Server-declared size.
|
||||
actual: u64,
|
||||
},
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,65 @@
|
||||
//! Optional native vision primitives used by OCR pipelines.
|
||||
//!
|
||||
//! The existing lopdf extractor remains the default path. Native page
|
||||
//! rendering is available only with the `render-pdfium` feature. Engine
|
||||
//! contracts are available with `vision`, while checksum-verified model
|
||||
//! resolution is a separate `model-cache` feature. The `ocr-oar` feature adds
|
||||
//! a CPU PP-OCRv6 Small implementation of [`OcrEngine`]. These remain separate
|
||||
//! so browser WASM, text-only consumers, and renderer-only users take on no
|
||||
//! model-management or inference dependencies.
|
||||
|
||||
#[cfg(all(feature = "vision", not(target_arch = "wasm32")))]
|
||||
mod contracts;
|
||||
#[cfg(all(feature = "model-download", not(target_arch = "wasm32")))]
|
||||
mod download;
|
||||
#[cfg(all(feature = "vision", not(target_arch = "wasm32")))]
|
||||
mod fusion;
|
||||
#[cfg(all(feature = "model-cache", not(target_arch = "wasm32")))]
|
||||
mod models;
|
||||
#[cfg(all(feature = "ocr-oar", not(target_arch = "wasm32")))]
|
||||
mod oar;
|
||||
#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
|
||||
mod pipeline;
|
||||
#[cfg(all(feature = "vision", not(target_arch = "wasm32")))]
|
||||
mod render;
|
||||
#[cfg(all(feature = "vision", not(target_arch = "wasm32")))]
|
||||
mod routing;
|
||||
|
||||
#[cfg(all(feature = "render-pdfium", not(target_arch = "wasm32")))]
|
||||
mod pdfium;
|
||||
|
||||
#[cfg(all(feature = "vision", not(target_arch = "wasm32")))]
|
||||
pub use contracts::{
|
||||
ImagePoint, ImageQuad, ModelDownloadPolicy, ModelIdentity, OcrEngine, OcrMode, OcrOptions,
|
||||
OcrPage, OcrSpan, PageContentSource, PageProvenance, PageRenderer, VisionTimings,
|
||||
};
|
||||
#[cfg(all(feature = "model-download", not(target_arch = "wasm32")))]
|
||||
pub use download::{HttpModelDownloadError, HttpModelDownloader, DEFAULT_MODEL_DOWNLOAD_TIMEOUT};
|
||||
#[cfg(all(feature = "vision", not(target_arch = "wasm32")))]
|
||||
pub use fusion::{
|
||||
fuse_ocr_pages, ocr_page_to_markdown, FusedPageMarkdown, FusedPages, OcrFusionError,
|
||||
OcrFusionOptions,
|
||||
};
|
||||
#[cfg(all(feature = "model-cache", not(target_arch = "wasm32")))]
|
||||
pub use models::{
|
||||
ModelAcquireError, ModelArtifact, ModelArtifactKind, ModelDownloader, ModelManifest,
|
||||
ModelPaths, ModelStore, ModelStoreError, PP_OCR_V6_SMALL,
|
||||
};
|
||||
#[cfg(all(feature = "ocr-oar", not(target_arch = "wasm32")))]
|
||||
pub use oar::{OarOcrEngine, OarOcrError, ONNX_RUNTIME_LIBRARY_ENV};
|
||||
#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
|
||||
pub use pipeline::{
|
||||
process_pdf_with_ocr, process_pdf_with_ocr_mem, OcrPdfOptions, OcrPdfResult, OcrPipelineError,
|
||||
};
|
||||
#[cfg(all(feature = "vision", not(target_arch = "wasm32")))]
|
||||
pub use render::{
|
||||
PagePoint, PageTransform, RenderBufferError, RenderOptions, RenderPixelFormat, RenderedPage,
|
||||
DEFAULT_RENDER_DPI,
|
||||
};
|
||||
#[cfg(all(feature = "vision", not(target_arch = "wasm32")))]
|
||||
pub use routing::{
|
||||
route_ocr_pages, run_ocr_pages, OcrRoutingError, OcrRun, OcrRunError, RoutedOcrPage,
|
||||
};
|
||||
|
||||
#[cfg(all(feature = "render-pdfium", not(target_arch = "wasm32")))]
|
||||
pub use pdfium::{PdfiumRenderer, RenderError};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,889 @@
|
||||
//! PP-OCRv6 Small implementation backed by OAR and ONNX Runtime.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
|
||||
use image::RgbImage;
|
||||
use oar_ocr::core::config::onnx::OrtSessionConfig;
|
||||
use oar_ocr::domain::tasks::TextDetectionConfig;
|
||||
use oar_ocr::oarocr::{EdgeProcessor, TextCroppingProcessor};
|
||||
use oar_ocr::predictors::{TextDetectionPredictor, TextRecognitionPredictor};
|
||||
use oar_ocr::processors::BoundingBox;
|
||||
use thiserror::Error;
|
||||
|
||||
use super::{
|
||||
ImagePoint, ImageQuad, ModelArtifactKind, ModelIdentity, ModelPaths, OcrEngine, OcrMode,
|
||||
OcrOptions, OcrPage, OcrSpan, RenderPixelFormat, RenderedPage,
|
||||
};
|
||||
|
||||
/// Environment variable selecting the ONNX Runtime shared library.
|
||||
pub const ONNX_RUNTIME_LIBRARY_ENV: &str = "ORT_DYLIB_PATH";
|
||||
|
||||
/// Failures while constructing or running the OAR OCR backend.
|
||||
#[derive(Debug, Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum OarOcrError {
|
||||
/// A required file is missing from the resolved model set.
|
||||
#[error("resolved OCR model set is missing {kind:?}")]
|
||||
MissingModelArtifact {
|
||||
/// Missing artifact role.
|
||||
kind: ModelArtifactKind,
|
||||
},
|
||||
/// OCR was invoked while the caller explicitly disabled it.
|
||||
#[error("OCR is disabled; select Auto or Force before invoking the engine")]
|
||||
OcrDisabled,
|
||||
/// Confidence thresholds must match the normalized engine output range.
|
||||
#[error("minimum OCR confidence must be finite and between 0 and 1, got {value}")]
|
||||
InvalidMinimumConfidence {
|
||||
/// Invalid threshold.
|
||||
value: f32,
|
||||
},
|
||||
/// Bitmap dimension arithmetic exceeded the host address space.
|
||||
#[error("rendered page {page} bitmap dimensions overflow the host address space")]
|
||||
ImageSizeOverflow {
|
||||
/// 1-indexed page number.
|
||||
page: u32,
|
||||
},
|
||||
/// A validated renderer buffer could not be represented as an RGB image.
|
||||
#[error("rendered page {page} could not be converted to an RGB image")]
|
||||
InvalidImageBuffer {
|
||||
/// 1-indexed page number.
|
||||
page: u32,
|
||||
},
|
||||
/// The external ONNX Runtime shared library could not be loaded.
|
||||
#[error(
|
||||
"failed to load ONNX Runtime from {path}; install a compatible ONNX Runtime shared library or set ORT_DYLIB_PATH to its path: {source}"
|
||||
)]
|
||||
OnnxRuntimeLoad {
|
||||
/// Requested shared-library path or platform library name.
|
||||
path: PathBuf,
|
||||
/// Dynamic-loader failure.
|
||||
#[source]
|
||||
source: ort::LoadDynamicError,
|
||||
},
|
||||
/// OAR returned no result for a submitted page.
|
||||
#[error("OAR returned no result for rendered page {page}")]
|
||||
MissingPageResult {
|
||||
/// 1-indexed page number.
|
||||
page: u32,
|
||||
},
|
||||
/// OAR or ONNX Runtime rejected the models or failed during inference.
|
||||
#[error(transparent)]
|
||||
Backend(#[from] oar_ocr::core::OCRError),
|
||||
}
|
||||
|
||||
/// Standard detection input cap. PP-OCR detection resizes each page so its
|
||||
/// longest side fits this before inference; it is the PaddleOCR default and
|
||||
/// is sufficient for ordinary body text at 150 DPI.
|
||||
const DETECTION_LIMIT_STANDARD: u32 = 960;
|
||||
|
||||
/// Escalated detection input cap for dense fine-print pages. Beyond this the
|
||||
/// measured recall plateaus while inference cost keeps growing.
|
||||
const DETECTION_LIMIT_ESCALATED: u32 = 2560;
|
||||
|
||||
/// Hard ceiling protecting detection from out-of-memory on giant renders.
|
||||
const DETECTION_MAXIMUM_SIDE: u32 = 4000;
|
||||
|
||||
/// Escalate only for pages dense with small text: at least this many detected
|
||||
/// regions in the standard pass...
|
||||
const ESCALATION_MINIMUM_REGIONS: usize = 80;
|
||||
|
||||
/// ...whose median height, at detection scale, is below this. Calibrated at
|
||||
/// `unclip_ratio` 2.0 (the expansion inflates measured heights, so this
|
||||
/// constant is coupled to [`detection_config`]): dense fine-print pages that
|
||||
/// gain from escalation measure 12.0–14.2 px with 144+ regions; the nearest
|
||||
/// non-gaining page above the region gate (an engineering drawing) measures
|
||||
/// 15.7 px, and prose/typewriter pages measure 14.5 px+ with too few
|
||||
/// regions to qualify at all.
|
||||
const ESCALATION_MAXIMUM_MEDIAN_HEIGHT: f32 = 15.0;
|
||||
|
||||
/// One worker's model sessions: a standard-limit detector plus a recognizer,
|
||||
/// and that worker's own lazily built escalated-limit detector.
|
||||
/// Staged (detect, crop, recognize as separate calls) rather than OAROCR's
|
||||
/// combined `predict` so an escalated page replaces only its detection pass —
|
||||
/// recognition runs exactly once, on the final region set.
|
||||
struct OcrWorker {
|
||||
detector: TextDetectionPredictor,
|
||||
recognizer: TextRecognitionPredictor,
|
||||
/// Built on this worker's first dense fine-print page. `None` inside the
|
||||
/// cell records a failed build so it is not retried per page.
|
||||
escalated: std::sync::OnceLock<Option<TextDetectionPredictor>>,
|
||||
}
|
||||
|
||||
/// CPU PP-OCRv6 Small engine using OAR's detection and recognition components.
|
||||
///
|
||||
/// Construction accepts only [`ModelPaths`] that have already passed
|
||||
/// pdf-inspector's manifest size and SHA-256 verification. OAR's independent
|
||||
/// model auto-download feature is deliberately not enabled.
|
||||
pub struct OarOcrEngine {
|
||||
workers: Vec<OcrWorker>,
|
||||
detection_path: PathBuf,
|
||||
intra_threads: usize,
|
||||
/// Present only when more than one worker exists; sized to match.
|
||||
pool: Option<rayon::ThreadPool>,
|
||||
model: ModelIdentity,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for OarOcrEngine {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("OarOcrEngine")
|
||||
.field("workers", &self.workers.len())
|
||||
.field("parallel", &self.pool.is_some())
|
||||
.field("model", &self.model)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Pages processed concurrently: one OAROCR pipeline (and its ONNX sessions)
|
||||
/// per worker, because oar-ocr serializes each session behind a mutex.
|
||||
/// Measured on CPU: workers beyond 3 stop scaling (memory-bandwidth bound)
|
||||
/// and each worker is fastest with 2 intra-op threads.
|
||||
fn pipeline_concurrency() -> usize {
|
||||
let cores = std::thread::available_parallelism()
|
||||
.map(std::num::NonZeroUsize::get)
|
||||
.unwrap_or(1);
|
||||
(cores / 4).clamp(1, 3)
|
||||
}
|
||||
|
||||
fn intra_threads_per_pipeline(concurrency: usize) -> usize {
|
||||
let cores = std::thread::available_parallelism()
|
||||
.map(std::num::NonZeroUsize::get)
|
||||
.unwrap_or(1);
|
||||
if concurrency > 1 {
|
||||
2
|
||||
} else {
|
||||
cores.min(4)
|
||||
}
|
||||
}
|
||||
|
||||
/// True when a standard-limit detection pass over a downscaled page shows
|
||||
/// dense, small text: the page deserves a second pass at the escalated limit.
|
||||
fn should_escalate_detection(
|
||||
median_detection_height: f32,
|
||||
region_count: usize,
|
||||
downscale: f32,
|
||||
) -> bool {
|
||||
downscale < 1.0
|
||||
&& region_count >= ESCALATION_MINIMUM_REGIONS
|
||||
&& median_detection_height < ESCALATION_MAXIMUM_MEDIAN_HEIGHT
|
||||
}
|
||||
|
||||
/// Median detected-region height in detection-input pixels: original-image
|
||||
/// heights multiplied by the downscale detection applied.
|
||||
fn median_detection_height(heights: &mut [f32], downscale: f32) -> f32 {
|
||||
if heights.is_empty() {
|
||||
return f32::MAX;
|
||||
}
|
||||
heights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
|
||||
let middle = heights.len() / 2;
|
||||
let median = if heights.len().is_multiple_of(2) {
|
||||
(heights[middle - 1] + heights[middle]) / 2.0
|
||||
} else {
|
||||
heights[middle]
|
||||
};
|
||||
median * downscale
|
||||
}
|
||||
|
||||
/// Detection preprocessing config at a given input cap.
|
||||
///
|
||||
/// Supplying an explicit config suppresses OAROCR's "general" text-type
|
||||
/// overrides, so every field the override would have set must be pinned
|
||||
/// here to match what the combined pipeline ran with before the staged
|
||||
/// split: score 0.3 and box 0.6 (equal to [`TextDetectionConfig`]'s
|
||||
/// defaults) and unclip 2.0 (the default is 1.5 — leaving it would
|
||||
/// silently shrink detection-box expansion and risk clipping edge glyphs).
|
||||
fn detection_config(detection_limit: u32) -> TextDetectionConfig {
|
||||
TextDetectionConfig {
|
||||
limit_side_len: Some(detection_limit),
|
||||
limit_type: Some(oar_ocr::processors::LimitType::Max),
|
||||
max_side_len: Some(DETECTION_MAXIMUM_SIDE),
|
||||
unclip_ratio: 2.0,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn build_detector(
|
||||
detection: &std::path::Path,
|
||||
detection_limit: u32,
|
||||
intra_threads: usize,
|
||||
) -> Result<TextDetectionPredictor, OarOcrError> {
|
||||
Ok(TextDetectionPredictor::builder()
|
||||
.with_config(detection_config(detection_limit))
|
||||
.with_ort_config(ocr_session_config(intra_threads))
|
||||
.build(detection)?)
|
||||
}
|
||||
|
||||
fn build_workers(
|
||||
detection: &std::path::Path,
|
||||
recognition: &std::path::Path,
|
||||
dictionary: &std::path::Path,
|
||||
count: usize,
|
||||
intra_threads: usize,
|
||||
) -> Result<Vec<OcrWorker>, OarOcrError> {
|
||||
let mut workers = Vec::with_capacity(count);
|
||||
for _ in 0..count {
|
||||
let detector = build_detector(detection, DETECTION_LIMIT_STANDARD, intra_threads)?;
|
||||
let recognizer = TextRecognitionPredictor::builder()
|
||||
.dict_path(dictionary)
|
||||
.with_ort_config(ocr_session_config(intra_threads))
|
||||
.build(recognition)?;
|
||||
workers.push(OcrWorker {
|
||||
detector,
|
||||
recognizer,
|
||||
escalated: std::sync::OnceLock::new(),
|
||||
});
|
||||
}
|
||||
Ok(workers)
|
||||
}
|
||||
|
||||
impl OarOcrEngine {
|
||||
/// Loads PP-OCRv6 Small from a resolved, verified model set.
|
||||
pub fn from_models(models: &ModelPaths) -> Result<Self, OarOcrError> {
|
||||
load_onnx_runtime()?;
|
||||
let detection = required_model(models, ModelArtifactKind::TextDetection)?;
|
||||
let recognition = required_model(models, ModelArtifactKind::TextRecognition)?;
|
||||
let dictionary = required_model(models, ModelArtifactKind::CharacterDictionary)?;
|
||||
|
||||
let concurrency = pipeline_concurrency();
|
||||
let intra_threads = intra_threads_per_pipeline(concurrency);
|
||||
let workers = build_workers(
|
||||
detection,
|
||||
recognition,
|
||||
dictionary,
|
||||
concurrency,
|
||||
intra_threads,
|
||||
)?;
|
||||
let pool = if concurrency > 1 {
|
||||
rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(concurrency)
|
||||
.build()
|
||||
.ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let model = ModelIdentity::new(models.manifest_id(), models.revision());
|
||||
Ok(Self {
|
||||
workers,
|
||||
detection_path: detection.to_path_buf(),
|
||||
intra_threads,
|
||||
pool,
|
||||
model,
|
||||
})
|
||||
}
|
||||
|
||||
/// This worker's escalated-limit detector, built on first use.
|
||||
fn escalated_detector<'w>(&self, worker: &'w OcrWorker) -> Option<&'w TextDetectionPredictor> {
|
||||
worker
|
||||
.escalated
|
||||
.get_or_init(|| {
|
||||
match build_detector(
|
||||
&self.detection_path,
|
||||
DETECTION_LIMIT_ESCALATED,
|
||||
self.intra_threads,
|
||||
) {
|
||||
Ok(detector) => Some(detector),
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"escalated OCR detection unavailable, keeping standard pass: {error}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
/// Detects text regions for one page: a standard-limit pass first, then —
|
||||
/// for pages the standard limit demonstrably under-resolves — a second
|
||||
/// pass at the escalated limit whose boxes replace the first. Pages whose
|
||||
/// render dwarfs even the escalated limit skip the standard pass outright.
|
||||
fn detect_boxes(
|
||||
&self,
|
||||
page: &RenderedPage,
|
||||
image: &Arc<RgbImage>,
|
||||
worker: &OcrWorker,
|
||||
) -> Result<Vec<BoundingBox>, OarOcrError> {
|
||||
let longest_side = page.width().max(page.height()) as f32;
|
||||
|
||||
// A page more than twice the standard limit loses over half its
|
||||
// resolution before detection even runs; go straight to the escalated
|
||||
// detector instead of paying a doomed standard pass.
|
||||
if longest_side > (DETECTION_LIMIT_STANDARD * 2) as f32 {
|
||||
if let Some(escalated) = self.escalated_detector(worker) {
|
||||
log::debug!(
|
||||
"page {}: direct escalated detection (render {longest_side}px)",
|
||||
page.page(),
|
||||
);
|
||||
match detect_with(escalated, image, page.page()) {
|
||||
Ok(boxes) => return Ok(boxes),
|
||||
Err(error) => {
|
||||
// Same degradation as the adaptive branch below: a
|
||||
// failing escalated pass falls back to standard
|
||||
// detection instead of failing the page outright.
|
||||
// Return the standard boxes directly — the adaptive
|
||||
// trigger would only re-invoke the detector that
|
||||
// just failed (repeating an OOM on a dense page).
|
||||
log::warn!(
|
||||
"page {}: direct escalated detection failed, using standard pass: {error}",
|
||||
page.page()
|
||||
);
|
||||
return detect_with(&worker.detector, image, page.page());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let detections = detect_with(&worker.detector, image, page.page())?;
|
||||
|
||||
// Dense fine-print pages (broadsheets, pricing sheets) lose most of
|
||||
// their text when detection downscales them to the standard limit.
|
||||
// When the standard pass shows many regions of tiny detection-scale
|
||||
// height, rerun detection at the escalated limit.
|
||||
let downscale = (DETECTION_LIMIT_STANDARD as f32 / longest_side).min(1.0);
|
||||
let mut heights: Vec<f32> = detections.iter().map(polygon_height).collect();
|
||||
let median = median_detection_height(&mut heights, downscale);
|
||||
log::trace!(
|
||||
"page {}: standard pass {} regions, median height {:.1}px at detection scale",
|
||||
page.page(),
|
||||
detections.len(),
|
||||
median
|
||||
);
|
||||
if should_escalate_detection(median, detections.len(), downscale) {
|
||||
log::debug!(
|
||||
"page {}: escalating detection ({} regions, median height {:.1}px at detection scale)",
|
||||
page.page(),
|
||||
detections.len(),
|
||||
median
|
||||
);
|
||||
if let Some(escalated) = self.escalated_detector(worker) {
|
||||
match detect_with(escalated, image, page.page()) {
|
||||
Ok(escalated_boxes) => return Ok(escalated_boxes),
|
||||
Err(error) => {
|
||||
log::warn!(
|
||||
"page {}: escalated detection failed, keeping standard pass: {error}",
|
||||
page.page()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(detections)
|
||||
}
|
||||
|
||||
fn recognize_page(
|
||||
&self,
|
||||
page: &RenderedPage,
|
||||
options: &OcrOptions,
|
||||
worker: usize,
|
||||
) -> Result<OcrPage, OarOcrError> {
|
||||
let started = Instant::now();
|
||||
let worker = &self.workers[worker % self.workers.len()];
|
||||
let image = Arc::new(rendered_page_to_rgb(page)?);
|
||||
let boxes = self.detect_boxes(page, &image, worker)?;
|
||||
// Reading order, matching what the combined pipeline produced.
|
||||
let boxes = oar_ocr::processors::sort_quad_boxes(&boxes);
|
||||
|
||||
// Same rotation-aware cropping the combined pipeline uses.
|
||||
let crops =
|
||||
TextCroppingProcessor::new(true).process((Arc::clone(&image), boxes.clone()))?;
|
||||
drop(image);
|
||||
|
||||
let recognizer = &worker.recognizer;
|
||||
let mut spans = Vec::with_capacity(boxes.len());
|
||||
let mut invalid_geometry = 0usize;
|
||||
let mut missing_recognition = 0usize;
|
||||
for (bounding_box, crop) in boxes.iter().zip(crops) {
|
||||
let Some(crop) = crop else {
|
||||
invalid_geometry += 1;
|
||||
continue;
|
||||
};
|
||||
// One crop per call: document line crops often have very
|
||||
// different widths, and batching pads every crop to the widest
|
||||
// line. Measured on CPU, batched recognition (even width-sorted)
|
||||
// is 2–3× slower than per-crop calls.
|
||||
let crop = Arc::try_unwrap(crop).unwrap_or_else(|shared| (*shared).clone());
|
||||
let recognized = recognizer.predict(vec![crop])?;
|
||||
let (Some(text), Some(confidence)) = (
|
||||
recognized.texts.into_iter().next(),
|
||||
recognized.scores.into_iter().next(),
|
||||
) else {
|
||||
missing_recognition += 1;
|
||||
continue;
|
||||
};
|
||||
if text.trim().is_empty() || !confidence.is_finite() {
|
||||
missing_recognition += 1;
|
||||
continue;
|
||||
}
|
||||
let confidence = confidence.clamp(0.0, 1.0);
|
||||
if confidence < options.minimum_confidence {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(polygon) = bounding_box_to_quad(bounding_box, page.width(), page.height())
|
||||
else {
|
||||
invalid_geometry += 1;
|
||||
continue;
|
||||
};
|
||||
spans.push(OcrSpan {
|
||||
text,
|
||||
polygon,
|
||||
confidence,
|
||||
// The combined pipeline's orientation_angle came from the
|
||||
// text-line-orientation classifier, a model this engine has
|
||||
// never loaded — it was structurally None before the staged
|
||||
// split too (the staged/combined A/B was byte-identical).
|
||||
// Region rotation is still carried by the polygon itself.
|
||||
orientation_degrees: None,
|
||||
});
|
||||
}
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
if missing_recognition > 0 {
|
||||
warnings.push(format!(
|
||||
"discarded {missing_recognition} regions without usable recognition output"
|
||||
));
|
||||
}
|
||||
if invalid_geometry > 0 {
|
||||
warnings.push(format!(
|
||||
"discarded {invalid_geometry} recognized regions with invalid geometry"
|
||||
));
|
||||
}
|
||||
|
||||
let mean_confidence = if spans.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(spans.iter().map(|span| span.confidence).sum::<f32>() / spans.len() as f32)
|
||||
};
|
||||
let processing_time_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX);
|
||||
|
||||
Ok(OcrPage {
|
||||
page_number: page.page(),
|
||||
spans,
|
||||
mean_confidence,
|
||||
model: self.model.clone(),
|
||||
processing_time_ms,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one detector over one page image and returns its region polygons.
|
||||
fn detect_with(
|
||||
detector: &TextDetectionPredictor,
|
||||
image: &Arc<RgbImage>,
|
||||
page_number: u32,
|
||||
) -> Result<Vec<BoundingBox>, OarOcrError> {
|
||||
let mut result = detector.predict(vec![(**image).clone()])?;
|
||||
if result.detections.is_empty() {
|
||||
return Err(OarOcrError::MissingPageResult { page: page_number });
|
||||
}
|
||||
Ok(result
|
||||
.detections
|
||||
.swap_remove(0)
|
||||
.into_iter()
|
||||
.map(|detection| detection.bbox)
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Vertical extent of a detection polygon in original-image pixels.
|
||||
fn polygon_height(polygon: &BoundingBox) -> f32 {
|
||||
let mut min_y = f32::MAX;
|
||||
let mut max_y = f32::MIN;
|
||||
for point in &polygon.points {
|
||||
min_y = min_y.min(point.y);
|
||||
max_y = max_y.max(point.y);
|
||||
}
|
||||
if max_y > min_y {
|
||||
max_y - min_y
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
fn ocr_session_config(intra_threads: usize) -> OrtSessionConfig {
|
||||
OrtSessionConfig::new()
|
||||
.with_intra_threads(intra_threads.max(1))
|
||||
.with_inter_threads(1)
|
||||
.with_parallel_execution(false)
|
||||
}
|
||||
|
||||
fn load_onnx_runtime() -> Result<(), OarOcrError> {
|
||||
let path = onnx_runtime_library_path();
|
||||
drop(
|
||||
ort::init_from(&path).map_err(|source| OarOcrError::OnnxRuntimeLoad {
|
||||
path: path.clone(),
|
||||
source,
|
||||
})?,
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn onnx_runtime_library_path() -> PathBuf {
|
||||
std::env::var_os(ONNX_RUNTIME_LIBRARY_ENV)
|
||||
.filter(|path| !path.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(default_onnx_runtime_library)
|
||||
}
|
||||
|
||||
fn default_onnx_runtime_library() -> PathBuf {
|
||||
#[cfg(target_os = "windows")]
|
||||
const NAME: &str = "onnxruntime.dll";
|
||||
#[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))]
|
||||
const NAME: &str = "libonnxruntime.so";
|
||||
#[cfg(any(target_os = "macos", target_os = "ios"))]
|
||||
const NAME: &str = "libonnxruntime.dylib";
|
||||
PathBuf::from(NAME)
|
||||
}
|
||||
|
||||
impl OcrEngine for OarOcrEngine {
|
||||
type Error = OarOcrError;
|
||||
|
||||
fn model(&self) -> &ModelIdentity {
|
||||
&self.model
|
||||
}
|
||||
|
||||
fn recognize(
|
||||
&self,
|
||||
pages: &[RenderedPage],
|
||||
options: &OcrOptions,
|
||||
) -> Result<Vec<OcrPage>, Self::Error> {
|
||||
validate_options(options)?;
|
||||
|
||||
let Some(pool) = self.pool.as_ref().filter(|_| pages.len() > 1) else {
|
||||
return pages
|
||||
.iter()
|
||||
.map(|page| self.recognize_page(page, options, 0))
|
||||
.collect();
|
||||
};
|
||||
pool.install(|| {
|
||||
use rayon::prelude::*;
|
||||
pages
|
||||
.par_iter()
|
||||
.map(|page| {
|
||||
let worker = rayon::current_thread_index().unwrap_or(0);
|
||||
self.recognize_page(page, options, worker)
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn preferred_page_concurrency(&self) -> usize {
|
||||
// Without a pool, recognition runs sequentially regardless of worker
|
||||
// count — report that honestly so the pipeline doesn't render
|
||||
// oversized page batches for parallelism that isn't there.
|
||||
if self.pool.is_some() {
|
||||
self.workers.len()
|
||||
} else {
|
||||
1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_options(options: &OcrOptions) -> Result<(), OarOcrError> {
|
||||
if options.mode == OcrMode::Off {
|
||||
return Err(OarOcrError::OcrDisabled);
|
||||
}
|
||||
if !options.minimum_confidence.is_finite() || !(0.0..=1.0).contains(&options.minimum_confidence)
|
||||
{
|
||||
return Err(OarOcrError::InvalidMinimumConfidence {
|
||||
value: options.minimum_confidence,
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn required_model(
|
||||
models: &ModelPaths,
|
||||
kind: ModelArtifactKind,
|
||||
) -> Result<&std::path::Path, OarOcrError> {
|
||||
models
|
||||
.get(kind)
|
||||
.ok_or(OarOcrError::MissingModelArtifact { kind })
|
||||
}
|
||||
|
||||
fn rendered_page_to_rgb(page: &RenderedPage) -> Result<RgbImage, OarOcrError> {
|
||||
let width = usize::try_from(page.width())
|
||||
.map_err(|_| OarOcrError::ImageSizeOverflow { page: page.page() })?;
|
||||
let height = usize::try_from(page.height())
|
||||
.map_err(|_| OarOcrError::ImageSizeOverflow { page: page.page() })?;
|
||||
let output_len = width
|
||||
.checked_mul(height)
|
||||
.and_then(|pixels| pixels.checked_mul(3))
|
||||
.ok_or(OarOcrError::ImageSizeOverflow { page: page.page() })?;
|
||||
let input_bpp = page.format().bytes_per_pixel();
|
||||
let active_input_row = width
|
||||
.checked_mul(input_bpp)
|
||||
.ok_or(OarOcrError::ImageSizeOverflow { page: page.page() })?;
|
||||
let output_row = width
|
||||
.checked_mul(3)
|
||||
.ok_or(OarOcrError::ImageSizeOverflow { page: page.page() })?;
|
||||
|
||||
let mut rgb = vec![0u8; output_len];
|
||||
for row in 0..height {
|
||||
let input_start = row * page.stride();
|
||||
let input = &page.pixels()[input_start..input_start + active_input_row];
|
||||
let output_start = row * output_row;
|
||||
let output = &mut rgb[output_start..output_start + output_row];
|
||||
match page.format() {
|
||||
RenderPixelFormat::Rgb8 => output.copy_from_slice(input),
|
||||
RenderPixelFormat::Rgba8 => {
|
||||
for (rgba, rgb) in input.chunks_exact(4).zip(output.chunks_exact_mut(3)) {
|
||||
rgb.copy_from_slice(&rgba[..3]);
|
||||
}
|
||||
}
|
||||
RenderPixelFormat::Gray8 => {
|
||||
for (&gray, rgb) in input.iter().zip(output.chunks_exact_mut(3)) {
|
||||
rgb.fill(gray);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RgbImage::from_raw(page.width(), page.height(), rgb)
|
||||
.ok_or(OarOcrError::InvalidImageBuffer { page: page.page() })
|
||||
}
|
||||
|
||||
fn bounding_box_to_quad(bounding_box: &BoundingBox, width: u32, height: u32) -> Option<ImageQuad> {
|
||||
let points: Vec<ImagePoint> = bounding_box
|
||||
.points
|
||||
.iter()
|
||||
.filter(|point| point.x.is_finite() && point.y.is_finite())
|
||||
.map(|point| {
|
||||
ImagePoint::new(
|
||||
point.x.clamp(0.0, width as f32),
|
||||
point.y.clamp(0.0, height as f32),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
if bounding_box.points.len() == 4 && points.len() == 4 && is_ordered_convex_quad(&points) {
|
||||
return Some(ImageQuad::new([points[0], points[1], points[2], points[3]]));
|
||||
}
|
||||
if points.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let min_x = points
|
||||
.iter()
|
||||
.map(|point| point.x)
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
let max_x = points
|
||||
.iter()
|
||||
.map(|point| point.x)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
let min_y = points
|
||||
.iter()
|
||||
.map(|point| point.y)
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
let max_y = points
|
||||
.iter()
|
||||
.map(|point| point.y)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
if max_x <= min_x || max_y <= min_y {
|
||||
return None;
|
||||
}
|
||||
Some(ImageQuad::new([
|
||||
ImagePoint::new(min_x, min_y),
|
||||
ImagePoint::new(max_x, min_y),
|
||||
ImagePoint::new(max_x, max_y),
|
||||
ImagePoint::new(min_x, max_y),
|
||||
]))
|
||||
}
|
||||
|
||||
fn is_ordered_convex_quad(points: &[ImagePoint]) -> bool {
|
||||
if points.len() != 4 {
|
||||
return false;
|
||||
}
|
||||
let mut orientation = 0.0_f32;
|
||||
for index in 0..4 {
|
||||
let first = points[index];
|
||||
let second = points[(index + 1) % 4];
|
||||
let third = points[(index + 2) % 4];
|
||||
let cross = (second.x - first.x) * (third.y - second.y)
|
||||
- (second.y - first.y) * (third.x - second.x);
|
||||
if cross.abs() <= f32::EPSILON {
|
||||
return false;
|
||||
}
|
||||
if orientation == 0.0 {
|
||||
orientation = cross.signum();
|
||||
} else if cross.signum() != orientation {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use oar_ocr::processors::Point;
|
||||
|
||||
use super::*;
|
||||
use crate::vision::PageTransform;
|
||||
|
||||
#[test]
|
||||
fn cpu_session_budget_is_bounded_for_small_ocr_models() {
|
||||
let concurrency = pipeline_concurrency();
|
||||
let config = ocr_session_config(intra_threads_per_pipeline(concurrency));
|
||||
assert!((1..=4).contains(&config.intra_threads.unwrap()));
|
||||
assert_eq!(config.inter_threads, Some(1));
|
||||
assert_eq!(config.parallel_execution, Some(false));
|
||||
// Zero requests are clamped so a session always has a thread.
|
||||
assert_eq!(ocr_session_config(0).intra_threads, Some(1));
|
||||
}
|
||||
|
||||
fn page(format: RenderPixelFormat, stride: usize, pixels: Vec<u8>) -> RenderedPage {
|
||||
let transform =
|
||||
PageTransform::from_corners(2, 2, (0.0, 2.0), (2.0, 2.0), (0.0, 0.0)).unwrap();
|
||||
RenderedPage::new(1, 2.0, 2.0, 2, 2, stride, format, pixels, transform).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_padded_rgb_without_exposing_padding() {
|
||||
let page = page(
|
||||
RenderPixelFormat::Rgb8,
|
||||
8,
|
||||
vec![1, 2, 3, 4, 5, 6, 99, 99, 7, 8, 9, 10, 11, 12, 99, 99],
|
||||
);
|
||||
let image = rendered_page_to_rgb(&page).unwrap();
|
||||
assert_eq!(image.as_raw(), &[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn converts_rgba_and_gray_to_rgb() {
|
||||
let rgba = page(
|
||||
RenderPixelFormat::Rgba8,
|
||||
8,
|
||||
vec![1, 2, 3, 44, 4, 5, 6, 55, 7, 8, 9, 66, 10, 11, 12, 77],
|
||||
);
|
||||
assert_eq!(
|
||||
rendered_page_to_rgb(&rgba).unwrap().as_raw(),
|
||||
&[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12]
|
||||
);
|
||||
|
||||
let gray = page(RenderPixelFormat::Gray8, 2, vec![1, 2, 3, 4]);
|
||||
assert_eq!(
|
||||
rendered_page_to_rgb(&gray).unwrap().as_raw(),
|
||||
&[1, 1, 1, 2, 2, 2, 3, 3, 3, 4, 4, 4]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_quads_and_clamps_them_to_the_bitmap() {
|
||||
let bbox = BoundingBox::new(vec![
|
||||
Point::new(-1.0, 2.0),
|
||||
Point::new(11.0, 2.0),
|
||||
Point::new(11.0, 9.0),
|
||||
Point::new(-1.0, 9.0),
|
||||
]);
|
||||
let quad = bounding_box_to_quad(&bbox, 10, 8).unwrap();
|
||||
assert_eq!(quad.points[0], ImagePoint::new(0.0, 2.0));
|
||||
assert_eq!(quad.points[2], ImagePoint::new(10.0, 8.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reduces_polygons_to_a_stable_axis_aligned_quad() {
|
||||
let bbox = BoundingBox::new(vec![
|
||||
Point::new(2.0, 1.0),
|
||||
Point::new(7.0, 2.0),
|
||||
Point::new(8.0, 6.0),
|
||||
Point::new(5.0, 9.0),
|
||||
Point::new(1.0, 5.0),
|
||||
]);
|
||||
let quad = bounding_box_to_quad(&bbox, 10, 10).unwrap();
|
||||
assert_eq!(quad.points[0], ImagePoint::new(1.0, 1.0));
|
||||
assert_eq!(quad.points[2], ImagePoint::new(8.0, 9.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_unordered_or_partially_invalid_quads() {
|
||||
let unordered = BoundingBox::new(vec![
|
||||
Point::new(1.0, 1.0),
|
||||
Point::new(8.0, 8.0),
|
||||
Point::new(8.0, 1.0),
|
||||
Point::new(1.0, 8.0),
|
||||
]);
|
||||
let quad = bounding_box_to_quad(&unordered, 10, 10).unwrap();
|
||||
assert_eq!(quad.points[0], ImagePoint::new(1.0, 1.0));
|
||||
assert_eq!(quad.points[1], ImagePoint::new(8.0, 1.0));
|
||||
assert_eq!(quad.points[2], ImagePoint::new(8.0, 8.0));
|
||||
|
||||
let partially_invalid = BoundingBox::new(vec![
|
||||
Point::new(8.0, 8.0),
|
||||
Point::new(f32::NAN, 4.0),
|
||||
Point::new(1.0, 8.0),
|
||||
Point::new(8.0, 1.0),
|
||||
Point::new(1.0, 1.0),
|
||||
]);
|
||||
let quad = bounding_box_to_quad(&partially_invalid, 10, 10).unwrap();
|
||||
assert_eq!(quad.points[0], ImagePoint::new(1.0, 1.0));
|
||||
assert_eq!(quad.points[1], ImagePoint::new(8.0, 1.0));
|
||||
assert_eq!(quad.points[2], ImagePoint::new(8.0, 8.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refuses_disabled_or_invalid_options_before_inference() {
|
||||
assert!(matches!(
|
||||
validate_options(&OcrOptions::new()),
|
||||
Err(OarOcrError::OcrDisabled)
|
||||
));
|
||||
for value in [-0.1, 1.1, f32::NAN, f32::INFINITY] {
|
||||
let options = OcrOptions::new()
|
||||
.mode(OcrMode::Force)
|
||||
.minimum_confidence(value);
|
||||
assert!(matches!(
|
||||
validate_options(&options),
|
||||
Err(OarOcrError::InvalidMinimumConfidence { .. })
|
||||
));
|
||||
}
|
||||
assert!(validate_options(
|
||||
&OcrOptions::new()
|
||||
.mode(OcrMode::Auto)
|
||||
.minimum_confidence(1.0)
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escalation_fires_for_dense_fine_print_pages() {
|
||||
// Measured cases (at unclip 2.0) that gain from escalation: dense
|
||||
// tiled ad pages (12.3–14.2px, 158–286 regions) and a dense pricing
|
||||
// sheet (12.0px, 144 regions), all downscaled by the standard limit.
|
||||
assert!(should_escalate_detection(14.2, 186, 0.55));
|
||||
assert!(should_escalate_detection(13.1, 286, 0.55));
|
||||
assert!(should_escalate_detection(12.3, 158, 0.55));
|
||||
assert!(should_escalate_detection(12.0, 144, 0.55));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escalation_skips_ordinary_pages() {
|
||||
// Academic prose: too few regions (and tall enough at unclip 2.0).
|
||||
assert!(!should_escalate_detection(14.5, 47, 0.58));
|
||||
// Engineering drawing: many regions but tall enough text.
|
||||
assert!(!should_escalate_detection(15.7, 205, 0.58));
|
||||
// Typewriter scan: tall text, few regions.
|
||||
assert!(!should_escalate_detection(17.5, 77, 0.55));
|
||||
// Page not downscaled at all: escalation cannot add pixels.
|
||||
assert!(!should_escalate_detection(9.0, 300, 1.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn median_detection_height_scales_and_handles_empty() {
|
||||
let mut heights = vec![30.0, 10.0, 20.0];
|
||||
assert_eq!(median_detection_height(&mut heights, 0.5), 10.0);
|
||||
// Even counts average the two middle values instead of picking the
|
||||
// upper one, so borderline pages don't skew away from escalation.
|
||||
let mut even = vec![10.0, 12.0, 14.0, 30.0];
|
||||
assert_eq!(median_detection_height(&mut even, 1.0), 13.0);
|
||||
let mut empty: Vec<f32> = Vec::new();
|
||||
assert_eq!(median_detection_height(&mut empty, 0.5), f32::MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn concurrency_derivations_stay_in_bounds() {
|
||||
let concurrency = pipeline_concurrency();
|
||||
assert!((1..=3).contains(&concurrency));
|
||||
assert!(intra_threads_per_pipeline(2) == 2);
|
||||
assert!((1..=4).contains(&intra_threads_per_pipeline(1)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
//! PDFium-backed implementation of the renderer-neutral page contract.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use firecrawl_pdfium::{PageChar, Pdfium, PixelFormat, PixelPoint, RenderConfig};
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::types::{ItemType, TextItem};
|
||||
|
||||
use super::{
|
||||
PageRenderer, PageTransform, RenderBufferError, RenderOptions, RenderPixelFormat, RenderedPage,
|
||||
};
|
||||
|
||||
impl RenderPixelFormat {
|
||||
fn pdfium_format(self) -> PixelFormat {
|
||||
match self {
|
||||
// PDFium produces BGR directly; `rendered_page_from_pdfium`
|
||||
// swaps the red and blue channels in place.
|
||||
Self::Rgb8 => PixelFormat::Bgr8,
|
||||
Self::Rgba8 => PixelFormat::Rgba8,
|
||||
Self::Gray8 => PixelFormat::Gray8,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOptions {
|
||||
fn pdfium_config(&self) -> RenderConfig {
|
||||
RenderConfig::new()
|
||||
.dpi(self.dpi)
|
||||
.pixel_format(self.pixel_format.pdfium_format())
|
||||
.annotations(self.annotations)
|
||||
.form_fields(self.form_fields)
|
||||
.max_output_bytes(self.max_output_bytes_per_page)
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors produced by the optional local renderer.
|
||||
#[derive(Debug, Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum RenderError {
|
||||
/// Page numbers in pdf-inspector APIs are 1-indexed, so zero is invalid.
|
||||
#[error("page numbers are 1-indexed; page 0 is invalid")]
|
||||
InvalidPageNumber,
|
||||
/// The requested 1-indexed page is not present in the document.
|
||||
#[error("page {page} is out of bounds for a {page_count}-page document")]
|
||||
PageOutOfBounds {
|
||||
/// Requested 1-indexed page.
|
||||
page: u32,
|
||||
/// Number of pages in the document.
|
||||
page_count: usize,
|
||||
},
|
||||
/// The PDFium shared library could not be discovered or loaded.
|
||||
#[error(
|
||||
"failed to load PDFium; install a compatible PDFium shared library or set PDFIUM_LIB_PATH to its path"
|
||||
)]
|
||||
PdfiumLoad {
|
||||
/// Dynamic loading failure.
|
||||
#[source]
|
||||
source: firecrawl_pdfium::Error,
|
||||
},
|
||||
/// PDFium loading, document parsing, form setup, or rendering failed.
|
||||
#[error(transparent)]
|
||||
Pdfium(#[from] firecrawl_pdfium::Error),
|
||||
/// PDFium returned an internally inconsistent bitmap or transform.
|
||||
#[error(transparent)]
|
||||
Buffer(#[from] RenderBufferError),
|
||||
}
|
||||
|
||||
/// Loaded PDFium renderer used to prepare pages for OCR.
|
||||
///
|
||||
/// PDFium calls are safe from concurrent threads but serialize inside the
|
||||
/// underlying binding. Returned [`RenderedPage`] values are ordinary owned
|
||||
/// data and can be processed concurrently after rendering.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct PdfiumRenderer {
|
||||
pdfium: Pdfium,
|
||||
}
|
||||
|
||||
/// Positioned native text recovered from one selected PDF page.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct PdfiumTextPage {
|
||||
pub(crate) page: u32,
|
||||
pub(crate) page_width: f32,
|
||||
pub(crate) page_height: f32,
|
||||
pub(crate) items: Vec<TextItem>,
|
||||
}
|
||||
|
||||
impl PdfiumRenderer {
|
||||
/// Loads PDFium using `firecrawl-pdfium`'s documented discovery chain.
|
||||
pub fn load() -> Result<Self, RenderError> {
|
||||
Ok(Self {
|
||||
pdfium: Pdfium::load().map_err(|source| RenderError::PdfiumLoad { source })?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Loads PDFium from an explicit native library path.
|
||||
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, RenderError> {
|
||||
Ok(Self {
|
||||
pdfium: Pdfium::load_from_path(path)
|
||||
.map_err(|source| RenderError::PdfiumLoad { source })?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Path of the active PDFium library, if it was loaded from a concrete
|
||||
/// file rather than through the system loader.
|
||||
pub fn loaded_from(&self) -> Option<&Path> {
|
||||
self.pdfium.loaded_from()
|
||||
}
|
||||
|
||||
/// Renders selected 1-indexed pages in the same order as `pages`.
|
||||
///
|
||||
/// This inherent method mirrors [`PageRenderer`] so existing callers do
|
||||
/// not need to import the trait.
|
||||
pub fn render_pages(
|
||||
&self,
|
||||
pdf_bytes: &[u8],
|
||||
pages: &[u32],
|
||||
password: Option<&str>,
|
||||
options: &RenderOptions,
|
||||
) -> Result<Vec<RenderedPage>, RenderError> {
|
||||
self.render_pages_impl(pdf_bytes, pages, password, options)
|
||||
}
|
||||
|
||||
/// Extracts positioned native text from selected 1-indexed pages.
|
||||
///
|
||||
/// This is deliberately separate from rendering: callers can probe a
|
||||
/// suspicious embedded text layer before paying for rasterization and
|
||||
/// OCR. A page-level text failure is treated as an unavailable recovery
|
||||
/// candidate so the caller can continue to its normal OCR fallback.
|
||||
pub(crate) fn extract_text_pages(
|
||||
&self,
|
||||
pdf_bytes: &[u8],
|
||||
pages: &[u32],
|
||||
password: Option<&str>,
|
||||
) -> Result<Vec<PdfiumTextPage>, RenderError> {
|
||||
const MAX_TEXT_CHARS_PER_PAGE: usize = 250_000;
|
||||
|
||||
if pages.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
if pages.contains(&0) {
|
||||
return Err(RenderError::InvalidPageNumber);
|
||||
}
|
||||
|
||||
let document = self.pdfium.load_document(pdf_bytes.to_vec(), password)?;
|
||||
let page_count = document.page_count();
|
||||
if let Some(&page) = pages.iter().find(|&&page| page as usize > page_count) {
|
||||
return Err(RenderError::PageOutOfBounds { page, page_count });
|
||||
}
|
||||
|
||||
let mut recovered = Vec::with_capacity(pages.len());
|
||||
for &page_number in pages {
|
||||
let page = document.page(page_number as usize - 1)?;
|
||||
let page_size = page.size();
|
||||
let text = match page.text_with_limit(MAX_TEXT_CHARS_PER_PAGE) {
|
||||
Ok(text) => text,
|
||||
Err(error) => {
|
||||
log::debug!(
|
||||
"page {page_number}: positioned native text recovery unavailable: {error}"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
recovered.push(PdfiumTextPage {
|
||||
page: page_number,
|
||||
page_width: page_size.width,
|
||||
page_height: page_size.height,
|
||||
items: text_chars_to_items(text.chars(), page_number),
|
||||
});
|
||||
}
|
||||
Ok(recovered)
|
||||
}
|
||||
|
||||
fn render_pages_impl(
|
||||
&self,
|
||||
pdf_bytes: &[u8],
|
||||
pages: &[u32],
|
||||
password: Option<&str>,
|
||||
options: &RenderOptions,
|
||||
) -> Result<Vec<RenderedPage>, RenderError> {
|
||||
if pages.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
if pages.contains(&0) {
|
||||
return Err(RenderError::InvalidPageNumber);
|
||||
}
|
||||
|
||||
let document = self.pdfium.load_document(pdf_bytes.to_vec(), password)?;
|
||||
let page_count = document.page_count();
|
||||
|
||||
if let Some(&page) = pages.iter().find(|&&page| page as usize > page_count) {
|
||||
return Err(RenderError::PageOutOfBounds { page, page_count });
|
||||
}
|
||||
|
||||
if options.form_fields {
|
||||
document.enable_form_rendering()?;
|
||||
}
|
||||
|
||||
let config = options.pdfium_config();
|
||||
let mut rendered_pages = Vec::with_capacity(pages.len());
|
||||
for &page_number in pages {
|
||||
let page = document.page(page_number as usize - 1)?;
|
||||
let size = page.size();
|
||||
let rendered = page.render(&config)?;
|
||||
rendered_pages.push(rendered_page_from_pdfium(
|
||||
page_number,
|
||||
size.width,
|
||||
size.height,
|
||||
options.pixel_format,
|
||||
rendered,
|
||||
)?);
|
||||
}
|
||||
|
||||
Ok(rendered_pages)
|
||||
}
|
||||
}
|
||||
|
||||
fn text_chars_to_items(chars: &[PageChar], page: u32) -> Vec<TextItem> {
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct Bounds {
|
||||
left: f64,
|
||||
bottom: f64,
|
||||
right: f64,
|
||||
top: f64,
|
||||
}
|
||||
|
||||
fn flush(items: &mut Vec<TextItem>, text: &mut String, bounds: &mut Option<Bounds>, page: u32) {
|
||||
let Some(bounds) = bounds.take() else {
|
||||
text.clear();
|
||||
return;
|
||||
};
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let width = (bounds.right - bounds.left) as f32;
|
||||
let height = (bounds.top - bounds.bottom) as f32;
|
||||
let x = bounds.left as f32;
|
||||
let y = bounds.bottom as f32;
|
||||
if !x.is_finite()
|
||||
|| !y.is_finite()
|
||||
|| !width.is_finite()
|
||||
|| !height.is_finite()
|
||||
|| width <= 0.0
|
||||
|| height <= 0.0
|
||||
{
|
||||
text.clear();
|
||||
return;
|
||||
}
|
||||
items.push(TextItem {
|
||||
text: std::mem::take(text),
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
font: "PDFium native text".to_string(),
|
||||
font_size: height.max(1.0),
|
||||
page,
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
is_underline: false,
|
||||
is_strikeout: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
});
|
||||
}
|
||||
|
||||
let mut items = Vec::new();
|
||||
let mut text = String::new();
|
||||
let mut bounds: Option<Bounds> = None;
|
||||
for character in chars {
|
||||
let Some(value) = character.unicode else {
|
||||
flush(&mut items, &mut text, &mut bounds, page);
|
||||
continue;
|
||||
};
|
||||
if value.is_whitespace() {
|
||||
flush(&mut items, &mut text, &mut bounds, page);
|
||||
continue;
|
||||
}
|
||||
|
||||
let rect = character.loose_bounds.normalized();
|
||||
if !rect.left.is_finite()
|
||||
|| !rect.bottom.is_finite()
|
||||
|| !rect.right.is_finite()
|
||||
|| !rect.top.is_finite()
|
||||
|| rect.width() <= 0.0
|
||||
|| rect.height() <= 0.0
|
||||
{
|
||||
flush(&mut items, &mut text, &mut bounds, page);
|
||||
continue;
|
||||
}
|
||||
text.push(value);
|
||||
bounds = Some(match bounds {
|
||||
Some(bounds) => Bounds {
|
||||
left: bounds.left.min(rect.left),
|
||||
bottom: bounds.bottom.min(rect.bottom),
|
||||
right: bounds.right.max(rect.right),
|
||||
top: bounds.top.max(rect.top),
|
||||
},
|
||||
None => Bounds {
|
||||
left: rect.left,
|
||||
bottom: rect.bottom,
|
||||
right: rect.right,
|
||||
top: rect.top,
|
||||
},
|
||||
});
|
||||
}
|
||||
flush(&mut items, &mut text, &mut bounds, page);
|
||||
items.sort_by(|first, second| {
|
||||
first
|
||||
.page
|
||||
.cmp(&second.page)
|
||||
.then(second.y.total_cmp(&first.y))
|
||||
.then(first.x.total_cmp(&second.x))
|
||||
});
|
||||
items
|
||||
}
|
||||
|
||||
impl PageRenderer for PdfiumRenderer {
|
||||
type Error = RenderError;
|
||||
|
||||
fn render_pages(
|
||||
&self,
|
||||
pdf_bytes: &[u8],
|
||||
pages: &[u32],
|
||||
password: Option<&str>,
|
||||
options: &RenderOptions,
|
||||
) -> Result<Vec<RenderedPage>, Self::Error> {
|
||||
self.render_pages_impl(pdf_bytes, pages, password, options)
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered_page_from_pdfium(
|
||||
page: u32,
|
||||
page_width: f32,
|
||||
page_height: f32,
|
||||
format: RenderPixelFormat,
|
||||
rendered: firecrawl_pdfium::RenderedPage,
|
||||
) -> Result<RenderedPage, RenderBufferError> {
|
||||
let width = rendered.width();
|
||||
let height = rendered.height();
|
||||
let stride = rendered.stride();
|
||||
let pdfium_transform = *rendered.transform();
|
||||
let corner = |x, y| {
|
||||
let point = pdfium_transform.pixel_to_page(PixelPoint::new(x, y));
|
||||
(point.x, point.y)
|
||||
};
|
||||
let transform = PageTransform::from_corners(
|
||||
width,
|
||||
height,
|
||||
corner(0.0, 0.0),
|
||||
corner(f64::from(width), 0.0),
|
||||
corner(0.0, f64::from(height)),
|
||||
)
|
||||
.ok_or(RenderBufferError::InvalidTransform)?;
|
||||
let mut pixels = rendered.into_pixels();
|
||||
|
||||
if format == RenderPixelFormat::Rgb8 {
|
||||
bgr_to_rgb_in_place(&mut pixels, width, height, stride)?;
|
||||
}
|
||||
|
||||
RenderedPage::new(
|
||||
page,
|
||||
page_width,
|
||||
page_height,
|
||||
width,
|
||||
height,
|
||||
stride,
|
||||
format,
|
||||
pixels,
|
||||
transform,
|
||||
)
|
||||
}
|
||||
|
||||
fn bgr_to_rgb_in_place(
|
||||
pixels: &mut [u8],
|
||||
width: u32,
|
||||
height: u32,
|
||||
stride: usize,
|
||||
) -> Result<(), RenderBufferError> {
|
||||
let row_bytes = (width as usize)
|
||||
.checked_mul(RenderPixelFormat::Rgb8.bytes_per_pixel())
|
||||
.ok_or(RenderBufferError::SizeOverflow)?;
|
||||
if stride < row_bytes {
|
||||
return Err(RenderBufferError::InvalidStride {
|
||||
stride,
|
||||
minimum: row_bytes,
|
||||
});
|
||||
}
|
||||
let expected = stride
|
||||
.checked_mul(height as usize)
|
||||
.ok_or(RenderBufferError::SizeOverflow)?;
|
||||
if pixels.len() != expected {
|
||||
return Err(RenderBufferError::InvalidBufferLength {
|
||||
actual: pixels.len(),
|
||||
expected,
|
||||
});
|
||||
}
|
||||
|
||||
for row in pixels.chunks_exact_mut(stride) {
|
||||
for pixel in row[..row_bytes].chunks_exact_mut(3) {
|
||||
pixel.swap(0, 2);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use firecrawl_pdfium::{PagePoint, PageRect};
|
||||
|
||||
fn page_char(value: char, bounds: PageRect) -> PageChar {
|
||||
PageChar {
|
||||
unicode: Some(value),
|
||||
code: value as u32,
|
||||
bounds,
|
||||
loose_bounds: bounds,
|
||||
origin: PagePoint::new(bounds.left, bounds.bottom),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bgr_pixels_are_converted_to_rgb_in_place() {
|
||||
let mut pixels = vec![1, 2, 3, 4, 5, 6];
|
||||
bgr_to_rgb_in_place(&mut pixels, 2, 1, 6).unwrap();
|
||||
assert_eq!(pixels, [3, 2, 1, 6, 5, 4]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bgr_conversion_skips_row_padding() {
|
||||
let mut pixels = vec![1, 2, 3, 9, 7, 8, 9, 6];
|
||||
bgr_to_rgb_in_place(&mut pixels, 1, 2, 4).unwrap();
|
||||
assert_eq!(pixels, [3, 2, 1, 9, 9, 8, 7, 6]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_bgr_buffers_return_errors() {
|
||||
assert!(matches!(
|
||||
bgr_to_rgb_in_place(&mut [0; 6], 2, 1, 5),
|
||||
Err(RenderBufferError::InvalidStride { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
bgr_to_rgb_in_place(&mut [0; 5], 1, 2, 3),
|
||||
Err(RenderBufferError::InvalidBufferLength { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_character_geometry_splits_text_runs() {
|
||||
let chars = [
|
||||
page_char('A', PageRect::new(0.0, 0.0, 8.0, 10.0)),
|
||||
page_char('X', PageRect::new(10.0, 0.0, 10.0, 10.0)),
|
||||
page_char('B', PageRect::new(20.0, 0.0, 28.0, 10.0)),
|
||||
];
|
||||
|
||||
let items = text_chars_to_items(&chars, 1);
|
||||
|
||||
assert_eq!(
|
||||
items
|
||||
.iter()
|
||||
.map(|item| item.text.as_str())
|
||||
.collect::<Vec<_>>(),
|
||||
["A", "B"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coordinates_that_overflow_f32_are_discarded() {
|
||||
let left = f64::from(f32::MAX) * 2.0;
|
||||
let chars = [page_char(
|
||||
'A',
|
||||
PageRect::new(left, 0.0, left + 1.0e30, 10.0),
|
||||
)];
|
||||
|
||||
assert!(text_chars_to_items(&chars, 1).is_empty());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,552 @@
|
||||
//! Renderer-neutral page bitmap and coordinate types.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::PdfRect;
|
||||
|
||||
/// Default rendering resolution for OCR.
|
||||
pub const DEFAULT_RENDER_DPI: f32 = 150.0;
|
||||
|
||||
/// Default maximum size of one rendered page: 256 MiB.
|
||||
pub const DEFAULT_MAX_OUTPUT_BYTES: u64 = 256 * 1024 * 1024;
|
||||
|
||||
/// Pixel layout returned by [`RenderedPage`].
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum RenderPixelFormat {
|
||||
/// Three bytes per pixel in red, green, blue order. This is the default
|
||||
/// because OCR preprocessors conventionally consume RGB images.
|
||||
#[default]
|
||||
Rgb8,
|
||||
/// Four bytes per pixel in red, green, blue, alpha order.
|
||||
Rgba8,
|
||||
/// One luminance byte per pixel.
|
||||
Gray8,
|
||||
}
|
||||
|
||||
impl RenderPixelFormat {
|
||||
/// Number of bytes used by one pixel.
|
||||
pub fn bytes_per_pixel(self) -> usize {
|
||||
match self {
|
||||
Self::Rgb8 => 3,
|
||||
Self::Rgba8 => 4,
|
||||
Self::Gray8 => 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for pages rendered as input to a local vision pipeline.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct RenderOptions {
|
||||
/// Output resolution. Defaults to 150 DPI.
|
||||
pub dpi: f32,
|
||||
/// Pixel layout. Defaults to three-channel RGB.
|
||||
pub pixel_format: RenderPixelFormat,
|
||||
/// Include PDF annotations in the rendered bitmap.
|
||||
pub annotations: bool,
|
||||
/// Include visible static AcroForm field appearances.
|
||||
pub form_fields: bool,
|
||||
/// Maximum allocation for each rendered page.
|
||||
pub max_output_bytes_per_page: u64,
|
||||
}
|
||||
|
||||
impl Default for RenderOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
dpi: DEFAULT_RENDER_DPI,
|
||||
pixel_format: RenderPixelFormat::Rgb8,
|
||||
annotations: true,
|
||||
form_fields: true,
|
||||
max_output_bytes_per_page: DEFAULT_MAX_OUTPUT_BYTES,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOptions {
|
||||
/// Creates local-rendering options with OCR-oriented defaults.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Sets the output resolution in dots per inch.
|
||||
pub fn dpi(mut self, dpi: f32) -> Self {
|
||||
self.dpi = dpi;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the output pixel layout.
|
||||
pub fn pixel_format(mut self, pixel_format: RenderPixelFormat) -> Self {
|
||||
self.pixel_format = pixel_format;
|
||||
self
|
||||
}
|
||||
|
||||
/// Toggles annotation rendering.
|
||||
pub fn annotations(mut self, annotations: bool) -> Self {
|
||||
self.annotations = annotations;
|
||||
self
|
||||
}
|
||||
|
||||
/// Toggles visible static form-field rendering.
|
||||
pub fn form_fields(mut self, form_fields: bool) -> Self {
|
||||
self.form_fields = form_fields;
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the maximum allocation for each rendered page.
|
||||
pub fn max_output_bytes_per_page(mut self, bytes: u64) -> Self {
|
||||
self.max_output_bytes_per_page = bytes;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A point in PDF page space, measured in points from the bottom-left.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PagePoint {
|
||||
/// Horizontal position in PDF points.
|
||||
pub x: f32,
|
||||
/// Vertical position in PDF points, increasing upward.
|
||||
pub y: f32,
|
||||
}
|
||||
|
||||
/// Affine transform between top-left pixel space and PDF page space.
|
||||
///
|
||||
/// Renderers create this from the page-space images of the bitmap corners.
|
||||
/// Keeping the coefficients in pdf-inspector makes [`RenderedPage`] neutral
|
||||
/// to the renderer implementation that produced it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PageTransform {
|
||||
forward: [f64; 6],
|
||||
inverse: [f64; 6],
|
||||
pixel_width: u32,
|
||||
pixel_height: u32,
|
||||
}
|
||||
|
||||
impl PageTransform {
|
||||
/// Builds a transform from the PDF-space images of device corners
|
||||
/// `(0, 0)`, `(pixel_width, 0)`, and `(0, pixel_height)`.
|
||||
pub fn from_corners(
|
||||
pixel_width: u32,
|
||||
pixel_height: u32,
|
||||
origin: (f64, f64),
|
||||
x_axis: (f64, f64),
|
||||
y_axis: (f64, f64),
|
||||
) -> Option<Self> {
|
||||
if pixel_width == 0 || pixel_height == 0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let values = [origin.0, origin.1, x_axis.0, x_axis.1, y_axis.0, y_axis.1];
|
||||
if values.iter().any(|value| !value.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let width = f64::from(pixel_width);
|
||||
let height = f64::from(pixel_height);
|
||||
let a = (x_axis.0 - origin.0) / width;
|
||||
let c = (x_axis.1 - origin.1) / width;
|
||||
let b = (y_axis.0 - origin.0) / height;
|
||||
let d = (y_axis.1 - origin.1) / height;
|
||||
let (e, f) = origin;
|
||||
let forward = [a, b, c, d, e, f];
|
||||
if forward.iter().any(|coefficient| !coefficient.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
let determinant = a * d - b * c;
|
||||
if determinant == 0.0 || !determinant.is_finite() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let inverse_a = d / determinant;
|
||||
let inverse_b = -b / determinant;
|
||||
let inverse_c = -c / determinant;
|
||||
let inverse_d = a / determinant;
|
||||
let inverse_e = -(inverse_a * e + inverse_b * f);
|
||||
let inverse_f = -(inverse_c * e + inverse_d * f);
|
||||
let inverse = [
|
||||
inverse_a, inverse_b, inverse_c, inverse_d, inverse_e, inverse_f,
|
||||
];
|
||||
if inverse.iter().any(|coefficient| !coefficient.is_finite()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self {
|
||||
forward,
|
||||
inverse,
|
||||
pixel_width,
|
||||
pixel_height,
|
||||
})
|
||||
}
|
||||
|
||||
/// Width of the bitmap this transform describes.
|
||||
pub fn pixel_width(&self) -> u32 {
|
||||
self.pixel_width
|
||||
}
|
||||
|
||||
/// Height of the bitmap this transform describes.
|
||||
pub fn pixel_height(&self) -> u32 {
|
||||
self.pixel_height
|
||||
}
|
||||
|
||||
/// Converts a bitmap point to PDF page space.
|
||||
pub fn pixel_to_page(&self, x: f64, y: f64) -> PagePoint {
|
||||
let [a, b, c, d, e, f] = self.forward;
|
||||
PagePoint {
|
||||
x: (a * x + b * y + e) as f32,
|
||||
y: (c * x + d * y + f) as f32,
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a PDF page-space point to bitmap coordinates.
|
||||
pub fn page_to_pixel(&self, x: f64, y: f64) -> (f64, f64) {
|
||||
let [a, b, c, d, e, f] = self.inverse;
|
||||
(a * x + b * y + e, c * x + d * y + f)
|
||||
}
|
||||
}
|
||||
|
||||
/// Invalid renderer output rejected by [`RenderedPage::new`].
|
||||
#[derive(Debug, Error, Clone, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum RenderBufferError {
|
||||
/// Page numbers are 1-indexed.
|
||||
#[error("rendered page number must be at least 1")]
|
||||
InvalidPageNumber,
|
||||
/// Bitmap dimensions must be non-zero.
|
||||
#[error("rendered bitmap dimensions must be non-zero")]
|
||||
InvalidDimensions,
|
||||
/// Page dimensions must be positive finite numbers.
|
||||
#[error("rendered PDF page dimensions must be positive and finite")]
|
||||
InvalidPageDimensions,
|
||||
/// Transform dimensions must match the bitmap dimensions.
|
||||
#[error("coordinate transform dimensions do not match the rendered bitmap")]
|
||||
TransformDimensions,
|
||||
/// Renderer did not provide an invertible finite coordinate transform.
|
||||
#[error("renderer returned an invalid coordinate transform")]
|
||||
InvalidTransform,
|
||||
/// The stride cannot hold one active row of pixels.
|
||||
#[error("pixel stride {stride} is shorter than the active row size {minimum}")]
|
||||
InvalidStride {
|
||||
/// Supplied bytes per row.
|
||||
stride: usize,
|
||||
/// Minimum bytes required for one row.
|
||||
minimum: usize,
|
||||
},
|
||||
/// Pixel buffer size is inconsistent with height and stride.
|
||||
#[error("pixel buffer has {actual} bytes; expected {expected}")]
|
||||
InvalidBufferLength {
|
||||
/// Actual byte count.
|
||||
actual: usize,
|
||||
/// Required byte count.
|
||||
expected: usize,
|
||||
},
|
||||
/// Dimension arithmetic overflowed the host address space.
|
||||
#[error("rendered bitmap dimensions overflow the host address space")]
|
||||
SizeOverflow,
|
||||
}
|
||||
|
||||
/// One rendered page with owned pixels and its pixel-to-PDF transform.
|
||||
///
|
||||
/// The value contains no live renderer, page, or document handles. It can be
|
||||
/// moved to an OCR worker and retained after rendering returns.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RenderedPage {
|
||||
page: u32,
|
||||
page_width: f32,
|
||||
page_height: f32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
stride: usize,
|
||||
format: RenderPixelFormat,
|
||||
pixels: Vec<u8>,
|
||||
transform: PageTransform,
|
||||
}
|
||||
|
||||
impl RenderedPage {
|
||||
/// Creates a renderer-neutral owned page after validating its buffer.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
page: u32,
|
||||
page_width: f32,
|
||||
page_height: f32,
|
||||
width: u32,
|
||||
height: u32,
|
||||
stride: usize,
|
||||
format: RenderPixelFormat,
|
||||
pixels: Vec<u8>,
|
||||
transform: PageTransform,
|
||||
) -> Result<Self, RenderBufferError> {
|
||||
if page == 0 {
|
||||
return Err(RenderBufferError::InvalidPageNumber);
|
||||
}
|
||||
if width == 0 || height == 0 {
|
||||
return Err(RenderBufferError::InvalidDimensions);
|
||||
}
|
||||
if page_width <= 0.0
|
||||
|| page_height <= 0.0
|
||||
|| !page_width.is_finite()
|
||||
|| !page_height.is_finite()
|
||||
{
|
||||
return Err(RenderBufferError::InvalidPageDimensions);
|
||||
}
|
||||
if transform.pixel_width() != width || transform.pixel_height() != height {
|
||||
return Err(RenderBufferError::TransformDimensions);
|
||||
}
|
||||
|
||||
let row_bytes = (width as usize)
|
||||
.checked_mul(format.bytes_per_pixel())
|
||||
.ok_or(RenderBufferError::SizeOverflow)?;
|
||||
if stride < row_bytes {
|
||||
return Err(RenderBufferError::InvalidStride {
|
||||
stride,
|
||||
minimum: row_bytes,
|
||||
});
|
||||
}
|
||||
let expected = stride
|
||||
.checked_mul(height as usize)
|
||||
.ok_or(RenderBufferError::SizeOverflow)?;
|
||||
if pixels.len() != expected {
|
||||
return Err(RenderBufferError::InvalidBufferLength {
|
||||
actual: pixels.len(),
|
||||
expected,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
page,
|
||||
page_width,
|
||||
page_height,
|
||||
width,
|
||||
height,
|
||||
stride,
|
||||
format,
|
||||
pixels,
|
||||
transform,
|
||||
})
|
||||
}
|
||||
|
||||
/// 1-indexed page number.
|
||||
pub fn page(&self) -> u32 {
|
||||
self.page
|
||||
}
|
||||
|
||||
/// Page width in PDF points after applying the page's rotation.
|
||||
pub fn page_width(&self) -> f32 {
|
||||
self.page_width
|
||||
}
|
||||
|
||||
/// Page height in PDF points after applying the page's rotation.
|
||||
pub fn page_height(&self) -> f32 {
|
||||
self.page_height
|
||||
}
|
||||
|
||||
/// Bitmap width in pixels.
|
||||
pub fn width(&self) -> u32 {
|
||||
self.width
|
||||
}
|
||||
|
||||
/// Bitmap height in pixels.
|
||||
pub fn height(&self) -> u32 {
|
||||
self.height
|
||||
}
|
||||
|
||||
/// Number of bytes between adjacent bitmap rows.
|
||||
pub fn stride(&self) -> usize {
|
||||
self.stride
|
||||
}
|
||||
|
||||
/// Pixel layout of [`pixels`](Self::pixels).
|
||||
pub fn format(&self) -> RenderPixelFormat {
|
||||
self.format
|
||||
}
|
||||
|
||||
/// Owned bitmap bytes, with rows ordered top-to-bottom.
|
||||
pub fn pixels(&self) -> &[u8] {
|
||||
&self.pixels
|
||||
}
|
||||
|
||||
/// Consumes the page and returns its pixel buffer.
|
||||
pub fn into_pixels(self) -> Vec<u8> {
|
||||
self.pixels
|
||||
}
|
||||
|
||||
/// Coordinate transform associated with the rendered page.
|
||||
pub fn transform(&self) -> PageTransform {
|
||||
self.transform
|
||||
}
|
||||
|
||||
/// Converts a bitmap point (top-left origin, y-down) to PDF page space
|
||||
/// (bottom-left origin, y-up).
|
||||
pub fn pixel_to_page(&self, x: f64, y: f64) -> PagePoint {
|
||||
self.transform.pixel_to_page(x, y)
|
||||
}
|
||||
|
||||
/// Converts a bitmap rectangle to the repository's existing PDF-space
|
||||
/// rectangle type. The returned page number remains 1-indexed.
|
||||
pub fn pixel_rect_to_pdf_rect(&self, x: f64, y: f64, width: f64, height: f64) -> PdfRect {
|
||||
let points = [
|
||||
self.transform.pixel_to_page(x, y),
|
||||
self.transform.pixel_to_page(x + width, y),
|
||||
self.transform.pixel_to_page(x, y + height),
|
||||
self.transform.pixel_to_page(x + width, y + height),
|
||||
];
|
||||
let left = points
|
||||
.iter()
|
||||
.map(|point| point.x)
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
let right = points
|
||||
.iter()
|
||||
.map(|point| point.x)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
let bottom = points
|
||||
.iter()
|
||||
.map(|point| point.y)
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
let top = points
|
||||
.iter()
|
||||
.map(|point| point.y)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
PdfRect {
|
||||
x: left,
|
||||
y: bottom,
|
||||
width: right - left,
|
||||
height: top - bottom,
|
||||
page: self.page,
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a PDF-space rectangle to bitmap coordinates
|
||||
/// `(x, y, width, height)` with a top-left origin.
|
||||
pub fn pdf_rect_to_pixel(&self, rect: &PdfRect) -> (f64, f64, f64, f64) {
|
||||
let left = f64::from(rect.x);
|
||||
let right = f64::from(rect.x + rect.width);
|
||||
let bottom = f64::from(rect.y);
|
||||
let top = f64::from(rect.y + rect.height);
|
||||
let points = [
|
||||
self.transform.page_to_pixel(left, bottom),
|
||||
self.transform.page_to_pixel(right, bottom),
|
||||
self.transform.page_to_pixel(left, top),
|
||||
self.transform.page_to_pixel(right, top),
|
||||
];
|
||||
let min_x = points
|
||||
.iter()
|
||||
.map(|point| point.0)
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
let max_x = points
|
||||
.iter()
|
||||
.map(|point| point.0)
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
let min_y = points
|
||||
.iter()
|
||||
.map(|point| point.1)
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
let max_y = points
|
||||
.iter()
|
||||
.map(|point| point.1)
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
(min_x, min_y, max_x - min_x, max_y - min_y)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn transform() -> PageTransform {
|
||||
PageTransform::from_corners(400, 200, (0.0, 100.0), (200.0, 100.0), (0.0, 0.0)).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transform_maps_both_directions_at_non_identity_scale() {
|
||||
let transform = transform();
|
||||
let point = transform.pixel_to_page(100.0, 50.0);
|
||||
assert!((point.x - 50.0).abs() < 1e-6);
|
||||
assert!((point.y - 75.0).abs() < 1e-6);
|
||||
let pixel = transform.page_to_pixel(f64::from(point.x), f64::from(point.y));
|
||||
assert!((pixel.0 - 100.0).abs() < 1e-6);
|
||||
assert!((pixel.1 - 50.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rendered_page_accepts_padding_and_validates_length() {
|
||||
let page = RenderedPage::new(
|
||||
1,
|
||||
200.0,
|
||||
100.0,
|
||||
400,
|
||||
200,
|
||||
1_204,
|
||||
RenderPixelFormat::Rgb8,
|
||||
vec![0; 1_204 * 200],
|
||||
transform(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(page.stride(), 1_204);
|
||||
|
||||
assert!(matches!(
|
||||
RenderedPage::new(
|
||||
1,
|
||||
200.0,
|
||||
100.0,
|
||||
400,
|
||||
200,
|
||||
1_204,
|
||||
RenderPixelFormat::Rgb8,
|
||||
vec![0; 5],
|
||||
transform(),
|
||||
),
|
||||
Err(RenderBufferError::InvalidBufferLength { .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotated_transform_round_trips_rectangles() {
|
||||
let transform =
|
||||
PageTransform::from_corners(100, 200, (0.0, 0.0), (0.0, 100.0), (200.0, 0.0)).unwrap();
|
||||
let page = RenderedPage::new(
|
||||
1,
|
||||
200.0,
|
||||
100.0,
|
||||
100,
|
||||
200,
|
||||
300,
|
||||
RenderPixelFormat::Rgb8,
|
||||
vec![0; 300 * 200],
|
||||
transform,
|
||||
)
|
||||
.unwrap();
|
||||
let pdf = page.pixel_rect_to_pdf_rect(10.0, 20.0, 30.0, 40.0);
|
||||
let pixel = page.pdf_rect_to_pixel(&pdf);
|
||||
assert!((pixel.0 - 10.0).abs() < 1e-5);
|
||||
assert!((pixel.1 - 20.0).abs() < 1e-5);
|
||||
assert!((pixel.2 - 30.0).abs() < 1e-5);
|
||||
assert!((pixel.3 - 40.0).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skewed_transform_bounds_all_rectangle_corners() {
|
||||
let transform =
|
||||
PageTransform::from_corners(100, 100, (0.0, 100.0), (100.0, 125.0), (25.0, 0.0))
|
||||
.unwrap();
|
||||
let page = RenderedPage::new(
|
||||
1,
|
||||
125.0,
|
||||
125.0,
|
||||
100,
|
||||
100,
|
||||
300,
|
||||
RenderPixelFormat::Rgb8,
|
||||
vec![0; 30_000],
|
||||
transform,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let pdf = page.pixel_rect_to_pdf_rect(10.0, 20.0, 30.0, 40.0);
|
||||
assert!((pdf.x - 15.0).abs() < 1e-5);
|
||||
assert!((pdf.y - 42.5).abs() < 1e-5);
|
||||
assert!((pdf.width - 40.0).abs() < 1e-5);
|
||||
assert!((pdf.height - 47.5).abs() < 1e-5);
|
||||
|
||||
let pixels = page.pdf_rect_to_pixel(&pdf);
|
||||
assert!(pixels.0 <= 10.0 && pixels.1 <= 20.0);
|
||||
assert!(pixels.0 + pixels.2 >= 40.0 && pixels.1 + pixels.3 >= 60.0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,384 @@
|
||||
//! Page routing and renderer/OCR orchestration without Markdown fusion.
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::error::Error;
|
||||
use std::time::Instant;
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
use super::{OcrEngine, OcrMode, OcrOptions, OcrPage, PageRenderer, RenderOptions, RenderedPage};
|
||||
|
||||
/// A rendered page paired with OCR output in the same bitmap coordinate space.
|
||||
#[derive(Debug)]
|
||||
pub struct RoutedOcrPage {
|
||||
/// Renderer-owned bitmap and pixel↔PDF transform.
|
||||
pub rendered: RenderedPage,
|
||||
/// Positioned OCR spans for the bitmap.
|
||||
pub ocr: OcrPage,
|
||||
}
|
||||
|
||||
/// Output of one selective OCR invocation.
|
||||
#[derive(Debug)]
|
||||
pub struct OcrRun {
|
||||
/// Pages processed in ascending document order.
|
||||
pub pages: Vec<RoutedOcrPage>,
|
||||
/// Total page-rendering wall time.
|
||||
pub render_time_ms: u64,
|
||||
/// Total engine wall time.
|
||||
pub ocr_time_ms: u64,
|
||||
}
|
||||
|
||||
/// Selects 1-indexed pages for OCR.
|
||||
///
|
||||
/// `recommended_pages` comes from pdf-inspector's existing detector/text
|
||||
/// quality signals. `selected_pages` is an optional user page filter. Results
|
||||
/// are validated, deduplicated, and returned in document order.
|
||||
pub fn route_ocr_pages(
|
||||
mode: OcrMode,
|
||||
page_count: u32,
|
||||
recommended_pages: &[u32],
|
||||
selected_pages: Option<&[u32]>,
|
||||
) -> Result<Vec<u32>, OcrRoutingError> {
|
||||
match mode {
|
||||
OcrMode::Off => Ok(Vec::new()),
|
||||
OcrMode::Auto => {
|
||||
let mut routed = validated_page_set("recommended", recommended_pages, page_count)?;
|
||||
if let Some(selected) = selected_pages {
|
||||
let selected = validated_page_set("selected", selected, page_count)?;
|
||||
routed.retain(|page| selected.contains(page));
|
||||
}
|
||||
Ok(routed.into_iter().collect())
|
||||
}
|
||||
OcrMode::Force => {
|
||||
let routed = selected_pages
|
||||
.map(|pages| validated_page_set("selected", pages, page_count))
|
||||
.transpose()?
|
||||
.unwrap_or_else(|| (1..=page_count).collect());
|
||||
Ok(routed.into_iter().collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders and recognizes already-routed pages while retaining transforms for
|
||||
/// the following fusion layer.
|
||||
///
|
||||
/// An empty page list returns without calling either dependency, which keeps
|
||||
/// model resolution and inference lazy when Auto routing finds no OCR work.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn run_ocr_pages<R, O>(
|
||||
renderer: &R,
|
||||
engine: &O,
|
||||
pdf_bytes: &[u8],
|
||||
pages: &[u32],
|
||||
password: Option<&str>,
|
||||
render_options: &RenderOptions,
|
||||
ocr_options: &OcrOptions,
|
||||
) -> Result<OcrRun, OcrRunError>
|
||||
where
|
||||
R: PageRenderer,
|
||||
O: OcrEngine,
|
||||
{
|
||||
if pages.is_empty() {
|
||||
return Ok(OcrRun {
|
||||
pages: Vec::new(),
|
||||
render_time_ms: 0,
|
||||
ocr_time_ms: 0,
|
||||
});
|
||||
}
|
||||
if ocr_options.mode == OcrMode::Off {
|
||||
return Err(OcrRunError::OcrDisabled);
|
||||
}
|
||||
|
||||
let render_started = Instant::now();
|
||||
let rendered = renderer
|
||||
.render_pages(pdf_bytes, pages, password, render_options)
|
||||
.map_err(|source| OcrRunError::Render {
|
||||
source: Box::new(source),
|
||||
})?;
|
||||
let render_time_ms = elapsed_ms(render_started);
|
||||
validate_page_order("renderer", pages, rendered.iter().map(RenderedPage::page))?;
|
||||
|
||||
let ocr_started = Instant::now();
|
||||
let recognized =
|
||||
engine
|
||||
.recognize(&rendered, ocr_options)
|
||||
.map_err(|source| OcrRunError::Ocr {
|
||||
source: Box::new(source),
|
||||
})?;
|
||||
let ocr_time_ms = elapsed_ms(ocr_started);
|
||||
validate_page_order(
|
||||
"OCR engine",
|
||||
pages,
|
||||
recognized.iter().map(|page| page.page_number),
|
||||
)?;
|
||||
|
||||
Ok(OcrRun {
|
||||
pages: rendered
|
||||
.into_iter()
|
||||
.zip(recognized)
|
||||
.map(|(rendered, ocr)| RoutedOcrPage { rendered, ocr })
|
||||
.collect(),
|
||||
render_time_ms,
|
||||
ocr_time_ms,
|
||||
})
|
||||
}
|
||||
|
||||
/// Invalid page routing or renderer/engine contract output.
|
||||
#[derive(Debug, Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum OcrRoutingError {
|
||||
/// A page list contained zero or a page beyond the document.
|
||||
#[error("{source_name} OCR page {page} is outside the valid range 1..={page_count}")]
|
||||
InvalidPage {
|
||||
/// Page-list source.
|
||||
source_name: &'static str,
|
||||
/// Invalid 1-indexed page.
|
||||
page: u32,
|
||||
/// Document page count.
|
||||
page_count: u32,
|
||||
},
|
||||
}
|
||||
|
||||
/// Failures while rendering and recognizing a routed page set.
|
||||
#[derive(Debug, Error)]
|
||||
#[non_exhaustive]
|
||||
pub enum OcrRunError {
|
||||
/// A non-empty route cannot execute with OCR disabled.
|
||||
#[error("cannot process routed pages while OCR mode is Off")]
|
||||
OcrDisabled,
|
||||
/// Page rasterization failed.
|
||||
#[error("page rendering failed: {source}")]
|
||||
Render {
|
||||
/// Renderer-specific failure.
|
||||
#[source]
|
||||
source: Box<dyn Error + Send + Sync>,
|
||||
},
|
||||
/// OCR inference failed.
|
||||
#[error("OCR inference failed: {source}")]
|
||||
Ocr {
|
||||
/// Engine-specific failure.
|
||||
#[source]
|
||||
source: Box<dyn Error + Send + Sync>,
|
||||
},
|
||||
/// A dependency returned the wrong count or order.
|
||||
#[error("{stage} returned pages {actual:?}; expected {expected:?}")]
|
||||
PageOrderMismatch {
|
||||
/// Dependency boundary that violated the contract.
|
||||
stage: &'static str,
|
||||
/// Requested 1-indexed pages.
|
||||
expected: Vec<u32>,
|
||||
/// Returned 1-indexed pages.
|
||||
actual: Vec<u32>,
|
||||
},
|
||||
}
|
||||
|
||||
fn validated_page_set(
|
||||
source_name: &'static str,
|
||||
pages: &[u32],
|
||||
page_count: u32,
|
||||
) -> Result<BTreeSet<u32>, OcrRoutingError> {
|
||||
let mut result = BTreeSet::new();
|
||||
for &page in pages {
|
||||
if page == 0 || page > page_count {
|
||||
return Err(OcrRoutingError::InvalidPage {
|
||||
source_name,
|
||||
page,
|
||||
page_count,
|
||||
});
|
||||
}
|
||||
result.insert(page);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn validate_page_order(
|
||||
stage: &'static str,
|
||||
expected: &[u32],
|
||||
actual: impl IntoIterator<Item = u32>,
|
||||
) -> Result<(), OcrRunError> {
|
||||
let actual: Vec<u32> = actual.into_iter().collect();
|
||||
if actual == expected {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(OcrRunError::PageOrderMismatch {
|
||||
stage,
|
||||
expected: expected.to_vec(),
|
||||
actual,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn elapsed_ms(started: Instant) -> u64 {
|
||||
u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::vision::{
|
||||
ImagePoint, ImageQuad, ModelIdentity, OcrSpan, PageTransform, RenderBufferError,
|
||||
RenderPixelFormat,
|
||||
};
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
#[error("fake failure")]
|
||||
struct FakeError;
|
||||
|
||||
struct FakeRenderer;
|
||||
|
||||
impl PageRenderer for FakeRenderer {
|
||||
type Error = FakeError;
|
||||
|
||||
fn render_pages(
|
||||
&self,
|
||||
_pdf_bytes: &[u8],
|
||||
pages: &[u32],
|
||||
_password: Option<&str>,
|
||||
_options: &RenderOptions,
|
||||
) -> Result<Vec<RenderedPage>, Self::Error> {
|
||||
pages
|
||||
.iter()
|
||||
.copied()
|
||||
.map(rendered_page)
|
||||
.collect::<Result<_, _>>()
|
||||
.map_err(|_| FakeError)
|
||||
}
|
||||
}
|
||||
|
||||
struct FakeEngine {
|
||||
model: ModelIdentity,
|
||||
}
|
||||
|
||||
impl FakeEngine {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
model: ModelIdentity::new("fake", "v1"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl OcrEngine for FakeEngine {
|
||||
type Error = FakeError;
|
||||
|
||||
fn model(&self) -> &ModelIdentity {
|
||||
&self.model
|
||||
}
|
||||
|
||||
fn recognize(
|
||||
&self,
|
||||
pages: &[RenderedPage],
|
||||
_options: &OcrOptions,
|
||||
) -> Result<Vec<OcrPage>, Self::Error> {
|
||||
Ok(pages
|
||||
.iter()
|
||||
.map(|page| OcrPage {
|
||||
page_number: page.page(),
|
||||
spans: vec![OcrSpan {
|
||||
text: format!("page {}", page.page()),
|
||||
polygon: ImageQuad::new([
|
||||
ImagePoint::new(0.0, 0.0),
|
||||
ImagePoint::new(1.0, 0.0),
|
||||
ImagePoint::new(1.0, 1.0),
|
||||
ImagePoint::new(0.0, 1.0),
|
||||
]),
|
||||
confidence: 0.9,
|
||||
orientation_degrees: None,
|
||||
}],
|
||||
mean_confidence: Some(0.9),
|
||||
model: self.model.clone(),
|
||||
processing_time_ms: 1,
|
||||
warnings: Vec::new(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
fn rendered_page(page: u32) -> Result<RenderedPage, RenderBufferError> {
|
||||
let transform =
|
||||
PageTransform::from_corners(1, 1, (0.0, 1.0), (1.0, 1.0), (0.0, 0.0)).unwrap();
|
||||
RenderedPage::new(
|
||||
page,
|
||||
1.0,
|
||||
1.0,
|
||||
1,
|
||||
1,
|
||||
3,
|
||||
RenderPixelFormat::Rgb8,
|
||||
vec![255; 3],
|
||||
transform,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn off_auto_and_force_route_expected_pages() {
|
||||
assert_eq!(
|
||||
route_ocr_pages(OcrMode::Off, 0, &[99], Some(&[0])).unwrap(),
|
||||
Vec::<u32>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
route_ocr_pages(OcrMode::Auto, 5, &[5, 3, 3, 1], Some(&[2, 3, 5])).unwrap(),
|
||||
vec![3, 5]
|
||||
);
|
||||
assert_eq!(
|
||||
route_ocr_pages(OcrMode::Force, 4, &[], None).unwrap(),
|
||||
vec![1, 2, 3, 4]
|
||||
);
|
||||
assert_eq!(
|
||||
route_ocr_pages(OcrMode::Force, 4, &[], Some(&[4, 2, 2])).unwrap(),
|
||||
vec![2, 4]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routing_rejects_invalid_page_numbers() {
|
||||
assert!(matches!(
|
||||
route_ocr_pages(OcrMode::Auto, 2, &[0], None),
|
||||
Err(OcrRoutingError::InvalidPage { page: 0, .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
route_ocr_pages(OcrMode::Force, 2, &[], Some(&[3])),
|
||||
Err(OcrRoutingError::InvalidPage { page: 3, .. })
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn run_retains_render_transforms_and_input_order() {
|
||||
let options = OcrOptions::new().mode(OcrMode::Auto);
|
||||
let run = run_ocr_pages(
|
||||
&FakeRenderer,
|
||||
&FakeEngine::new(),
|
||||
b"pdf",
|
||||
&[2, 4],
|
||||
None,
|
||||
&RenderOptions::new(),
|
||||
&options,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
run.pages
|
||||
.iter()
|
||||
.map(|page| page.rendered.page())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![2, 4]
|
||||
);
|
||||
assert_eq!(run.pages[1].ocr.spans[0].text, "page 4");
|
||||
assert_eq!(run.pages[1].rendered.pixel_to_page(0.0, 0.0).y, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_route_is_a_noop_even_when_ocr_is_off() {
|
||||
let run = run_ocr_pages(
|
||||
&FakeRenderer,
|
||||
&FakeEngine::new(),
|
||||
b"pdf",
|
||||
&[],
|
||||
None,
|
||||
&RenderOptions::new(),
|
||||
&OcrOptions::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(run.pages.is_empty());
|
||||
assert_eq!(run.render_time_ms, 0);
|
||||
assert_eq!(run.ocr_time_ms, 0);
|
||||
}
|
||||
}
|
||||
@@ -1654,6 +1654,282 @@ fn test_extract_regions_mem_basic_text_pdf() {
|
||||
assert_eq!(regions[0].page, 0);
|
||||
}
|
||||
|
||||
/// Build a synthetic "scanned page" PDF: a full-page image XObject with a
|
||||
/// text layer drawn in the given render mode (3 = invisible OCR overlay,
|
||||
/// 0 = normal visible fill). `visible_extra` optionally adds a normally
|
||||
/// rendered line so double-layer behavior can be tested; `layer_lines`
|
||||
/// overrides the layer content (default: three pangram lines);
|
||||
/// `quote_ops` shows every layer line via the `'` operator instead of Tj
|
||||
/// (both are standard show-text encodings for OCR layers).
|
||||
fn make_pdf_with_custom_text_layer(
|
||||
text_render_mode: i32,
|
||||
visible_extra: Option<&str>,
|
||||
layer_lines: Option<&[&str]>,
|
||||
quote_ops: bool,
|
||||
) -> Vec<u8> {
|
||||
let mut pdf = b"%PDF-1.4\n".to_vec();
|
||||
let mut offsets = vec![0usize];
|
||||
|
||||
fn add_object(pdf: &mut Vec<u8>, offsets: &mut Vec<usize>, id: usize, body: &str) {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
|
||||
pdf.extend_from_slice(body.as_bytes());
|
||||
pdf.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
fn add_stream_object(
|
||||
pdf: &mut Vec<u8>,
|
||||
offsets: &mut Vec<usize>,
|
||||
id: usize,
|
||||
dict: &str,
|
||||
stream_bytes: &[u8],
|
||||
) {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
|
||||
pdf.extend_from_slice(
|
||||
format!("<< {} /Length {} >>\nstream\n", dict, stream_bytes.len()).as_bytes(),
|
||||
);
|
||||
pdf.extend_from_slice(stream_bytes);
|
||||
pdf.extend_from_slice(b"\nendstream\nendobj\n");
|
||||
}
|
||||
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
1,
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
);
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
2,
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
);
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
3,
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
|
||||
/Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >> \
|
||||
/Contents 4 0 R >>",
|
||||
);
|
||||
// Full-page raster, then the text layer in the requested render mode —
|
||||
// several lines so the OCR-layer gate's alnum floor (40) is well cleared.
|
||||
let mut content = String::from("q 612 0 0 792 0 0 cm /Im0 Do Q\n");
|
||||
let default_layer = [
|
||||
"The quick brown fox jumps over the lazy dog",
|
||||
"Pack my box with five dozen liquor jugs tonight",
|
||||
"Sphinx of black quartz judge my vow carefully",
|
||||
];
|
||||
let layer: &[&str] = layer_lines.unwrap_or(&default_layer);
|
||||
if quote_ops {
|
||||
// Every line shown via `'` (move-to-next-line + show) — nothing on
|
||||
// this layer goes through Tj, pinning the `'` suppression path.
|
||||
content.push_str(&format!(
|
||||
"BT /F1 12 Tf {text_render_mode} Tr 16 TL 72 716 Td "
|
||||
));
|
||||
for line in layer {
|
||||
content.push_str(&format!("({line}) ' "));
|
||||
}
|
||||
} else {
|
||||
content.push_str(&format!("BT /F1 12 Tf {text_render_mode} Tr 72 700 Td "));
|
||||
for (i, line) in layer.iter().enumerate() {
|
||||
if i > 0 {
|
||||
content.push_str("0 -16 Td ");
|
||||
}
|
||||
content.push_str(&format!("({line}) Tj "));
|
||||
}
|
||||
}
|
||||
content.push_str("ET\n");
|
||||
if let Some(extra) = visible_extra {
|
||||
content.push_str(&format!("BT /F1 12 Tf 0 Tr 72 500 Td ({extra}) Tj ET\n"));
|
||||
}
|
||||
add_stream_object(&mut pdf, &mut offsets, 4, "", content.as_bytes());
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
5,
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
);
|
||||
let image_pixel = [128u8];
|
||||
add_stream_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
6,
|
||||
"/Type /XObject /Subtype /Image /Width 1 /Height 1 \
|
||||
/ColorSpace /DeviceGray /BitsPerComponent 8",
|
||||
&image_pixel,
|
||||
);
|
||||
|
||||
let xref_start = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n", offsets.len()).as_bytes());
|
||||
pdf.extend_from_slice(b"0000000000 65535 f \n");
|
||||
for offset in offsets.iter().skip(1) {
|
||||
pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(
|
||||
format!(
|
||||
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF",
|
||||
offsets.len(),
|
||||
xref_start
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
pdf
|
||||
}
|
||||
|
||||
fn make_pdf_with_text_layer(text_render_mode: i32, visible_extra: Option<&str>) -> Vec<u8> {
|
||||
make_pdf_with_custom_text_layer(text_render_mode, visible_extra, None, false)
|
||||
}
|
||||
|
||||
/// A scanned page whose only text is an invisible (Tr 3) OCR layer behind
|
||||
/// the raster must serve that layer from the region extractor instead of
|
||||
/// reporting the region as needs_ocr — the exact text is already in the PDF.
|
||||
#[test]
|
||||
fn test_extract_regions_mem_recovers_invisible_ocr_layer() {
|
||||
let buf = make_pdf_with_text_layer(3, None);
|
||||
let regions = extract_text_in_regions_mem(&buf, &full_page_regions(1)).unwrap();
|
||||
assert_eq!(regions.len(), 1);
|
||||
let region = ®ions[0].regions[0];
|
||||
assert!(
|
||||
region.text.contains("quick brown fox"),
|
||||
"invisible OCR layer should be served as region text, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
assert!(
|
||||
!region.needs_ocr,
|
||||
"recovered OCR layer must not fall back to GPU OCR"
|
||||
);
|
||||
}
|
||||
|
||||
/// ANY visible text on the page — even a single short line — must block the
|
||||
/// invisible-layer adoption entirely: the invisible pass returns visible
|
||||
/// items too, so adopting it alongside visible text would duplicate the
|
||||
/// visible words. Strict zero-visible gate, no fuzzy dedupe.
|
||||
#[test]
|
||||
fn test_extract_regions_mem_visible_text_blocks_invisible_layer() {
|
||||
let buf = make_pdf_with_text_layer(3, Some("Folio 142"));
|
||||
let regions = extract_text_in_regions_mem(&buf, &full_page_regions(1)).unwrap();
|
||||
let region = ®ions[0].regions[0];
|
||||
assert!(
|
||||
region.text.contains("Folio 142"),
|
||||
"visible text should be extracted, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
assert!(
|
||||
!region.text.contains("quick brown fox"),
|
||||
"invisible layer must not be adopted when any visible text exists, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
assert_eq!(
|
||||
region.text.matches("Folio 142").count(),
|
||||
1,
|
||||
"visible text must appear exactly once, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
}
|
||||
|
||||
/// An invisible OCR layer shown entirely via the `'` show-text operator
|
||||
/// (move-to-next-line + show) must also be recovered — the skipped_invisible
|
||||
/// signal has to fire on every show-text path, not just Tj/TJ.
|
||||
#[test]
|
||||
fn test_extract_regions_mem_recovers_quote_operator_layer() {
|
||||
let buf = make_pdf_with_custom_text_layer(3, None, None, true);
|
||||
let regions = extract_text_in_regions_mem(&buf, &full_page_regions(1)).unwrap();
|
||||
let region = ®ions[0].regions[0];
|
||||
assert!(
|
||||
region.text.contains("quick brown fox"),
|
||||
"'-operator OCR layer should be recovered, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
assert!(!region.needs_ocr);
|
||||
}
|
||||
|
||||
/// An invisible layer below the 40-alnum floor (a stray watermark line)
|
||||
/// must NOT be adopted — the region keeps its needs_ocr fallback.
|
||||
#[test]
|
||||
fn test_extract_regions_mem_tiny_invisible_layer_not_adopted() {
|
||||
let buf = make_pdf_with_custom_text_layer(3, None, Some(&["Scanned by ACME"]), false);
|
||||
let regions = extract_text_in_regions_mem(&buf, &full_page_regions(1)).unwrap();
|
||||
let region = ®ions[0].regions[0];
|
||||
assert!(
|
||||
!region.text.contains("Scanned by ACME"),
|
||||
"below-floor invisible layer must not be adopted, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
// Only the raster placeholder remains — needs_ocr stays whatever main
|
||||
// reports for placeholder-only regions (false today; downstream
|
||||
// pipelines route placeholder-only text to OCR themselves, and this PR
|
||||
// deliberately does not change that contract).
|
||||
assert!(
|
||||
region.text.trim().starts_with("[Image:"),
|
||||
"region should hold only the raster placeholder, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
}
|
||||
|
||||
/// An invisible layer that clears the alnum floor but is mostly symbol
|
||||
/// garbage (a broken OCR run) must be rejected by the garbage gate.
|
||||
#[test]
|
||||
fn test_extract_regions_mem_garbage_invisible_layer_not_adopted() {
|
||||
// Each line: 5 alphanumerics among 15 symbol chars. Ten lines clear the
|
||||
// 40-alnum floor (50 alnum) while staying well under the half-alnum
|
||||
// ratio is_garbage_text requires.
|
||||
let garbage_lines: Vec<&str> = vec!["a@@b%%c&&d==e~~"; 10];
|
||||
let buf = make_pdf_with_custom_text_layer(3, None, Some(&garbage_lines), false);
|
||||
let regions = extract_text_in_regions_mem(&buf, &full_page_regions(1)).unwrap();
|
||||
let region = ®ions[0].regions[0];
|
||||
assert!(
|
||||
!region.text.contains("a@@b"),
|
||||
"garbage invisible layer must not be adopted, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
assert!(
|
||||
region.text.trim().starts_with("[Image:"),
|
||||
"region should hold only the raster placeholder, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
}
|
||||
|
||||
/// Punctuation-only visible text (zero alphanumerics) must ALSO block
|
||||
/// adoption — the gate is item-presence, not alphanumeric mass. (Real-world
|
||||
/// rationale: an invisible OCR layer transcribes the raster, so visible
|
||||
/// glyphs typically have invisible twins there; this fixture's layers are
|
||||
/// disjoint, so it pins the gate itself, not the duplication scenario.)
|
||||
#[test]
|
||||
fn test_extract_regions_mem_punctuation_visible_blocks_invisible_layer() {
|
||||
let buf = make_pdf_with_text_layer(3, Some("... --- ..."));
|
||||
let regions = extract_text_in_regions_mem(&buf, &full_page_regions(1)).unwrap();
|
||||
let region = ®ions[0].regions[0];
|
||||
assert!(
|
||||
!region.text.contains("quick brown fox"),
|
||||
"invisible layer must not be adopted over punctuation-only visible text, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
assert_eq!(
|
||||
region.text.matches("... --- ...").count(),
|
||||
1,
|
||||
"visible punctuation must be preserved exactly once, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression guard: a normal visible-text page (render mode 0) is served
|
||||
/// once and only once — if the fallback ever mis-fired here and merged a
|
||||
/// second pass, the phrase would duplicate.
|
||||
#[test]
|
||||
fn test_extract_regions_mem_visible_layer_unchanged() {
|
||||
let buf = make_pdf_with_text_layer(0, None);
|
||||
let regions = extract_text_in_regions_mem(&buf, &full_page_regions(1)).unwrap();
|
||||
let region = ®ions[0].regions[0];
|
||||
assert_eq!(
|
||||
region.text.matches("quick brown fox").count(),
|
||||
1,
|
||||
"visible text must appear exactly once, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
assert!(!region.needs_ocr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_regions_mem_identity_h_needs_ocr() {
|
||||
let buf = std::fs::read("tests/fixtures/shinagawa_identity_h.pdf").unwrap();
|
||||
@@ -3219,6 +3495,28 @@ fn test_extract_pages_markdown_basic() {
|
||||
assert!(!result.pages[0].needs_ocr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_pages_markdown_keeps_line_based_tables() {
|
||||
// The per-page path (used by every `--ocr auto` run) once passed an
|
||||
// empty line slice to markdown conversion, silently dropping every
|
||||
// table that only the line-based detector finds. This fixture's table
|
||||
// is rule-anchored: it must survive the pages API exactly as it does
|
||||
// the whole-document API.
|
||||
let buf = std::fs::read("tests/fixtures/bits_pilani_feedback.pdf").unwrap();
|
||||
let result = extract_pages_markdown_mem(&buf, None).unwrap();
|
||||
|
||||
let all_markdown: String = result
|
||||
.pages
|
||||
.iter()
|
||||
.map(|p| p.markdown.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
assert!(
|
||||
all_markdown.contains("|BIO|"),
|
||||
"line-based table rows missing from pages API output"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_pages_markdown_uses_document_wide_folio_context() {
|
||||
let pdf = make_recurring_contextual_folio_pdf();
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
#![cfg(all(feature = "render-pdfium", not(target_arch = "wasm32")))]
|
||||
|
||||
use pdf_inspector::vision::{PdfiumRenderer, RenderError, RenderOptions, RenderPixelFormat};
|
||||
|
||||
fn load_renderer() -> Option<PdfiumRenderer> {
|
||||
match PdfiumRenderer::load() {
|
||||
Ok(renderer) => Some(renderer),
|
||||
Err(RenderError::PdfiumLoad { .. }) => {
|
||||
eprintln!("skipping PDFium runtime test because no native library is installed");
|
||||
None
|
||||
}
|
||||
Err(error) => panic!("failed to load PDFium: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_owned_rgb_page_and_round_trips_coordinates() {
|
||||
let Some(renderer) = load_renderer() else {
|
||||
return;
|
||||
};
|
||||
let bytes = std::fs::read("tests/fixtures/thermo-freon12.pdf").unwrap();
|
||||
let pages = renderer
|
||||
.render_pages(
|
||||
&bytes,
|
||||
&[1],
|
||||
None,
|
||||
&RenderOptions::new().dpi(150.0).form_fields(false),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(pages.len(), 1);
|
||||
let page = &pages[0];
|
||||
assert_eq!(page.page(), 1);
|
||||
assert_eq!(page.format(), RenderPixelFormat::Rgb8);
|
||||
assert_eq!(page.stride(), page.width() as usize * 3);
|
||||
assert_eq!(page.pixels().len(), page.stride() * page.height() as usize);
|
||||
assert!((page.width() as f32 - page.page_width()).abs() > 1.0);
|
||||
|
||||
let pdf_rect = page.pixel_rect_to_pdf_rect(10.0, 10.0, 20.0, 12.0);
|
||||
let pixel_rect = page.pdf_rect_to_pixel(&pdf_rect);
|
||||
assert!((pixel_rect.0 - 10.0).abs() < 0.01);
|
||||
assert!((pixel_rect.1 - 10.0).abs() < 0.01);
|
||||
assert!((pixel_rect.2 - 20.0).abs() < 0.01);
|
||||
assert!((pixel_rect.3 - 12.0).abs() < 0.01);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_zero_and_out_of_range_page_numbers() {
|
||||
let Some(renderer) = load_renderer() else {
|
||||
return;
|
||||
};
|
||||
let bytes = std::fs::read("tests/fixtures/thermo-freon12.pdf").unwrap();
|
||||
|
||||
assert!(matches!(
|
||||
renderer.render_pages(&bytes, &[0], None, &RenderOptions::new()),
|
||||
Err(RenderError::InvalidPageNumber)
|
||||
));
|
||||
|
||||
assert!(matches!(
|
||||
renderer.render_pages(&bytes, &[u32::MAX], None, &RenderOptions::new()),
|
||||
Err(RenderError::PageOutOfBounds { .. })
|
||||
));
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
#![cfg(all(feature = "ocr-oar", not(target_arch = "wasm32")))]
|
||||
|
||||
#[cfg(feature = "ocr")]
|
||||
use pdf_inspector::vision::{
|
||||
process_pdf_with_ocr_mem, ModelDownloadPolicy, OcrPdfOptions, OcrPipelineError,
|
||||
PageContentSource,
|
||||
};
|
||||
use pdf_inspector::vision::{
|
||||
ModelStore, OarOcrEngine, OcrEngine, OcrMode, OcrOptions, PageTransform, RenderPixelFormat,
|
||||
RenderedPage, PP_OCR_V6_SMALL,
|
||||
};
|
||||
#[cfg(feature = "render-pdfium")]
|
||||
use pdf_inspector::vision::{PdfiumRenderer, RenderError, RenderOptions};
|
||||
|
||||
const MODEL_DIRECTORY_ENV: &str = "PDF_INSPECTOR_OCR_TEST_MODELS";
|
||||
const IMAGE_ENV: &str = "PDF_INSPECTOR_OCR_TEST_IMAGE";
|
||||
const EXPECTED_TEXT_ENV: &str = "PDF_INSPECTOR_OCR_TEST_EXPECTED";
|
||||
|
||||
#[cfg(feature = "render-pdfium")]
|
||||
fn load_renderer() -> Option<PdfiumRenderer> {
|
||||
match PdfiumRenderer::load() {
|
||||
Ok(renderer) => Some(renderer),
|
||||
Err(RenderError::PdfiumLoad { .. }) => {
|
||||
eprintln!("skipping OCR runtime test because no native PDFium library is installed");
|
||||
None
|
||||
}
|
||||
Err(error) => panic!("failed to load PDFium: {error}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recognizes_an_rgb_image_with_verified_models() {
|
||||
let Some(model_directory) = std::env::var_os(MODEL_DIRECTORY_ENV) else {
|
||||
eprintln!("skipping OCR runtime test because {MODEL_DIRECTORY_ENV} is not set");
|
||||
return;
|
||||
};
|
||||
let Some(image_path) = std::env::var_os(IMAGE_ENV) else {
|
||||
eprintln!("skipping OCR runtime test because {IMAGE_ENV} is not set");
|
||||
return;
|
||||
};
|
||||
|
||||
let image = image::open(image_path).unwrap().into_rgb8();
|
||||
let (width, height) = image.dimensions();
|
||||
let transform = PageTransform::from_corners(
|
||||
width,
|
||||
height,
|
||||
(0.0, f64::from(height)),
|
||||
(f64::from(width), f64::from(height)),
|
||||
(0.0, 0.0),
|
||||
)
|
||||
.unwrap();
|
||||
let page = RenderedPage::new(
|
||||
1,
|
||||
width as f32,
|
||||
height as f32,
|
||||
width,
|
||||
height,
|
||||
width as usize * 3,
|
||||
RenderPixelFormat::Rgb8,
|
||||
image.into_raw(),
|
||||
transform,
|
||||
)
|
||||
.unwrap();
|
||||
let results = recognize(&model_directory, &[page]);
|
||||
assert_usable_result(&results);
|
||||
|
||||
let text = results[0]
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.text.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
eprintln!("recognized: {text}");
|
||||
if let Ok(expected) = std::env::var(EXPECTED_TEXT_ENV) {
|
||||
assert!(
|
||||
text.to_lowercase().contains(&expected.to_lowercase()),
|
||||
"expected OCR output to contain {expected:?}, got {text:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "render-pdfium")]
|
||||
#[test]
|
||||
fn recognizes_a_pdfium_rendered_fixture_with_verified_models() {
|
||||
let Some(model_directory) = std::env::var_os(MODEL_DIRECTORY_ENV) else {
|
||||
eprintln!("skipping OCR runtime test because {MODEL_DIRECTORY_ENV} is not set");
|
||||
return;
|
||||
};
|
||||
let Some(renderer) = load_renderer() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let bytes = std::fs::read("tests/fixtures/thermo-freon12.pdf").unwrap();
|
||||
let pages = renderer
|
||||
.render_pages(
|
||||
&bytes,
|
||||
&[1],
|
||||
None,
|
||||
&RenderOptions::new().dpi(150.0).form_fields(false),
|
||||
)
|
||||
.unwrap();
|
||||
let results = recognize(&model_directory, &pages);
|
||||
assert_usable_result(&results);
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "ocr", feature = "render-pdfium"))]
|
||||
#[test]
|
||||
fn complete_ocr_pipeline_routes_and_assembles_a_scanned_fixture() {
|
||||
let Some(model_directory) = std::env::var_os(MODEL_DIRECTORY_ENV) else {
|
||||
eprintln!("skipping OCR runtime test because {MODEL_DIRECTORY_ENV} is not set");
|
||||
return;
|
||||
};
|
||||
let Some(_renderer) = load_renderer() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let bytes = std::fs::read("tests/fixtures/scan_with_native_header_text.pdf").unwrap();
|
||||
let ocr = OcrOptions::new()
|
||||
.mode(OcrMode::Auto)
|
||||
.minimum_confidence(0.3)
|
||||
.model_directory(model_directory)
|
||||
.model_downloads(ModelDownloadPolicy::Offline);
|
||||
let options = OcrPdfOptions::new().ocr(ocr);
|
||||
let result = process_pdf_with_ocr_mem(&bytes, options.clone()).unwrap();
|
||||
let repeated = process_pdf_with_ocr_mem(&bytes, options).unwrap();
|
||||
|
||||
assert_eq!(result.pages_routed_to_ocr, vec![1]);
|
||||
assert!(!result.markdown.trim().is_empty());
|
||||
assert!(result
|
||||
.markdown
|
||||
.contains("Order Date Item Code Description Status Unit Cost\n\n03/14/2024"));
|
||||
assert!(result.markdown.contains("$482,110.40\n\n05/02/2024"));
|
||||
assert_eq!(result.pages[0].provenance.source, PageContentSource::Fused);
|
||||
assert!(result.pages[0]
|
||||
.provenance
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|warning| warning.contains("complementary OCR")));
|
||||
assert_eq!(
|
||||
result.pages[0].provenance.ocr_model.as_ref().unwrap().name,
|
||||
PP_OCR_V6_SMALL.id
|
||||
);
|
||||
assert_eq!(repeated.markdown, result.markdown);
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "ocr", feature = "render-pdfium"))]
|
||||
#[test]
|
||||
fn auto_recovers_credible_native_text_before_loading_ocr_models() {
|
||||
let Some(_renderer) = load_renderer() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let bytes = std::fs::read("tests/fixtures/shinagawa_identity_h.pdf").unwrap();
|
||||
let ocr = OcrOptions::new()
|
||||
.mode(OcrMode::Auto)
|
||||
.model_directory("/models/must-not-be-read")
|
||||
.model_downloads(ModelDownloadPolicy::Offline);
|
||||
let result = process_pdf_with_ocr_mem(&bytes, OcrPdfOptions::new().ocr(ocr)).unwrap();
|
||||
|
||||
assert_eq!(result.pages_recommended_for_ocr, vec![1]);
|
||||
assert!(result.pages_routed_to_ocr.is_empty());
|
||||
assert!(result.markdown.contains("羽田空港新飛行経路"));
|
||||
assert!(result.markdown.contains("|4月30日|有|81.0|"));
|
||||
assert!(result.markdown.contains("※1 最大騒音レベル"));
|
||||
assert!(result.pages_with_tables.contains(&1));
|
||||
assert_eq!(result.pages[0].provenance.source, PageContentSource::Native);
|
||||
assert!(result.pages[0].provenance.ocr_model.is_none());
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "ocr", feature = "render-pdfium"))]
|
||||
#[test]
|
||||
fn auto_rejects_garbled_native_recovery_and_continues_to_ocr() {
|
||||
let Some(_renderer) = load_renderer() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let bytes = std::fs::read("tests/fixtures/shifted_cipher_tounicode.pdf").unwrap();
|
||||
let ocr = OcrOptions::new()
|
||||
.mode(OcrMode::Auto)
|
||||
.model_directory("/models/must-not-be-read")
|
||||
.model_downloads(ModelDownloadPolicy::Offline);
|
||||
let error = process_pdf_with_ocr_mem(&bytes, OcrPdfOptions::new().ocr(ocr)).unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
OcrPipelineError::ModelAcquire(_) | OcrPipelineError::ModelStore(_)
|
||||
));
|
||||
}
|
||||
|
||||
fn recognize(
|
||||
model_directory: &std::ffi::OsStr,
|
||||
pages: &[RenderedPage],
|
||||
) -> Vec<pdf_inspector::vision::OcrPage> {
|
||||
let store = ModelStore::new(model_directory).override_root(model_directory);
|
||||
let models = store.resolve(&PP_OCR_V6_SMALL).unwrap();
|
||||
let engine = OarOcrEngine::from_models(&models).unwrap();
|
||||
engine
|
||||
.recognize(
|
||||
pages,
|
||||
&OcrOptions::new()
|
||||
.mode(OcrMode::Force)
|
||||
.minimum_confidence(0.3),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn assert_usable_result(results: &[pdf_inspector::vision::OcrPage]) {
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].page_number, 1);
|
||||
assert_eq!(results[0].model.name, PP_OCR_V6_SMALL.id);
|
||||
assert_eq!(results[0].model.revision, PP_OCR_V6_SMALL.revision);
|
||||
assert!(!results[0].spans.is_empty());
|
||||
assert!(results[0].spans.iter().all(|span| span.confidence >= 0.3));
|
||||
}
|
||||
@@ -2,9 +2,19 @@
|
||||
|
||||
# BePriced?
|
||||
|
||||
*Commercial real estate pricing* **C O M M E R C I A L R E A L E S T A T E** pricingisliketheweather:everyonetalks *needs disciplined and systematic*about it, but few understand it. Most observers base “appropriate” real estate *analysis of the data.* pricing on historical norms. The cap rate—anindicatorofvaluerelativetosta- bilized net operating income (NOI) before capital expenditures, tenant improvement,andleasingcommissions— isthemostcommonlyusedmetricofreal estate pricing. But cap rates have been largelyunresponsivetoalternativeratesof return available to investors, with the **P E T E R L I N N E M A N** exception of BBB bonds, throughout
|
||||
*Commercial real estate pricing*
|
||||
|
||||
8 4 Z E L L / L U R I E R E A L E S T A T E C E N T E R
|
||||
*needs disciplined and systematic*
|
||||
|
||||
*analysis of the data.*
|
||||
|
||||
**C O M M E R C I A L R E A L E S T A T E** pricingisliketheweather:everyonetalks about it, but few understand it. Most observers base “appropriate” real estate pricing on historical norms. The cap rate—anindicatorofvaluerelativetosta- bilized net operating income (NOI) before capital expenditures, tenant improvement,andleasingcommissions— isthemostcommonlyusedmetricofreal estate pricing. But cap rates have been largelyunresponsivetoalternativeratesof return available to investors, with the exception of BBB bonds, throughout
|
||||
|
||||
C E N T E R
|
||||
|
||||
**P E T E R L I N N E M A N**
|
||||
|
||||
8 4 Z E L L / L U R I E R E A L E S T A T E
|
||||
|
||||
**Table I:** Cap rate correlations **Cap Rate Correlation With:*** **BBB Corp** **10-Year Bond Yield S&P Dividend** **Treasury (10-15 yr) Yield** Multifamily 0.187 0.771 0.068 Industrial-0.221 0.748-0.307 CBD Office-0.449 0.694-0.458 Retail-0.181 0.649-02.58
|
||||
|
||||
@@ -13,9 +23,11 @@
|
||||
12 10 8 Percent 6 4 2 1982 1986 1990 1994 1998 2002 2006
|
||||
Apartment Retail ndustrial 10-yr reasury CBD Office
|
||||
|
||||
most of the past twenty-five years (Table presented in Figure 2 with an eighteen-
|
||||
most of the past twenty-five years (Table
|
||||
|
||||
I). Such a relationship defies investment theory,asrealestatepricingshouldchange as property risks and the returns of alter- nativeinvestmentschange. Figure1displaysNCREIFcapratesby property type compared to the ten-year Treasury yield. Because the National Council of Real Estate Investment Fiduciaries (NCREIF) cap rate data is seriouslyflawedduetoappraisallags,itis
|
||||
presented in Figure 2 with an eighteen- monthlag.Thisdataprovidesanoverview ofthepricingofinstitutionalqualityreal estate.Figure2reflectsthesecapratesnet of the ten-year Treasury yield. Since cap rate spreads are highly correlated across propertytypes(TableII),wecanspeakof “cap rates” without reference to property type with little loss of insight. Cap rate spreadswerenegativeintheearlytomid- 1980s, when purchasing real estate was
|
||||
|
||||
I). Such a relationship defies investment monthlag.Thisdataprovidesanoverview theory,asrealestatepricingshouldchange ofthepricingofinstitutionalqualityreal as property risks and the returns of alter-estate.Figure2reflectsthesecapratesnet nativeinvestmentschange. of the ten-year Treasury yield. Since cap Figure1displaysNCREIFcapratesby rate spreads are highly correlated across property type compared to the ten-year propertytypes(TableII),wecanspeakof Treasury yield. Because the National “cap rates” without reference to property Council of Real Estate Investment type with little loss of insight. Cap rate Fiduciaries (NCREIF) cap rate data is spreadswerenegativeintheearlytomid- seriouslyflawedduetoappraisallags,itis 1980s, when purchasing real estate was
|
||||
R E V I E W 8 5
|
||||
|
||||
**Figure 2:** Capratespreadsover10-yearTreasury
|
||||
@@ -46,9 +58,9 @@ more about investing in tax losses than burst, cap rates spreads steadily com- r
|
||||
|
||||
8 6 Z E L L / L U R I E R E A L E S T A T E C E N T E R
|
||||
|
||||
ingdebtspreadswerepartoforiginalpro formamodels.Thiscapratespreadcom- pressionoffsetweakcashflowsinapost- recessionary economy from 2002 to 2005, while continued compression, combined with improved cash flows, pushed property values skyward in 2006 throughmid-2007. Cap rate compression reduced the importance of the ability to add value. After all, if all you had to do to make moneywastoleveragetothehiltwhilecap ratesfell,whytakeontheextraworkand riskofattemptingtoaddvalue?Stateddif- ferently: Why print money if it is laying everywhereonthestreets? In Tables III and IV, we demonstrate thepowerofcapratecompressionviavery simple pro forma cash flow analyses that assume Year 1 NOI of $100; a going-in cap rate of 9 percent; an LTV of 70 per- cent; and an interest rate of 7 percent. Withineachfigure,wedisplaytwoscenar- ios, which vary based on NOI growth assumptions.ScenarioIassumesthatNOI growsby3percentperyear,whileScenario IIassumesavalue-addNOIgrowthof20 percentbetweenyearstwoandthree. The only other difference between TablesIIIandIVisinresidualcaprates, which are assumed to be 6 percent and 9 percent, respectively. Based on these assumptions, we calculate the equity IRRs. It is clear that cap rate compres- sion is a significant factor in driving
|
||||
ingdebtspreadswerepartoforiginalpro formamodels.Thiscapratespreadcom- pressionoffsetweakcashflowsinapost- recessionary economy from 2002 to 2005, while continued compression, combined with improved cash flows, pushed property values skyward in 2006 throughmid-2007. Cap rate compression reduced the importance of the ability to add value. After all, if all you had to do to make moneywastoleveragetothehiltwhilecap ratesfell,whytakeontheextraworkand riskofattemptingtoaddvalue?Stateddif- ferently: Why print money if it is laying everywhereonthestreets? In Tables III and IV, we demonstrate thepowerofcapratecompressionviavery simple pro forma cash flow analyses that assume Year 1 NOI of $100; a going-in cap rate of 9 percent; an LTV of 70 percent; and an interest rate of 7 percent. Withineachfigure,wedisplaytwoscenar- ios, which vary based on NOI growth assumptions.ScenarioIassumesthatNOI growsby3percentperyear,whileScenario IIassumesavalue-addNOIgrowthof20 percentbetweenyearstwoandthree. The only other difference between TablesIIIandIVisinresidualcaprates, which are assumed to be 6 percent and 9 percent, respectively. Based on these assumptions, we calculate the equity IRRs. It is clear that cap rate compression is a significant factor in driving
|
||||
|
||||
returns. That is, cap rate compression from 9 percent to 6 percent increased IRR on leveraged stabilized properties by 250 percent, to a staggering 57 per- cent. Who needs to take on value add riskatthisreturnforstabilizedassets? Intheearly1980s,moneywasmadein real estate by mastering the creation and syndication of tax gimmicks. In the late 1980s, one made money by mastering bank and S&L connections to over-lever- age.Intheearly1990s,onemademoneyin realestatebyhavingaccesstoequity—the morethebetter.Duringthelate1990s,one made money from real estate by realizing large spreads between cap rates and debt costs.And,overthepastfiveyears,theway to make money in real estate was to own realestateonahighlyleveragedbasisascap ratesplunged. Theclassicassetpricingmodelisthe capital asset pricing model (CAPM). CAPM is a simple, yet elegant, model that relates asset pricing to the risk-free rate(F),theabilityofanassettoreduce portfolio variance (B), and the expected rate of return on the market bundle of investableassets(M).CAPMisfarfrom perfect,butprovidesacrudebenchmark for asset pricing, around which discrep- ancies and novelties arise. Specifically, CAPM states that an asset’s price is set suchthattheexpectedreturnforanasset
|
||||
returns. That is, cap rate compression from 9 percent to 6 percent increased IRR on leveraged stabilized properties by 250 percent, to a staggering 57 percent. Who needs to take on value add riskatthisreturnforstabilizedassets? Intheearly1980s,moneywasmadein real estate by mastering the creation and syndication of tax gimmicks. In the late 1980s, one made money by mastering bank and S&L connections to over-leverage.Intheearly1990s,onemademoneyin realestatebyhavingaccesstoequity—the morethebetter.Duringthelate1990s,one made money from real estate by realizing large spreads between cap rates and debt costs.And,overthepastfiveyears,theway to make money in real estate was to own realestateonahighlyleveragedbasisascap ratesplunged. Theclassicassetpricingmodelisthe capital asset pricing model (CAPM). CAPM is a simple, yet elegant, model that relates asset pricing to the risk-free rate(F),theabilityofanassettoreduce portfolio variance (B), and the expected rate of return on the market bundle of investableassets(M).CAPMisfarfrom perfect,butprovidesacrudebenchmark for asset pricing, around which discrep- ancies and novelties arise. Specifically, CAPM states that an asset’s price is set suchthattheexpectedreturnforanasset
|
||||
|
||||
(R)is R=F+ β(M-F).
|
||||
R E V I E W 8 7
|
||||
|
||||
@@ -14,7 +14,7 @@ basis for such a theory is contained in the important papers of Nyquist¹ and Ha
|
||||
|
||||
1. It is practically more useful. Parameters of engineering importance such as time, bandwidth, number of relays, etc., tend to vary linearly with the logarithm of the number of possibilities. For example, adding one relay to a group doubles the number of possible states of the relays. It adds 1 to the base 2 logarithm of this number. Doubling the time roughly squares the number of possible messages, or doubles the logarithm, etc.
|
||||
2. It is nearer to our intuitive feeling as to the proper measure. This is closely related to (1) since we in- tuitively measures entities by linear comparison with common standards. One feels, for example, that two punched cards should have twice the capacity of one for information storage, and two identical channels twice the capacity of one for transmitting information.
|
||||
3. It is mathematically more suitable. Many of the limiting operations are simple in terms of the loga- rithm but would require clumsy restatement in terms of the number of possibilities. The choice of a logarithmic base corresponds to the choice of a unit for measuring information. If the
|
||||
3. It is mathematically more suitable. Many of the limiting operations are simple in terms of the logarithm but would require clumsy restatement in terms of the number of possibilities. The choice of a logarithmic base corresponds to the choice of a unit for measuring information. If the
|
||||
base 2 is used the resulting units may be called binary digits, or more briefly *bits,* a word suggested by
|
||||
|
||||
J. W. Tukey. A device with two stable positions, such as a relay or a flip-flop circuit, can store one bit of information. *N* such devices can store*N* bits, since the total number of possible states is 2
|
||||
@@ -34,8 +34,8 @@ Fig. 1 — Schematic diagram of a general communication system.
|
||||
|
||||
a decimal digit is about 3 13 bits. A digit wheel on a desk computing machine has ten stable positions and therefore has a storage capacity of one decimal digit. In analytical work where integration and differentiation are involved the base *e* is sometimes useful. The resulting units of information will be called natural units. Change from the base *a* to base *b* merely requires multiplication by log*ba*. By a communication system we will mean a system of the type indicated schematically in Fig. 1. It consists of essentially five parts:
|
||||
|
||||
1. An *information source* which produces a message or sequence of messages to be communicated to the receiving terminal. The message may be of various types: (a) A sequence of letters as in a telegraph of teletype system; (b) A single function of time *f* (*t*) as in radio or telephony; (c) A function of time and other variables as in black and white television — here the message may be thought of as a function *f* (*x*; *y*;*t*) of two space coordinates and time, the light intensity at point (*x*; *y*) and time *t* on a pickup tube plate; (d) Two or more functions of time, say *f* (*t*), *g*(*t*), *h*(*t*) — this is the case in “three- dimensional” sound transmission or if the system is intended to service several individual channels in multiplex; (e) Several functions of several variables — in color television the message consists of three functions *f* (*x*; *y*;*t*), *g*(*x*; *y*;*t*), *h*(*x*; *y*;*t*) defined in a three-dimensional continuum — we may also think of these three functions as components of a vector field defined in the region — similarly, several black and white television sources would produce “messages” consisting of a number of functions of three variables; (f) Various combinations also occur, for example in television with an associated audio channel.
|
||||
2. A *transmitter* which operates on the message in some way to produce a signal suitable for trans- mission over the channel. In telephony this operation consists merely of changing sound pressure into a proportional electrical current. In telegraphy we have an encoding operation which produces a sequence of dots, dashes and spaces on the channel corresponding to the message. In a multiplex PCM system the different speech functions must be sampled, compressed, quantized and encoded, and finally interleaved properly to construct the signal. Vocoder systems, television and frequency modulation are other examples of complex operations applied to the message to obtain the signal.
|
||||
1. An *information source* which produces a message or sequence of messages to be communicated to the receiving terminal. The message may be of various types: (a) A sequence of letters as in a telegraph of teletype system; (b) A single function of time *f* (*t*) as in radio or telephony; (c) A function of time and other variables as in black and white television — here the message may be thought of as a function *f* (*x*; *y*;*t*) of two space coordinates and time, the light intensity at point (*x*; *y*) and time *t* on a pickup tube plate; (d) Two or more functions of time, say *f* (*t*), *g*(*t*), *h*(*t*) — this is the case in “three-dimensional” sound transmission or if the system is intended to service several individual channels in multiplex; (e) Several functions of several variables — in color television the message consists of three functions *f* (*x*; *y*;*t*), *g*(*x*; *y*;*t*), *h*(*x*; *y*;*t*) defined in a three-dimensional continuum — we may also think of these three functions as components of a vector field defined in the region — similarly, several black and white television sources would produce “messages” consisting of a number of functions of three variables; (f) Various combinations also occur, for example in television with an associated audio channel.
|
||||
2. A *transmitter* which operates on the message in some way to produce a signal suitable for transmission over the channel. In telephony this operation consists merely of changing sound pressure into a proportional electrical current. In telegraphy we have an encoding operation which produces a sequence of dots, dashes and spaces on the channel corresponding to the message. In a multiplex PCM system the different speech functions must be sampled, compressed, quantized and encoded, and finally interleaved properly to construct the signal. Vocoder systems, television and frequency modulation are other examples of complex operations applied to the message to obtain the signal.
|
||||
3. The *channel* is merely the medium used to transmit the signal from transmitter to receiver. It may be a pair of wires, a coaxial cable, a band of radio frequencies, a beam of light, etc.
|
||||
4. The *receiver* ordinarily performs the inverse operation of that done by the transmitter, reconstructing the message from the signal.
|
||||
5. The *destination* is the person (or thing) for whom the message is intended. We wish to consider certain general problems involving communication systems. To do this it is first
|
||||
|
||||
@@ -77,6 +77,50 @@ 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Generated
+2
-2
@@ -724,7 +724,7 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||
|
||||
[[package]]
|
||||
name = "pdf-inspector"
|
||||
version = "1.14.0"
|
||||
version = "1.15.0"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"include_dir",
|
||||
@@ -740,7 +740,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdf-inspector-wasm"
|
||||
version = "1.14.0"
|
||||
version = "1.15.0"
|
||||
dependencies = [
|
||||
"console_error_panic_hook",
|
||||
"js-sys",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "pdf-inspector-wasm"
|
||||
version = "1.14.0"
|
||||
version = "1.15.0"
|
||||
edition = "2021"
|
||||
authors = ["Firecrawl Team"]
|
||||
description = "Browser WebAssembly bindings for pdf-inspector"
|
||||
|
||||
Reference in New Issue
Block a user