Compare commits

..
Author SHA1 Message Date
Abimael Martell cabc108d5c test: add crop bbox plausibility coverage
Cover in-crop, out-of-crop, slack-boundary, and non-positive DPI behavior for vector grid cell bbox validation.

Made-with: Cursor
2026-04-28 17:14:17 -07:00
Abimael Martell 860328bd74 fix: address vector grid review feedback
Return null for rotated vector grids until the coordinate transform has coverage and reject out-of-crop cell boxes surfaced by real-PDF smoke testing.

Made-with: Cursor
2026-04-28 16:56:10 -07:00
Abimael Martell 0674ba5155 feat: add vector grid region detector napi export
Expose region-scoped vector PDF grid detection so TSR callers can reuse native geometry before model fallback.

Made-with: Cursor
2026-04-28 16:38:11 -07:00
113 changed files with 1389 additions and 43619 deletions
+39 -63
View File
@@ -14,59 +14,64 @@ jobs:
name: Test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable
with:
toolchain: stable
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
- name: Check package version sync
run: python3 scripts/version.py --check
uses: actions/cache@v4
with:
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-
- name: Run tests
run: cargo test --verbose
- name: Test developer scripts
run: python3 -m unittest discover -s scripts/tests
fmt:
name: Format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
components: rustfmt
- name: Check formatting
run: cargo fmt --all -- --check
- name: Check WASM formatting
run: cargo fmt --manifest-path wasm/Cargo.toml -- --check
clippy:
name: Clippy
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable
uses: dtolnay/rust-toolchain@stable
with:
toolchain: stable
components: clippy
- name: Cache cargo
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
uses: actions/cache@v4
with:
key: clippy
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-clippy-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-clippy-
- name: Run clippy
run: cargo clippy -- -D warnings
@@ -78,52 +83,23 @@ jobs:
matrix:
os: [ubuntu-latest, macos-latest]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable
with:
toolchain: stable
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
uses: actions/cache@v4
with:
key: build
path: |
~/.cargo/bin/
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
target/
key: ${{ runner.os }}-cargo-build-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-build-
- name: Build
run: cargo build --release --verbose
wasm:
name: WebAssembly
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Rust
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable
with:
toolchain: stable
targets: wasm32-unknown-unknown
components: clippy
- name: Cache cargo
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: |
wasm -> target
key: wasm
- name: Check WebAssembly bindings
run: cargo check --manifest-path wasm/Cargo.toml --target wasm32-unknown-unknown
- name: Check root package for WebAssembly
run: cargo check --target wasm32-unknown-unknown
- name: Lint WebAssembly bindings
run: cargo clippy --manifest-path wasm/Cargo.toml --target wasm32-unknown-unknown -- -D warnings
- name: Install wasm-pack
run: cargo install wasm-pack --version 0.15.0 --locked
- name: Test WebAssembly package
run: wasm-pack test --node --release wasm
-36
View File
@@ -1,36 +0,0 @@
name: Deploy landing page
on:
push:
branches: [main]
paths: ['site/**', '.github/workflows/pages.yml']
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
# Allow one concurrent deployment; don't cancel an in-progress production deploy.
concurrency:
group: pages
cancel-in-progress: false
jobs:
deploy:
name: Build & deploy to GitHub Pages
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Upload site artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
with:
path: site
- name: Deploy to GitHub Pages
id: deploy
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
-108
View File
@@ -1,108 +0,0 @@
name: Publish Rust crate
on:
push:
branches: [main]
paths: ['Cargo.toml']
# Manual fallback: retry a publish that failed after the version was
# already merged (a plain re-push won't register as a version change).
workflow_dispatch:
permissions:
contents: read
env:
CARGO_TERM_COLOR: always
jobs:
check-version:
name: Check version change
# Guard manual dispatches: crates.io trusted publishing matches
# repo+workflow+environment but NOT branch, so without this a
# workflow_dispatch from any branch could publish unmerged code.
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.check.outputs.changed }}
published: ${{ steps.check.outputs.published }}
version: ${{ steps.check.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 2
- name: Check package version sync
run: python3 scripts/version.py --check
- name: Check if version changed
id: check
run: |
NEW_VERSION=$(python3 -c 'import pathlib, tomllib; print(tomllib.loads(pathlib.Path("Cargo.toml").read_text())["package"]["version"])')
echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
# Manual dispatch publishes the current version regardless of the
# previous commit; the crates.io check below still prevents
# double-publishing an already-released version.
echo "manual dispatch: publishing v$NEW_VERSION"
echo "changed=true" >> "$GITHUB_OUTPUT"
else
OLD_VERSION=$(git show HEAD~1:Cargo.toml | python3 -c 'import sys, tomllib; print(tomllib.loads(sys.stdin.read())["package"]["version"])')
echo "old=$OLD_VERSION new=$NEW_VERSION"
if [ "$NEW_VERSION" = "$OLD_VERSION" ]; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "published=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
HTTP_STATUS=$(curl --silent --show-error --output /tmp/crate-version.json --write-out "%{http_code}" \
-H "User-Agent: firecrawl/pdf-inspector publish workflow (https://github.com/firecrawl/pdf-inspector)" \
"https://crates.io/api/v1/crates/pdf-inspector/$NEW_VERSION")
case "$HTTP_STATUS" in
200)
echo "published=true" >> "$GITHUB_OUTPUT"
echo "pdf-inspector v$NEW_VERSION is already published"
;;
404)
echo "published=false" >> "$GITHUB_OUTPUT"
;;
*)
cat /tmp/crate-version.json
echo "Unexpected crates.io response: $HTTP_STATUS" >&2
exit 1
;;
esac
publish:
name: Publish to crates.io
needs: check-version
if: needs.check-version.outputs.changed == 'true' && needs.check-version.outputs.published == 'false'
runs-on: ubuntu-latest
environment: crates-io
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Rust
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable
with:
toolchain: stable
- name: Verify package
run: cargo publish --dry-run
- name: Authenticate with crates.io
id: auth
uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5
- name: Publish crate
run: cargo publish
env:
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
-169
View File
@@ -1,169 +0,0 @@
name: Publish Python package
on:
push:
branches: [main]
paths: ['pyproject.toml']
# Manual fallback: re-publish the current version without a version bump
# (e.g. first run after PyPI trusted publishing is configured).
workflow_dispatch:
permissions:
contents: read
jobs:
check-version:
name: Check version change
# Guard manual dispatches too: PyPI trusted publishing matches
# repo+workflow+environment but NOT branch, so without this a
# workflow_dispatch from any branch could publish unmerged code.
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.check.outputs.changed }}
published: ${{ steps.check.outputs.published }}
version: ${{ steps.check.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 2
- name: Check package version sync
run: python3 scripts/version.py --check
- name: Check if version changed
id: check
run: |
NEW_VERSION=$(python3 -c 'import pathlib, tomllib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')
echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
# Manual dispatch always rebuilds and publishes. Combined with
# skip-existing on the publish step, this repairs partial releases
# (PyPI's version endpoint returns 200 even when only some of the
# expected wheels were uploaded).
echo "manual dispatch: publishing v$NEW_VERSION (skip-existing handles uploaded files)"
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "published=false" >> "$GITHUB_OUTPUT"
exit 0
fi
# .get(): the parent commit may predate the static version field
# (pyproject.toml used dynamic = ["version"]) — treat that as a change
# so the very first merge of this workflow publishes.
OLD_VERSION=$(git show HEAD~1:pyproject.toml | python3 -c 'import sys, tomllib; print(tomllib.loads(sys.stdin.read())["project"].get("version", ""))')
echo "old=$OLD_VERSION new=$NEW_VERSION"
if [ "$NEW_VERSION" = "$OLD_VERSION" ]; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "published=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "changed=true" >> "$GITHUB_OUTPUT"
HTTP_STATUS=$(curl --silent --show-error --output /tmp/pypi-version.json --write-out "%{http_code}" \
"https://pypi.org/pypi/pdf-inspector/$NEW_VERSION/json")
case "$HTTP_STATUS" in
200)
echo "published=true" >> "$GITHUB_OUTPUT"
echo "pdf-inspector v$NEW_VERSION is already published to PyPI"
;;
404)
echo "published=false" >> "$GITHUB_OUTPUT"
;;
*)
cat /tmp/pypi-version.json
echo "Unexpected PyPI response: $HTTP_STATUS" >&2
exit 1
;;
esac
build:
needs: check-version
if: needs.check-version.outputs.changed == 'true' && needs.check-version.outputs.published == 'false'
name: Build ${{ matrix.target }}
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
# macos-13 was retired by GitHub; macos-15-intel is the remaining
# Intel runner label (available through 2027).
- os: macos-15-intel
target: x86_64-apple-darwin
- os: macos-14
target: aarch64-apple-darwin
- os: windows-latest
target: x86_64-pc-windows-msvc
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- name: Build wheel
uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
with:
target: ${{ matrix.target }}
args: --release --out dist
manylinux: auto
- name: Upload wheel
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: wheels-${{ matrix.target }}
path: dist/*.whl
if-no-files-found: error
sdist:
needs: check-version
if: needs.check-version.outputs.changed == 'true' && needs.check-version.outputs.published == 'false'
name: Build sdist
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Build sdist
uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
with:
command: sdist
args: --out dist
- name: Upload sdist
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: sdist
path: dist/*.tar.gz
if-no-files-found: error
publish:
name: Publish to PyPI
needs: [check-version, build, sdist]
runs-on: ubuntu-latest
environment: pypi
permissions:
contents: read
id-token: write
steps:
- name: Download all artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: dist
merge-multiple: true
- name: List artifacts
run: ls -la dist/
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
with:
packages-dir: dist
# Tolerate already-uploaded files so a manual re-run can complete
# a release that previously failed partway through.
skip-existing: true
-115
View File
@@ -1,115 +0,0 @@
name: Publish WebAssembly package
on:
push:
branches: [main]
paths: ['wasm/Cargo.toml']
workflow_dispatch:
permissions:
contents: read
id-token: write
env:
CARGO_TERM_COLOR: always
jobs:
check-version:
name: Check version change
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.check.outputs.changed }}
package_exists: ${{ steps.check.outputs.package_exists }}
published: ${{ steps.check.outputs.published }}
version: ${{ steps.check.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 2
- name: Check package version sync
run: python3 scripts/version.py --check
- name: Check package version
id: check
run: |
NEW_VERSION=$(python3 -c 'import pathlib, tomllib; print(tomllib.loads(pathlib.Path("wasm/Cargo.toml").read_text())["package"]["version"])')
echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
elif git cat-file -e HEAD~1:wasm/Cargo.toml 2>/dev/null; then
OLD_VERSION=$(git show HEAD~1:wasm/Cargo.toml | python3 -c 'import sys, tomllib; print(tomllib.loads(sys.stdin.read())["package"]["version"])')
if [ "$NEW_VERSION" = "$OLD_VERSION" ]; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "published=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "changed=true" >> "$GITHUB_OUTPUT"
else
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
if ! npm view "@firecrawl/pdf-inspector-wasm" name >/dev/null 2>&1; then
echo "package_exists=false" >> "$GITHUB_OUTPUT"
echo "published=false" >> "$GITHUB_OUTPUT"
echo "The initial package must be published once before trusted publishing can be configured."
exit 0
fi
echo "package_exists=true" >> "$GITHUB_OUTPUT"
if npm view "@firecrawl/pdf-inspector-wasm@$NEW_VERSION" version >/dev/null 2>&1; then
echo "published=true" >> "$GITHUB_OUTPUT"
else
echo "published=false" >> "$GITHUB_OUTPUT"
fi
publish:
name: Build and publish
needs: check-version
if: needs.check-version.outputs.changed == 'true' && needs.check-version.outputs.package_exists == 'true' && needs.check-version.outputs.published == 'false'
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install Rust
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable
with:
toolchain: stable
targets: wasm32-unknown-unknown
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
- name: Install wasm-pack
run: cargo install wasm-pack --version 0.15.0 --locked
- name: Build browser package
run: wasm-pack build wasm --target web --scope firecrawl --out-dir pkg --release
- name: Prepare package metadata
run: |
node -e '
const fs = require("fs")
const path = "wasm/pkg/package.json"
const pkg = JSON.parse(fs.readFileSync(path, "utf8"))
pkg.name = "@firecrawl/pdf-inspector-wasm"
pkg.description = "Browser WebAssembly bindings for the pdf-inspector Rust PDF parser"
pkg.keywords = ["pdf", "pdf-parser", "webassembly", "wasm", "markdown", "rust", "firecrawl"]
pkg.repository = { type: "git", url: "https://github.com/firecrawl/pdf-inspector" }
pkg.homepage = "https://github.com/firecrawl/pdf-inspector/tree/main/wasm"
pkg.publishConfig = { access: "public" }
fs.writeFileSync(path, JSON.stringify(pkg, null, 2) + "\n")
'
- name: Inspect package contents
run: npm pack --dry-run ./wasm/pkg
- name: Publish package
run: npm publish ./wasm/pkg --provenance --access public
+19 -184
View File
@@ -4,9 +4,6 @@ on:
push:
branches: [main]
paths: ['napi/package.json']
# Manual fallback: retry a publish that failed partway (per-package
# already-published checks make re-runs idempotent).
workflow_dispatch:
permissions:
contents: read
@@ -15,41 +12,24 @@ permissions:
jobs:
check-version:
name: Check version change
# Guard manual dispatches: npm trusted publishing matches
# repo+workflow+environment but NOT branch, so without this a
# workflow_dispatch from any branch could publish unmerged code.
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.check.outputs.changed }}
version: ${{ steps.check.outputs.version }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@v4
with:
fetch-depth: 2
- name: Check package version sync
run: python3 scripts/version.py --check
- name: Check if version changed
id: check
run: |
NEW_VERSION=$(node -p "require('./napi/package.json').version")
echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
# Manual dispatch rebuilds and publishes the current version; the
# per-package already-published checks in the publish job skip
# anything that made it out in a previous partial run.
echo "manual dispatch: publishing v$NEW_VERSION"
echo "changed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
OLD_VERSION=$(git show HEAD~1:napi/package.json | node -p "JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')).version")
echo "old=$OLD_VERSION new=$NEW_VERSION"
if [ "$NEW_VERSION" != "$OLD_VERSION" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
@@ -64,61 +44,31 @@ jobs:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
# napi-cross builds gnu targets against an old glibc sysroot for
# broad distro compatibility; musl targets cross-compile with
# zig via cargo-zigbuild (napi's -x flag).
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
build-flags: --use-napi-cross
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
build-flags: -x
- os: ubuntu-latest
target: aarch64-unknown-linux-musl
build-flags: -x
- os: macos-14
target: aarch64-apple-darwin
- os: windows-latest
target: x86_64-pc-windows-msvc
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@v4
- name: Install Rust
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable
with:
toolchain: stable
targets: ${{ matrix.target }}
uses: dtolnay/rust-toolchain@stable
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
- uses: oven-sh/setup-bun@v2
with:
bun-version: latest
- name: Install zig
if: contains(matrix.target, 'musl')
uses: mlugg/setup-zig@d1434d08867e3ee9daa34448df10607b98908d29 # v2.2.1
with:
version: 0.14.1
- name: Install cargo-zigbuild
if: contains(matrix.target, 'musl')
uses: taiki-e/install-action@67729d5c413db75907f0ad1e39bb04b9c868ff60 # v2.85.7
env:
GITHUB_TOKEN: ${{ github.token }}
with:
tool: cargo-zigbuild
- name: Cache cargo
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@v4
with:
path: |
~/.cargo/registry/index/
~/.cargo/registry/cache/
~/.cargo/git/db/
~/.napi-rs
napi/target/
key: ${{ matrix.target }}-cargo-napi-${{ hashFiles('**/Cargo.lock') }}
key: ${{ runner.os }}-cargo-napi-${{ hashFiles('**/Cargo.lock') }}
restore-keys: |
${{ matrix.target }}-cargo-napi-
${{ runner.os }}-cargo-napi-
- name: Install dependencies
working-directory: napi
@@ -126,10 +76,10 @@ jobs:
- name: Build native addon
working-directory: napi
run: bunx napi build --platform --release --target ${{ matrix.target }} ${{ matrix.build-flags }}
run: bunx napi build --platform --release
- name: Upload native binary
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@v4
with:
name: bindings-${{ matrix.target }}
path: napi/*.node
@@ -137,7 +87,7 @@ jobs:
- name: Upload generated JS bindings
if: matrix.target == 'x86_64-unknown-linux-gnu'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@v4
with:
name: js-bindings
path: |
@@ -145,149 +95,34 @@ jobs:
napi/index.d.ts
if-no-files-found: error
smoke-test:
name: Smoke test ${{ matrix.target }}
needs: [check-version, build]
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
- os: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
- os: macos-14
target: aarch64-apple-darwin
- os: windows-latest
target: x86_64-pc-windows-msvc
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Download native binary
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: bindings-${{ matrix.target }}
path: napi
- name: Download generated JS bindings
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: js-bindings
path: napi
# musl binaries must load under a real musl libc, so run inside Alpine.
- name: Run smoke test (Alpine)
if: contains(matrix.target, 'musl')
run: docker run --rm -v "$PWD:/repo" -w /repo/napi node:24-alpine node test.mjs
- name: Run smoke test
if: ${{ !contains(matrix.target, 'musl') }}
working-directory: napi
run: node test.mjs
publish:
name: Publish to npm
needs: [check-version, build, smoke-test]
needs: [check-version, build]
runs-on: ubuntu-latest
permissions:
contents: read
id-token: write
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/checkout@v6
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
- uses: actions/setup-node@v6
with:
node-version: '24'
registry-url: 'https://registry.npmjs.org'
- name: Download all artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
uses: actions/download-artifact@v4
with:
path: napi/artifacts
- name: Publish platform packages
- name: Collect binaries and publish
working-directory: napi
run: |
VERSION="${{ needs.check-version.outputs.version }}"
for node_file in artifacts/bindings-*/pdf-inspector.*.node; do
base=$(basename "$node_file")
suffix=${base#pdf-inspector.}
suffix=${suffix%.node}
pkg="@firecrawl/pdf-inspector-$suffix"
if npm view "$pkg@$VERSION" version >/dev/null 2>&1; then
echo "$pkg@$VERSION already published — skipping"
continue
fi
dir="npm-dist/$suffix"
mkdir -p "$dir"
cp "$node_file" "$dir/"
node -e '
const [suffix, version] = process.argv.slice(1)
const meta = {
"linux-x64-gnu": { os: ["linux"], cpu: ["x64"], libc: ["glibc"] },
"linux-x64-musl": { os: ["linux"], cpu: ["x64"], libc: ["musl"] },
"linux-arm64-gnu": { os: ["linux"], cpu: ["arm64"], libc: ["glibc"] },
"linux-arm64-musl": { os: ["linux"], cpu: ["arm64"], libc: ["musl"] },
"darwin-arm64": { os: ["darwin"], cpu: ["arm64"] },
"win32-x64-msvc": { os: ["win32"], cpu: ["x64"] },
}[suffix]
if (!meta) {
console.error(`unknown platform suffix: ${suffix} — add it to the meta map`)
process.exit(1)
}
const pkg = {
name: `@firecrawl/pdf-inspector-${suffix}`,
version,
description: `Prebuilt ${suffix} binary for @firecrawl/pdf-inspector`,
main: `pdf-inspector.${suffix}.node`,
files: [`pdf-inspector.${suffix}.node`],
license: "MIT",
engines: { node: ">= 10" },
repository: { type: "git", url: "https://github.com/firecrawl/pdf-inspector" },
publishConfig: { access: "public" },
...meta,
}
require("fs").writeFileSync(`npm-dist/${suffix}/package.json`, JSON.stringify(pkg, null, 2) + "\n")
' "$suffix" "$VERSION"
echo "=== $pkg@$VERSION ==="
ls -la "$dir"
(cd "$dir" && npm publish --provenance --access public)
done
- name: Publish main package
working-directory: napi
run: |
VERSION="${{ needs.check-version.outputs.version }}"
if npm view "@firecrawl/pdf-inspector@$VERSION" version >/dev/null 2>&1; then
echo "@firecrawl/pdf-inspector@$VERSION already published — skipping"
exit 0
fi
cp artifacts/bindings-*/*.node .
cp artifacts/js-bindings/index.js .
cp artifacts/js-bindings/index.d.ts .
# Stamp optionalDependencies to this exact version so the platform
# pins can never drift from the main package version.
node -e '
const fs = require("fs")
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"))
for (const dep of Object.keys(pkg.optionalDependencies ?? {})) {
pkg.optionalDependencies[dep] = pkg.version
}
fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2) + "\n")
'
echo "=== Main package contents ==="
npm pack --dry-run
echo "=== Package contents ==="
ls -la *.node index.js index.d.ts
npm publish --provenance --access public
+3 -5
View File
@@ -1,13 +1,10 @@
# Rust build artifacts
/target/
/wasm/target/
/wasm/pkg/
debug/
*.pdb
# Cargo lock (optional for libraries)
Cargo.lock
!/wasm/Cargo.lock
# IDE
.idea/
@@ -31,14 +28,15 @@ Thumbs.db
napi/index.js
napi/index.d.ts
# Local samples
# Local samples and scripts
samples/
scripts/
# Test output
test_output/
.firecrawl/
# Python
__pycache__/
*.pyc
.pytest_cache/
+1 -2
View File
@@ -61,8 +61,7 @@ src/
- **Unit tests**: inline `#[cfg(test)] mod tests` in each module with synthetic data.
- **Integration tests**: `tests/integration_tests.rs` with fixture PDFs in `tests/fixtures/`.
- **Regression suite**: sibling repo `pdf-evals` with ~200 snapshot PDFs. Run `cargo build --release` then `bench.py test` in that repo before committing. While iterating, prefer a subset run (`bench.py test -q` for the quick set, or `-s <name>` for a named test set) and save the full `bench.py test` for the final pre-commit check.
- **Semantic quality**: run `bench.py score` in `pdf-evals` for the semantic verdict (TEDS + MHS + reading order + char/word + list preservation, composited). Character-level diff alone misclassifies structural improvements (e.g., column-detection rewrites) as regressions — `score` is the tie-breaker. See `pdf-evals/CLAUDE.md` "Semantic scoring".
- **Regression suite**: sibling repo `pdf-evals` with 179+ snapshot PDFs. Run `cargo build --release` then `bench.py test` in that repo before committing.
## Debugging
+1 -1
View File
@@ -61,7 +61,7 @@ src/
- **Unit tests**: inline `#[cfg(test)] mod tests` in each module with synthetic data.
- **Integration tests**: `tests/integration_tests.rs` with fixture PDFs in `tests/fixtures/`.
- **Regression suite**: sibling repo `pdf-evals` with ~200 snapshot PDFs. Run `cargo build --release` then `bench.py test` in that repo before committing. While iterating, prefer a subset run (`bench.py test -q` for the quick set, or `-s <name>` for a named test set) and save the full `bench.py test` for the final pre-commit check.
- **Regression suite**: sibling repo `pdf-evals` with 187+ snapshot PDFs. Run `cargo build --release` then `bench.py test` in that repo before committing.
- **Semantic quality**: run `bench.py score` in `pdf-evals` for the semantic verdict (TEDS + MHS + reading order + char/word + list preservation, composited). Character-level diff alone misclassifies structural improvements (e.g., column-detection rewrites) as regressions — `score` is the tie-breaker. See `pdf-evals/CLAUDE.md` "Semantic scoring".
## Debugging
+9 -55
View File
@@ -1,25 +1,12 @@
[package]
name = "pdf-inspector"
version = "1.14.2"
version = "0.1.0"
edition = "2021"
autobins = false
authors = ["Firecrawl Team"]
description = "Fast PDF inspection, classification, and text extraction with smart scanned vs text-based detection"
license = "MIT"
repository = "https://github.com/firecrawl/pdf-inspector"
readme = "docs/rust-api.md"
# Explicit allowlist: crates.io caps uploads at 10 MiB and tests/fixtures
# alone exceeds that. external/bcmaps ships in the crate — tounicode.rs
# loads it at runtime relative to CARGO_MANIFEST_DIR.
include = [
"/src/**",
"/external/bcmaps/**",
"/docs/rust-api.md",
"/LICENSE",
# maturin derives the sdist file list from this allowlist; the stub must
# ship so wheels built from the sdist keep their type hints.
"/pdf_inspector.pyi",
]
[lib]
name = "pdf_inspector"
@@ -27,13 +14,20 @@ crate-type = ["lib", "cdylib"]
[dependencies]
# Python bindings
pyo3 = { version = "0.25", features = ["extension-module", "abi3-py38"], optional = true }
pyo3 = { version = "0.25", features = ["extension-module"], optional = true }
# PDF parsing
lopdf = { git = "https://github.com/J-F-Liu/lopdf", rev = "7a05512d831415b1f2b1ce522391d6beab8a1284", features = ["rayon"] }
# Error handling
thiserror = "2.0"
# Parallel processing
rayon = "1.10"
# Logging
log = "0.4"
env_logger = "0.11"
# Text processing
regex = "1.10"
@@ -43,52 +37,12 @@ unicode-normalization = "0.1"
# TrueType font parsing (for Identity-H CID font cmap extraction)
ttf-parser = "0.25"
# Native builds keep lopdf's parallel parser and CLI logging. Browser WASM is
# deliberately single-threaded so it works without cross-origin isolation.
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
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.
[target.'cfg(target_arch = "wasm32")'.dependencies]
lopdf = { version = "0.42.0", default-features = false, features = ["wasm_js"] }
include_dir = "0.7"
[dev-dependencies]
tempfile = "3.3"
[features]
default = []
python = ["pyo3"]
vision = []
model-cache = ["vision", "dep:dirs", "dep:fs2", "dep:sha2", "dep:windows-sys"]
model-download = ["model-cache", "dep:ureq"]
ocr-oar = ["model-cache", "dep:image", "dep:oar-ocr", "dep:ort"]
render-pdfium = ["vision", "dep:firecrawl-pdfium"]
# Complete native OCR path. This remains opt-in so default library,
# renderer-only, and browser consumers do not inherit inference or HTTP/TLS.
ocr = ["render-pdfium", "ocr-oar", "model-download"]
[[bin]]
name = "pdf2md"
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2026 Firecrawl
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+20 -69
View File
@@ -1,11 +1,6 @@
# pdf-inspector
[![Crates.io](https://img.shields.io/crates/v/pdf-inspector.svg)](https://crates.io/crates/pdf-inspector)
[![npm](https://img.shields.io/npm/v/@firecrawl/pdf-inspector.svg)](https://www.npmjs.com/package/@firecrawl/pdf-inspector)
[![PyPI](https://img.shields.io/pypi/v/pdf-inspector.svg)](https://pypi.org/project/pdf-inspector/)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
Fast Rust library for PDF classification and text extraction. Detects whether a PDF is text-based or scanned, extracts text with position awareness, and converts to clean Markdown — all without OCR. Includes bindings for [Python](docs/python.md), [Node.js](napi/README.md), and [browser WebAssembly](wasm/README.md).
Fast Rust library for PDF classification and text extraction. 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) and [Node.js](napi/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.
@@ -19,28 +14,24 @@ Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in
- **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.
- **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.
## Benchmark
Evaluated on the [opendataloader-bench](https://github.com/opendataloader-project/opendataloader-bench) corpus (200 PDFs). Only local engines without model-based PDF parsing are shown; OCR was disabled. Scores are 0-1, higher is better.
Evaluated on the [opendataloader-bench](https://github.com/opendataloader-project/opendataloader-bench) corpus (200 PDFs). Only direct text extraction engines are shown — no OCR, no ML models. Scores are 0-1, higher is better.
| Engine | Overall | Reading Order (NID) | Tables (TEDS) | Headings (MHS) | Speed (200 docs) |
|---|---|---|---|---|---|
| pdf-inspector | **0.875** | **0.915** | **0.814** | 0.788 | **0.470s** |
| liteparse | 0.873 | 0.913 | 0.693 | **0.811** | 0.750s |
| opendataloader | 0.831 | 0.902 | 0.489 | 0.739 | 2.569s |
| pymupdf4llm | 0.735 | 0.886 | 0.401 | 0.424 | 17.117s |
| markitdown | 0.589 | 0.844 | 0.273 | 0.000 | 16.165s |
| pdf-inspector | 0.78 | 0.87 | 0.59 | 0.57 | 4s |
| opendataloader | 0.84 | 0.91 | 0.49 | 0.74 | 11s |
| pymupdf4llm | 0.73 | 0.89 | 0.40 | 0.41 | 18s |
| markitdown | 0.58 | 0.88 | 0.00 | 0.00 | 8s |
Results were refreshed on July 31, 2026, on an Apple M4 Pro. Engine versions were pdf-inspector 0.2.6, LiteParse 2.10.1, OpenDataLoader 2.2.1, PyMuPDF4LLM 0.2.0, and MarkItDown 0.1.5. Speed is the median of five alternating or rotating complete corpus runs after an excluded warm-up run, with each parser processing documents sequentially in a single process.
For context, engines that use OCR/ML (docling, marker, mineru) score 0.83-0.88 overall but take 2-180 minutes on the same corpus.
The complete parser configuration, per-document predictions, evaluator output, and generated charts are available in the [reproducible results branch](https://github.com/firecrawl/opendataloader-bench/tree/abi/pdf-parser-benchmark-results).
**Where we do well:** Speed (fastest of all engines), reading order, table detection vs other direct-text tools.
**Best fit:** Native-text PDFs where speed, reading order, and table structure matter. In this comparison, pdf-inspector delivered the higher overall, reading-order, and table scores, along with the fastest complete run. That makes it a strong local default for reports, research papers, financial documents, invoices, and legal PDFs that need clean, structured Markdown without adding OCR latency or infrastructure.
Use the [paired benchmark harness](docs/benchmarking.md) to compare two local builds against the exact same corpus and evaluator revision.
**Where we lag:** Heading detection trails opendataloader — many PDFs use bold text at body font size for headings, or headings that are only slightly larger than body text. Table detection trails OCR-based engines that can see visual table structure.
## Quick start
@@ -78,39 +69,11 @@ console.log(result.markdown); // Markdown string or null
> Full API reference: [napi/README.md](napi/README.md)
### Browser WebAssembly
```bash
npm install @firecrawl/pdf-inspector-wasm
```
```javascript
import init, { processPdf } from '@firecrawl/pdf-inspector-wasm';
await init();
const response = await fetch('/document.pdf');
const pdf = new Uint8Array(await response.arrayBuffer());
const result = processPdf(pdf);
console.log(result.pdfType);
console.log(result.markdown);
```
> Full API reference: [wasm/README.md](wasm/README.md)
### Rust
Install from [crates.io](https://crates.io/crates/pdf-inspector):
```bash
cargo add pdf-inspector
```
Or add it manually:
```toml
[dependencies]
pdf-inspector = "1"
pdf-inspector = { git = "https://github.com/firecrawl/pdf-inspector" }
```
```rust
@@ -128,40 +91,29 @@ if let Some(markdown) = &result.markdown {
### CLI
```bash
# Install the CLI tools
cargo install pdf-inspector
# Convert PDF to Markdown
pdf2md document.pdf
cargo run --bin pdf2md -- document.pdf
# JSON output (for piping)
pdf2md document.pdf --json
# Positioned TextItem JSON, including is_underline metadata
pdf2md document.pdf --items-json
cargo run --bin pdf2md -- document.pdf --json
# Raw markdown only (no headers)
pdf2md document.pdf --raw
# Token-efficient output (collapses long dot leaders and similar source padding)
pdf2md document.pdf --compact
cargo run --bin pdf2md -- document.pdf --raw
# Insert page break markers (<!-- Page N -->)
pdf2md document.pdf --pages
cargo run --bin pdf2md -- document.pdf --pages
# Process only specific pages
pdf2md document.pdf --select-pages 1,3,5-10
cargo run --bin pdf2md -- document.pdf --select-pages 1,3,5-10
# Detection only (no extraction)
detect-pdf document.pdf
detect-pdf document.pdf --json
cargo run --bin detect-pdf -- document.pdf
cargo run --bin detect-pdf -- document.pdf --json
# Detection + layout analysis (tables, columns)
detect-pdf document.pdf --analyze --json
cargo run --bin detect-pdf -- document.pdf --analyze --json
```
From a source checkout, use `cargo run --bin pdf2md -- document.pdf` or `cargo run --bin detect-pdf -- document.pdf` instead.
## Architecture
```
@@ -209,7 +161,6 @@ src/
markdown/ — Markdown conversion and structure detection
bin/ — CLI tools (pdf2md, detect_pdf)
napi/ — Node.js/Bun bindings (napi-rs)
wasm/ — Browser bindings (wasm-bindgen)
```
## How classification works
@@ -238,7 +189,7 @@ The converter handles:
|---|---|
| Headings (H1-H4) | Font size tiers relative to body text, with 0.5pt clustering |
| Bold/italic | Font name patterns (Bold, Italic, Oblique) |
| Bullet lists | ``, `-`, `*`, `○`, `●`, `◦` prefixes |
| Bullet lists | `*`, `-`, `*`, `○`, `●`, `◦` prefixes |
| Numbered lists | `1.`, `1)`, `(1)` patterns |
| Letter lists | `a.`, `a)`, `(a)` patterns |
| Code blocks | Monospace fonts (Courier, Consolas, Monaco, Menlo, Fira Code, JetBrains Mono) and keyword detection |
@@ -272,4 +223,4 @@ See [docs/debugging.md](docs/debugging.md) for `RUST_LOG` environment variable u
## License
[MIT](LICENSE)
MIT
-34
View File
@@ -1,34 +0,0 @@
# Security Policy
## Reporting a Vulnerability
If you believe you've found a security vulnerability in pdf-inspector, please
report it privately so we can fix it before public disclosure.
**Preferred:** Submit through Firecrawl's Bugcrowd vulnerability disclosure
program at <https://bugcrowd.com/engagements/firecrawl-vdp-ess>. Please include:
- A description of the issue and its impact
- Steps to reproduce (a minimal PDF or input that triggers the bug is ideal)
- The version or commit hash of pdf-inspector you tested against
**Alternative:** If you'd rather not use Bugcrowd, email
**help@firecrawl.dev** with the same details.
We'll acknowledge your report in a timely manner and keep you updated on
remediation progress. Please do not open a public GitHub issue for security
bugs.
## Scope
In scope:
- Memory-safety issues (panics, OOB reads, UB) reachable from a crafted PDF
- Denial-of-service vectors (unbounded allocation, infinite loops) on
reasonably-sized inputs
- Bugs in the `pdf2md` / `detect-pdf` binaries or the `pdf-inspector` crate
that affect downstream consumers
Out of scope:
- Bugs in upstream dependencies (`lopdf`, etc.) — please report those upstream
- Extraction quality issues (wrong text, missing tables) — open a regular
GitHub issue instead
-62
View File
@@ -1,62 +0,0 @@
# Benchmarking against OpenDataLoader
The paired harness runs two `pdf2md` binaries through the same local
OpenDataLoader corpus, evaluates both outputs, and reports aggregate and
per-document deltas. This avoids comparing results produced from different
corpus revisions or evaluator versions.
Build a candidate and provide a released or worktree build as the baseline:
```bash
cargo build --release
python3 scripts/bench_opendataloader.py \
--bench-dir ../opendataloader-bench \
--baseline ../pdf-inspector-main/target/release/pdf2md \
--candidate target/release/pdf2md \
--max-document-regression 0.02 \
--json-output /tmp/pdf-inspector-benchmark.json
```
Pass `--reference-evaluation path/to/evaluation.json` to report the candidate
delta against another evaluation, and add `--require-reference-lead` to make a
negative reference delta fail the run. By default, the candidate must not
regress the baseline overall score or introduce missing predictions. Use
`--min-overall-delta` to require a specific aggregate gain.
The OpenDataLoader repository is external and keeps its normal
`prediction/pdf-inspector` output. Paired evaluation copies each run into a
temporary directory before evaluating it, so the baseline and candidate cannot
overwrite one another.
## Published comparison protocol
The public benchmark table was refreshed on July 31, 2026, on an Apple M4 Pro
using pdf-inspector 0.2.6, LiteParse 2.10.1, OpenDataLoader 2.2.1,
PyMuPDF4LLM 0.2.0, and MarkItDown 0.1.5. Every engine processed the same 200
PDFs sequentially in a single process with OCR disabled. Reported speed is the
median of five alternating or rotating complete corpus runs after an excluded
warm-up run; quality scores come from the benchmark evaluator over all 200
outputs. Raw timings, predictions, evaluations, and charts are available in the
[results branch](https://github.com/firecrawl/opendataloader-bench/tree/abi/pdf-parser-benchmark-results).
## Optional backend evidence probe
The evidence probe compares positioned `pdf2md` items with MuPDF structured
text on the same pages. It is intended to find deterministic extraction or
layout evidence that could justify a future native implementation; it does not
merge MuPDF output into Markdown, invoke OCR, or add a runtime dependency.
Install MuPDF's `mutool`, build `pdf2md`, then run:
```bash
python3 scripts/probe_backend_evidence.py document.pdf \
--pdf2md target/release/pdf2md \
--json-output /tmp/backend-evidence.json
```
The report flags pages when MuPDF exposes a material net token gain, repeated
alignment anchors absent from local evidence, or additional image blocks. The
JSON includes bounded token samples and page-level counts so promising cases
can be inspected without treating backend disagreement as automatically
correct. Thresholds are configurable with `--min-token-gain`,
`--min-alternate-only-ratio`, and `--min-anchor-gain`.
-57
View File
@@ -1,57 +0,0 @@
# Publishing
Every pdf-inspector distribution uses one shared semantic version:
- Rust crate: `pdf-inspector`
- Python package: `pdf-inspector`
- Node package: `@firecrawl/pdf-inspector` and its platform packages
- Browser package: `@firecrawl/pdf-inspector-wasm`
- Internal NAPI and WASM Rust crates
`Cargo.toml` is the canonical version source. Update every manifest and lockfile
with:
```bash
python3 scripts/version.py <version>
```
Verify that nothing has diverged with:
```bash
python3 scripts/version.py --check
```
CI and every publishing workflow run this check before building or publishing.
## Release steps
1. Choose the next shared semantic version and run `scripts/version.py`.
2. Review the manifest and lockfile changes in the version-bump pull request.
3. Merge the pull request to `main`.
4. The crates.io, PyPI, Node, and WASM workflows independently build and
publish that version from the same commit.
5. After all registries succeed, create one `v<version>` GitHub release that
links to each package and describes changes since the previous shared tag.
The independent workflows are intentionally idempotent. A manual dispatch from
`main` can repair a partial release, and already-published artifacts are skipped.
## Trusted publishers
The repositories use GitHub Actions OIDC instead of long-lived registry tokens.
Configure each registry's trusted publisher for `firecrawl/pdf-inspector` and
its corresponding workflow:
- crates.io: `publish-crate.yml`, environment `crates-io`
- PyPI: `publish-pypi.yml`, environment `pypi`
- npm Node package: `publish.yml`
- npm WASM package: `publish-wasm.yml`
The WASM package must exist before npm trusted publishing can be configured. If
it ever needs to be bootstrapped again, build and inspect it before publishing:
```bash
wasm-pack build wasm --target web --scope firecrawl --out-dir pkg --release
npm pack --dry-run ./wasm/pkg
npm publish ./wasm/pkg --access public
```
+9 -104
View File
@@ -1,39 +1,9 @@
# pdf-inspector
# Python API
Fast PDF classification and text extraction. Detects whether a PDF is text-based or scanned, extracts text with position awareness, and converts to clean Markdown — all without OCR. Python bindings via [PyO3](https://pyo3.rs) for the [pdf-inspector](https://github.com/firecrawl/pdf-inspector) Rust library.
Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in under 200ms, skipping expensive OCR services for the ~54% of PDFs that don't need them.
## Features
- **Smart classification** — `text_based` / `scanned` / `image_based` / `mixed` in ~1050ms, with a confidence score and per-page OCR routing.
- **Markdown conversion** — headings, lists, code blocks, bold/italic, URL linking, and dual-mode table detection (PDF drawing ops + text-alignment heuristics).
- **Layout-aware extraction** — multi-column reading order, position and font info per text item, RTL support.
- **Robust text decoding** — CID/Type0 fonts via ToUnicode CMaps, plus automatic flagging of broken encodings so callers can fall back to OCR.
- **Lightweight** — native Rust core, no ML models, no external services; ships type stubs.
## Benchmark
[opendataloader-bench](https://github.com/opendataloader-project/opendataloader-bench) corpus (200 PDFs), local engines without model-based PDF parsing; OCR disabled. Scores 01, higher is better:
| Engine | Overall | Reading order | Tables (TEDS) | Headings | Speed |
|---|---|---|---|---|---|
| **pdf-inspector** | **0.875** | **0.915** | **0.814** | 0.788 | **0.470s** |
| liteparse | 0.873 | 0.913 | 0.693 | **0.811** | 0.750s |
| opendataloader | 0.831 | 0.902 | 0.489 | 0.739 | 2.569s |
| pymupdf4llm | 0.735 | 0.886 | 0.401 | 0.424 | 17.117s |
| markitdown | 0.589 | 0.844 | 0.273 | 0.000 | 16.165s |
Refreshed July 31, 2026, on Apple M4 Pro; speed is the median of five complete corpus runs after an excluded warm-up. Full methodology and versions are in the [repo README](https://github.com/firecrawl/pdf-inspector#benchmark), with raw timings and artifacts in the [results branch](https://github.com/firecrawl/opendataloader-bench/tree/abi/pdf-parser-benchmark-results).
Python bindings via [PyO3](https://pyo3.rs). Requires Rust toolchain for building from source.
## Install
```bash
pip install pdf-inspector
```
Prebuilt wheels cover CPython ≥3.8 on Linux (x86_64, aarch64), macOS (Intel, Apple Silicon), and Windows (x64). Other platforms build from source, which requires a Rust toolchain. For local development in a repo checkout:
```bash
pip install maturin
maturin develop --release
@@ -80,17 +50,6 @@ for page in result.pages:
# Restrict to specific 0-indexed pages (preserves caller order)
result = pdf_inspector.extract_pages_markdown("document.pdf", pages=[0, 2])
# 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:
elements = pdf_inspector.extract_structure_elements("tagged.pdf")
roles = {(e.page, e.mcid): e.role for e in elements}
headings = [
item.text
for item in pdf_inspector.extract_text_with_positions("tagged.pdf")
if item.mcid is not None and roles.get((item.page, item.mcid), "").startswith("H")
]
```
## API reference
@@ -111,73 +70,19 @@ headings = [
| `extract_text_in_regions_bytes(data, page_regions)` | Region extraction from bytes |
| `extract_pages_markdown(path, pages=None)` | Per-page Markdown + layout metadata (all pages by default) |
| `extract_pages_markdown_bytes(data, pages=None)` | Per-page Markdown from bytes |
| `extract_structure_elements(path, pages=None)` | Structure-tree elements from tagged PDFs (page, mcid, role) |
| `extract_structure_elements_bytes(data, pages=None)` | Structure-tree elements from bytes |
## Types
Type stubs (`pdf_inspector.pyi`) ship with the package. Result types at a glance:
**`PdfResult` fields:** `pdf_type`, `markdown`, `page_count`, `processing_time_ms`, `pages_needing_ocr`, `title`, `confidence`, `is_complex_layout`, `pages_with_tables`, `pages_with_columns`, `has_encoding_issues`
```python
class PdfResult: # process_pdf / detect_pdf
pdf_type: str # "text_based" | "scanned" | "image_based" | "mixed"
markdown: str | None # extracted Markdown (None for detect_pdf)
page_count: int
processing_time_ms: int
pages_needing_ocr: list[int] # 1-indexed
ocr_reasons_by_page: list[PageOcrReasons]
title: str | None
confidence: float # 0.0 - 1.0
is_complex_layout: bool
pages_with_tables: list[int]
pages_with_columns: list[int]
has_encoding_issues: bool # broken font encodings — consider OCR fallback
**`PdfClassification` fields:** `pdf_type`, `page_count`, `pages_needing_ocr` (0-indexed), `confidence`
class PageOcrReasons: # per-page OCR diagnostics
page: int # 1-indexed
reasons: list[str] # machine-readable reason identifiers
**`TextItem` fields:** `text`, `x`, `y`, `width`, `height`, `font`, `font_size`, `page`, `is_bold`, `is_italic`, `item_type`
class PdfClassification: # classify_pdf
pdf_type: str
page_count: int
pages_needing_ocr: list[int] # 0-indexed
confidence: float
**`RegionText` fields:** `text`, `needs_ocr`
class TextItem: # extract_text_with_positions
text: str
x: float
y: float
width: float
height: float
font: str
font_size: float
page: int
is_bold: bool
is_italic: bool
is_underline: bool
is_strikeout: bool
item_type: str
mcid: int | None # marked-content ID for tagged PDFs (None otherwise)
**`PageRegionTexts` fields:** `page` (0-indexed), `regions` (list of RegionText)
class StructureElement: # extract_structure_elements
page: int # 1-indexed (matches TextItem.page)
mcid: int
role: str # "H1".."H6", "P", "Table", ... (resolved via /RoleMap)
**`PageMarkdown` fields:** `page` (0-indexed), `markdown`, `needs_ocr`
class RegionText: # extract_text_in_regions
text: str
needs_ocr: bool
ocr_reason: str | None # machine-readable OCR reason
class PageRegionTexts: # extract_text_in_regions
page: int # 0-indexed
regions: list[RegionText]
class PagesExtractionResult: # extract_pages_markdown
pages: list[PageMarkdown] # PageMarkdown: page (0-indexed), markdown, needs_ocr, ocr_reason
pages_with_tables: list[int] # 1-indexed
pages_with_columns: list[int] # 1-indexed
pages_needing_ocr: list[int] # 1-indexed
ocr_reasons_by_page: list[PageOcrReasons]
is_complex: bool # any page has tables or multi-column layout
```
**`PagesExtractionResult` fields:** `pages` (list of PageMarkdown), `pages_with_tables` (1-indexed), `pages_with_columns` (1-indexed), `pages_needing_ocr` (1-indexed), `is_complex`
+3 -351
View File
@@ -1,50 +1,12 @@
# pdf-inspector
# Rust API
Fast PDF classification and text extraction. Detects whether a PDF is text-based or scanned, extracts text with position awareness, and converts to clean Markdown — all without OCR. The default build is pure Rust, has no ML models or external services, and uses [lopdf](https://crates.io/crates/lopdf) for PDF parsing. Also available for [Python](https://pypi.org/project/pdf-inspector/) and [Node.js](https://www.npmjs.com/package/@firecrawl/pdf-inspector/).
Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in under 200ms, skipping expensive OCR services for the ~54% of PDFs that don't need them.
## Features
- **Smart classification** — TextBased / Scanned / ImageBased / Mixed in ~1050ms, with a confidence score and per-page OCR routing.
- **Markdown conversion** — headings, lists, code blocks, bold/italic, URL linking, and dual-mode table detection (PDF drawing ops + text-alignment heuristics).
- **Layout-aware extraction** — multi-column reading order, position and font info per text item, RTL support.
- **Robust text decoding** — CID/Type0 fonts via ToUnicode CMaps, plus automatic flagging of broken encodings so callers can fall back to OCR.
- **Lightweight** — pure Rust, no ML models, no external services; single PDF dependency ([lopdf](https://crates.io/crates/lopdf)).
## Benchmark
[opendataloader-bench](https://github.com/opendataloader-project/opendataloader-bench) corpus (200 PDFs), local engines without model-based PDF parsing; OCR disabled. Scores 01, higher is better:
| Engine | Overall | Reading order | Tables (TEDS) | Headings | Speed |
|---|---|---|---|---|---|
| **pdf-inspector** | **0.875** | **0.915** | **0.814** | 0.788 | **0.470s** |
| liteparse | 0.873 | 0.913 | 0.693 | **0.811** | 0.750s |
| opendataloader | 0.831 | 0.902 | 0.489 | 0.739 | 2.569s |
| pymupdf4llm | 0.735 | 0.886 | 0.401 | 0.424 | 17.117s |
| markitdown | 0.589 | 0.844 | 0.273 | 0.000 | 16.165s |
Refreshed July 31, 2026, on Apple M4 Pro; speed is the median of five complete corpus runs after an excluded warm-up. Full methodology and versions are in the [repo README](https://github.com/firecrawl/pdf-inspector#benchmark), with raw timings and artifacts in the [results branch](https://github.com/firecrawl/opendataloader-bench/tree/abi/pdf-parser-benchmark-results).
## Install
```bash
cargo add pdf-inspector
```
For the latest unreleased changes, use the git dependency instead:
Add to your `Cargo.toml`:
```toml
[dependencies]
pdf-inspector = { git = "https://github.com/firecrawl/pdf-inspector" }
```
The crate also ships CLI binaries — `pdf2md` (PDF → Markdown, with `--json`, `--pages`, `--select-pages`, and the opt-in token-saving `--compact` profile) and `detect-pdf` (classification, with `--analyze --json`):
```bash
cargo install pdf-inspector
```
## Usage
Detect and extract in one call:
@@ -117,285 +79,6 @@ let bytes = std::fs::read("document.pdf")?;
let result = process_pdf_mem(&bytes)?;
```
### Vision extension contracts
The native-only `vision` feature exposes the stable seam used by OCR
integrations without selecting or embedding an inference runtime. The
separate `model-cache` feature adds pinned artifact management:
- `PageRenderer`, `OcrEngine`, and `LayoutEngine` traits;
- renderer-neutral owned page buffers and affine pixel↔PDF transforms;
- `OcrOptions` and opt-in `Off`/`Auto`/`Force` routing modes;
- positioned OCR/layout results and per-page provenance types; and
- a versioned PP-OCRv6 Small manifest with checksum-verified, locked, atomic
model-cache installation and explicit offline-directory overrides.
```toml
[dependencies]
pdf-inspector = { version = "1", features = ["vision", "model-cache"] }
```
The OCR contracts preserve existing behavior by default: OCR is `Off`, learned
layout is disabled, and model resolution is never reached. `ModelStore` itself
does not access the network. 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. Set `PDFIUM_LIB_PATH`, place its shared library
next to the executable, or use another discovery route supported by
`firecrawl-pdfium`.
```rust
use pdf_inspector::vision::{PdfiumRenderer, RenderOptions};
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` when it is not
available through the platform library search path. The feature currently
requires Rust 1.95 or newer, matching OAR 0.9.1's MSRV.
```toml
[dependencies]
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 + 1);
}
}
```
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, OcrMode, OcrPdfOptions,
};
let result = process_pdf_with_ocr(
"document.pdf",
OcrPdfOptions::new()
.mode(OcrMode::Auto)
.pages([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. Learned layout intentionally
returns an explicit unsupported error in this lightweight pipeline.
Build the CLI with the same opt-in feature:
```bash
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 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::pages`.
Extract per-page Markdown (one string per page, plus document-wide layout
metadata):
@@ -417,34 +100,6 @@ for page in &result.pages {
println!("Complex layout? {}", result.is_complex);
```
Extract structure-tree elements from tagged PDFs, and join them against
`extract_text_with_positions` to attach semantic roles (heading levels,
paragraphs, table cells) to extracted text:
```rust
use pdf_inspector::{extract_structure_elements, extract_text_with_positions};
use std::collections::HashMap;
// One entry per marked-content reference, sorted by (page, mcid); empty for
// untagged PDFs. Pages are 1-indexed to match `TextItem::page`, so the
// (page, mcid) pair is a direct join key.
let elements = extract_structure_elements("tagged.pdf", None)?;
let roles: HashMap<(u32, i64), &str> = elements
.iter()
.map(|e| ((e.page, e.mcid), e.role.as_str()))
.collect();
for item in extract_text_with_positions("tagged.pdf")? {
if let Some(mcid) = item.mcid {
if let Some(role) = roles.get(&(item.page, mcid)) {
if role.starts_with('H') {
println!("{}: {}", role, item.text);
}
}
}
}
```
## Processing modes
| Mode | What it does | Returns |
@@ -470,8 +125,6 @@ for item in extract_text_with_positions("tagged.pdf")? {
| `to_markdown_from_items_with_rects(items, options, rects)` | Markdown with rectangle-based table detection |
| `extract_pages_markdown(path, pages)` | Per-page Markdown + layout metadata (file) |
| `extract_pages_markdown_mem(bytes, pages)` | Per-page Markdown from bytes |
| `extract_structure_elements(path, pages)` | Structure-tree elements from tagged PDFs (page, mcid, role) |
| `extract_structure_elements_mem(bytes, pages)` | Structure-tree elements from bytes |
Low-level detection functions are also available via the `detector` module (`detect_pdf_type`, `detect_pdf_type_with_config`, etc.) for callers who need `PdfTypeResult` instead of `PdfProcessResult`.
@@ -487,8 +140,7 @@ Low-level detection functions are also available via the `detector` module (`det
| `DetectionConfig` | Configuration for detection: scan strategy, thresholds |
| `ScanStrategy` | `EarlyExit`, `Full`, `Sample(n)`, `Pages(vec)` |
| `LayoutComplexity` | Layout analysis: is_complex, pages_with_tables, pages_with_columns |
| `TextItem` | Text with position, font info, page number, and optional structure-tree `mcid` |
| `StructureElement` | Tagged-PDF structure reference: page (1-indexed), mcid, role (`"H1"`..`"H6"`, `"P"`, …) |
| `TextItem` | Text with position, font info, and page number |
| `MarkdownOptions` | Configuration for Markdown formatting (page numbers, etc.) |
| `PageMarkdown` | Per-page result: page (0-indexed), markdown, needs_ocr |
| `PagesExtractionResult` | Per-page output + 1-indexed pages_with_tables / pages_with_columns / pages_needing_ocr, is_complex |
+4 -27
View File
@@ -499,13 +499,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
dependencies = [
"cfg-if",
"js-sys",
"libc",
"r-efi",
"rand_core",
"wasip2",
"wasip3",
"wasm-bindgen",
]
[[package]]
@@ -559,25 +557,6 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "include_dir"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
dependencies = [
"include_dir_macros",
]
[[package]]
name = "include_dir_macros"
version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "indexmap"
version = "2.13.0"
@@ -693,9 +672,8 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
[[package]]
name = "lopdf"
version = "0.42.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "25aab26d99567469098e64a02f42679f8965c6401263eefa31d8f2dcc37a221c"
version = "0.40.0"
source = "git+https://github.com/J-F-Liu/lopdf?rev=7a05512d831415b1f2b1ce522391d6beab8a1284#7a05512d831415b1f2b1ce522391d6beab8a1284"
dependencies = [
"aes",
"bitflags",
@@ -851,10 +829,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "pdf-inspector"
version = "1.14.2"
version = "0.1.0"
dependencies = [
"env_logger",
"include_dir",
"log",
"lopdf",
"once_cell",
@@ -867,7 +844,7 @@ dependencies = [
[[package]]
name = "pdf-inspector-napi"
version = "1.14.2"
version = "0.2.0"
dependencies = [
"napi",
"napi-build",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "pdf-inspector-napi"
version = "1.14.2"
version = "0.2.0"
edition = "2021"
[lib]
+6 -51
View File
@@ -4,28 +4,6 @@ Fast PDF classification and region-based text extraction for Node.js/Bun. Native
Built by [Firecrawl](https://firecrawl.dev) for hybrid OCR pipelines — extract text from PDF structure where possible, fall back to OCR only when needed.
## Features
- **Smart classification** — text-based / scanned / image-based / mixed in ~1050ms, with a confidence score and per-page OCR routing.
- **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; ~56 MB platform binary, TypeScript definitions included.
## Benchmark
[opendataloader-bench](https://github.com/opendataloader-project/opendataloader-bench) corpus (200 PDFs), local engines without model-based PDF parsing; OCR disabled. Scores 01, higher is better:
| Engine | Overall | Reading order | Tables (TEDS) | Headings | Speed |
|---|---|---|---|---|---|
| **pdf-inspector** | **0.875** | **0.915** | **0.814** | 0.788 | **0.470s** |
| liteparse | 0.873 | 0.913 | 0.693 | **0.811** | 0.750s |
| opendataloader | 0.831 | 0.902 | 0.489 | 0.739 | 2.569s |
| pymupdf4llm | 0.735 | 0.886 | 0.401 | 0.424 | 17.117s |
| markitdown | 0.589 | 0.844 | 0.273 | 0.000 | 16.165s |
Refreshed July 31, 2026, on Apple M4 Pro; speed is the median of five complete corpus runs after an excluded warm-up. Full methodology and versions are in the [repo README](https://github.com/firecrawl/pdf-inspector#benchmark), with raw timings and artifacts in the [results branch](https://github.com/firecrawl/opendataloader-bench/tree/abi/pdf-parser-benchmark-results).
## Install
```bash
@@ -34,7 +12,7 @@ npm install @firecrawl/pdf-inspector
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.
Prebuilt binaries included for **linux-x64** and **macOS ARM64**. No Rust toolchain needed.
## API
@@ -59,7 +37,7 @@ console.log(result.confidence) // 0.875
Extract text within bounding-box regions from a PDF. Designed for hybrid OCR pipelines where a layout model detects regions in rendered page images, and this function extracts text from the PDF structure for text-based pages — skipping GPU OCR.
Each region result includes a `needsOcr` flag that signals unreliable extraction (empty text, GID-encoded fonts, garbage text, encoding issues). When the cause is a suspected garbled text layer, `ocrReason` is set to `"suspected_garbled_text"`.
Each region result includes a `needsOcr` flag that signals unreliable extraction (empty text, GID-encoded fonts, garbage text, encoding issues).
```typescript
import { extractTextInRegions } from '@firecrawl/pdf-inspector'
@@ -83,22 +61,6 @@ for (const region of result[0].regions) {
}
```
### Async variants
`processPdf`, `classifyPdf`, and `extractPagesMarkdown` are synchronous and parse on the calling thread — in Node, that's the event loop. For a one-off call in a script that's fine, but in a server a large document can hold the loop for tens to hundreds of milliseconds.
`processPdfAsync`, `classifyPdfAsync`, and `extractPagesMarkdownAsync` take the same arguments and produce the same results, but run the parse on the libuv thread pool and return a promise, keeping the event loop free. The input buffer is copied before the call returns, so it's safe to reuse or mutate immediately:
```typescript
import { classifyPdfAsync, extractPagesMarkdownAsync } from '@firecrawl/pdf-inspector'
const classification = await classifyPdfAsync(pdf)
if (classification.pdfType === 'TextBased') {
const { pages } = await extractPagesMarkdownAsync(pdf)
// ...
}
```
## Types
```typescript
@@ -122,22 +84,15 @@ interface PageRegionTexts {
interface RegionText {
text: string
needsOcr: boolean // true when text is unreliable
ocrReason?: string // "suspected_garbled_text" when known
}
```
## Platforms
Prebuilt binaries ship as platform-specific packages installed automatically via `optionalDependencies`:
| Platform | Architecture | Package |
|----------|-------------|---------|
| Linux | x64 (glibc) | `@firecrawl/pdf-inspector-linux-x64-gnu` |
| Linux | x64 (musl/Alpine) | `@firecrawl/pdf-inspector-linux-x64-musl` |
| Linux | ARM64 (glibc) | `@firecrawl/pdf-inspector-linux-arm64-gnu` |
| Linux | ARM64 (musl/Alpine) | `@firecrawl/pdf-inspector-linux-arm64-musl` |
| macOS | ARM64 | `@firecrawl/pdf-inspector-darwin-arm64` |
| Windows | x64 | `@firecrawl/pdf-inspector-win32-x64-msvc` |
| Platform | Architecture | Supported |
|----------|-------------|-----------|
| Linux | x64 | Yes |
| macOS | ARM64 | Yes |
## License
-8
View File
@@ -7,14 +7,6 @@
"devDependencies": {
"@napi-rs/cli": "^3.4.1",
},
"optionalDependencies": {
"@firecrawl/pdf-inspector-darwin-arm64": "1.14.2",
"@firecrawl/pdf-inspector-linux-arm64-gnu": "1.14.2",
"@firecrawl/pdf-inspector-linux-arm64-musl": "1.14.2",
"@firecrawl/pdf-inspector-linux-x64-gnu": "1.14.2",
"@firecrawl/pdf-inspector-linux-x64-musl": "1.14.2",
"@firecrawl/pdf-inspector-win32-x64-msvc": "1.14.2",
},
},
},
"packages": {
+6 -13
View File
@@ -1,6 +1,6 @@
{
"name": "@firecrawl/pdf-inspector",
"version": "1.14.2",
"version": "1.7.2",
"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",
@@ -22,6 +22,7 @@
"files": [
"index.js",
"index.d.ts",
"*.node",
"bin/",
"README.md"
],
@@ -37,12 +38,12 @@
"binaryName": "pdf-inspector",
"targets": [
"x86_64-unknown-linux-gnu",
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-gnu",
"aarch64-unknown-linux-musl",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc"
]
],
"package": {
"name": "@firecrawl/pdf-inspector-js"
}
},
"scripts": {
"build": "napi build --platform --release",
@@ -50,13 +51,5 @@
},
"devDependencies": {
"@napi-rs/cli": "^3.4.1"
},
"optionalDependencies": {
"@firecrawl/pdf-inspector-linux-x64-gnu": "1.14.2",
"@firecrawl/pdf-inspector-linux-x64-musl": "1.14.2",
"@firecrawl/pdf-inspector-linux-arm64-gnu": "1.14.2",
"@firecrawl/pdf-inspector-linux-arm64-musl": "1.14.2",
"@firecrawl/pdf-inspector-darwin-arm64": "1.14.2",
"@firecrawl/pdf-inspector-win32-x64-msvc": "1.14.2"
}
}
-29
View File
@@ -1,29 +0,0 @@
import { readFileSync } from "node:fs";
import { createRequire } from "node:module";
const require = createRequire(import.meta.url);
const { detectVectorGridInRegion } = require("./index.js");
const pdfPath =
process.argv[2] ?? "/tmp/pdf_inspector_indent_fixtures/cis_edge_benchmark.pdf";
const pdf = readFileSync(pdfPath);
const dpi = Number(process.argv[3] ?? 200);
const crops = [
{ pageIdx: 29, box: [0, 0, 612, 792], label: "page30-full" },
{ pageIdx: 16, box: [0, 0, 612, 792], label: "page17-full" },
{ pageIdx: 23, box: [0, 0, 612, 792], label: "page24-full" },
];
for (const { pageIdx, box, label } of crops) {
const result = detectVectorGridInRegion(pdf, pageIdx, box, dpi);
if (!result) {
console.log(`${label}: null`);
continue;
}
const rows = result.structureTokens.filter((token) => token === "<tr>").length;
const cols = rows > 0 ? result.cellBboxes.length / rows : 0;
console.log(
`${label}: cells=${result.cellBboxes.length} rows=${rows} cols=${cols}`,
);
}
+43 -279
View File
@@ -40,8 +40,6 @@ pub struct PdfResult {
pub processing_time_ms: u32,
/// 1-indexed page numbers that need OCR.
pub pages_needing_ocr: Vec<u32>,
/// Machine-readable OCR reasons by 1-indexed page.
pub ocr_reasons_by_page: Vec<PageOcrReasons>,
pub title: Option<String>,
pub confidence: f64,
pub is_complex_layout: bool,
@@ -50,13 +48,6 @@ pub struct PdfResult {
pub has_encoding_issues: bool,
}
/// OCR reasons for a single 1-indexed page.
#[napi(object)]
pub struct PageOcrReasons {
pub page: u32,
pub reasons: Vec<String>,
}
/// Lightweight PDF classification result.
#[napi(object)]
pub struct PdfClassification {
@@ -80,20 +71,9 @@ pub struct TextItem {
pub page: u32,
pub is_bold: bool,
pub is_italic: bool,
/// Underline detected geometrically (drawn rule/thin rect under the
/// baseline) — PDFs carry no underline font flag.
pub is_underline: bool,
/// Strikeout detected geometrically (rule crossing the glyphs at mid
/// x-height).
pub is_strikeout: bool,
pub item_type: ItemType,
/// URL for link items, `None` for other types.
pub link_url: Option<String>,
/// Marked Content ID from the content stream's BDC/BMC operator, `None`
/// when the text is not part of marked content. Join with the
/// `page`/`mcid` pairs from [`extractStructureElements`] to attach
/// structure-tree roles (headings, paragraphs, …) in tagged PDFs.
pub mcid: Option<i64>,
}
/// A page's regions for text extraction: (page_index_0based, bboxes).
@@ -110,8 +90,6 @@ pub struct RegionText {
pub text: String,
/// `true` when the text should not be trusted (empty, GID fonts, garbage, encoding issues).
pub needs_ocr: bool,
/// Machine-readable OCR reason when the cause is known.
pub ocr_reason: Option<String>,
}
/// Extracted text for one page's regions.
@@ -148,7 +126,6 @@ fn to_napi_result(r: pdf_inspector::PdfProcessResult) -> PdfResult {
page_count: r.page_count,
processing_time_ms: r.processing_time_ms as u32,
pages_needing_ocr: r.pages_needing_ocr,
ocr_reasons_by_page: to_napi_page_ocr_reasons(r.ocr_reasons_by_page),
title: r.title,
confidence: r.confidence as f64,
is_complex_layout: r.layout.is_complex,
@@ -158,16 +135,6 @@ fn to_napi_result(r: pdf_inspector::PdfProcessResult) -> PdfResult {
}
}
fn to_napi_page_ocr_reasons(reasons: Vec<pdf_inspector::PageOcrReasons>) -> Vec<PageOcrReasons> {
reasons
.into_iter()
.map(|reason| PageOcrReasons {
page: reason.page,
reasons: reason.reasons,
})
.collect()
}
fn convert_item_type(t: &pdf_inspector::types::ItemType) -> (ItemType, Option<String>) {
match t {
pdf_inspector::types::ItemType::Text => (ItemType::Text, None),
@@ -205,31 +172,6 @@ where
}
}
// ---------------------------------------------------------------------------
// Shared implementations (single body behind sync and async entry points)
// ---------------------------------------------------------------------------
fn process_pdf_impl(bytes: &[u8], pages: Option<Vec<u32>>) -> Result<PdfResult> {
let mut opts = pdf_inspector::PdfOptions::new();
if let Some(p) = pages {
opts = opts.pages(p);
}
let result = pdf_inspector::process_pdf_mem_with_options(bytes, opts)
.map_err(|e| to_napi_err(e, "process_pdf"))?;
Ok(to_napi_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"))?;
Ok(PdfClassification {
pdf_type: convert_pdf_type(result.pdf_type),
page_count: result.page_count,
pages_needing_ocr: result.pages_needing_ocr,
confidence: result.confidence as f64,
})
}
// ---------------------------------------------------------------------------
// Public NAPI API
// ---------------------------------------------------------------------------
@@ -238,7 +180,15 @@ fn classify_pdf_impl(bytes: &[u8]) -> Result<PdfClassification> {
#[napi]
pub fn process_pdf(buffer: Buffer, pages: Option<Vec<u32>>) -> Result<PdfResult> {
let bytes: Vec<u8> = buffer.to_vec();
catch_panic("process_pdf", move || process_pdf_impl(&bytes, pages))
catch_panic("process_pdf", move || {
let mut opts = pdf_inspector::PdfOptions::new();
if let Some(p) = pages {
opts = opts.pages(p);
}
let result = pdf_inspector::process_pdf_mem_with_options(&bytes, opts)
.map_err(|e| to_napi_err(e, "process_pdf"))?;
Ok(to_napi_result(result))
})
}
/// Fast detection only — no text extraction or markdown.
@@ -258,7 +208,16 @@ pub fn detect_pdf(buffer: Buffer) -> Result<PdfResult> {
#[napi]
pub fn classify_pdf(buffer: Buffer) -> Result<PdfClassification> {
let bytes: Vec<u8> = buffer.to_vec();
catch_panic("classify_pdf", move || classify_pdf_impl(&bytes))
catch_panic("classify_pdf", move || {
let result =
pdf_inspector::classify_pdf_mem(&bytes).map_err(|e| to_napi_err(e, "classify_pdf"))?;
Ok(PdfClassification {
pdf_type: convert_pdf_type(result.pdf_type),
page_count: result.page_count,
pages_needing_ocr: result.pages_needing_ocr,
confidence: result.confidence as f64,
})
})
}
/// Extract plain text from a PDF Buffer.
@@ -307,65 +266,14 @@ pub fn extract_text_with_positions(
page: item.page,
is_bold: item.is_bold,
is_italic: item.is_italic,
is_underline: item.is_underline,
is_strikeout: item.is_strikeout,
item_type,
link_url,
mcid: item.mcid,
}
})
.collect())
})
}
/// One structure-tree element reference from a tagged PDF.
#[napi(object)]
pub struct StructureElementJs {
/// 1-indexed page number (matches `TextItem.page`).
pub page: u32,
/// Marked Content ID from the page's content stream (matches
/// `TextItem.mcid`).
pub mcid: i64,
/// Standard structure type name ("H1".."H6", "P", "Table", "TD", …).
/// Custom tags are resolved through the document's role map; tags with
/// no standard mapping are returned verbatim.
pub role: String,
}
/// Extract structure-tree element references from a tagged PDF.
///
/// Parses the document's structure tree (when present) and returns one
/// entry per marked-content reference, resolved to its 1-indexed page,
/// MCID, and structure type name. Returns an empty array when the PDF is
/// not tagged.
///
/// Join `(page, mcid)` against the `page`/`mcid` fields from
/// [`extractTextWithPositions`] to attach heading levels (H1..H6) and other
/// semantic roles to extracted text.
///
/// Pass 1-indexed page numbers (matching `TextItem.page`) to restrict
/// output; omit `pages` for the whole document. Entries are sorted by
/// `(page, mcid)`.
#[napi]
pub fn extract_structure_elements(
buffer: Buffer,
pages: Option<Vec<u32>>,
) -> Result<Vec<StructureElementJs>> {
let bytes: Vec<u8> = buffer.to_vec();
catch_panic("extract_structure_elements", move || {
let elements = pdf_inspector::extract_structure_elements_mem(&bytes, pages.as_deref())
.map_err(|e| to_napi_err(e, "extract_structure_elements"))?;
Ok(elements
.into_iter()
.map(|e| StructureElementJs {
page: e.page,
mcid: e.mcid,
role: e.role,
})
.collect())
})
}
/// Extract text within bounding-box regions from a PDF.
///
/// For hybrid OCR: layout model detects regions in rendered images,
@@ -574,10 +482,9 @@ pub fn extract_tables_with_structure_cells(
/// `fallbackReason` is `null` when the TSR-hybrid path produced the
/// markdown directly. When stage 1's quality check fires (the cells
/// look like a SLANet detection pathology — phantom rows or multi-row
/// content in a single cell), the auto path may expand the TSR cells
/// in-place or run the heuristic table extractor on the same region.
/// `fallbackReason` carries the diagnostic label (for example
/// `"multi_row_in_cell_expanded"` or `"phantom_empty_row"`).
/// content in a single cell), the heuristic table extractor is run on
/// the same region instead, and `fallbackReason` carries the diagnostic
/// label (`"phantom_empty_row"`, `"multi_row_in_cell"`).
#[napi(object)]
pub struct TableExtractionResultJs {
pub markdown: String,
@@ -587,14 +494,13 @@ pub struct TableExtractionResultJs {
/// Auto-fallback variant of [`extractTablesWithStructure`].
///
/// Runs the TSR-hybrid path, checks the resulting cells for known
/// SLANet detection pathologies, expands multi-row cells in-place when
/// possible, and otherwise falls back to the heuristic
/// `extractTablesInRegions` for inputs where the TSR path looks
/// SLANet detection pathologies, and falls back to the heuristic
/// `extractTablesInRegions` for any input where the TSR path looks
/// compromised.
///
/// On clean inputs this returns identical markdown to
/// `extractTablesWithStructure`; on flagged inputs `fallbackReason` is
/// set to the recovery path that produced the result.
/// `extractTablesWithStructure`; on flagged inputs the heuristic
/// markdown replaces the TSR markdown and `fallbackReason` is set.
#[napi]
pub fn extract_tables_with_structure_auto(
buffer: Buffer,
@@ -655,8 +561,6 @@ pub struct PageMarkdownResult {
pub markdown: String,
/// `true` when text on this page is unreliable.
pub needs_ocr: bool,
/// Machine-readable OCR reason when the cause is known.
pub ocr_reason: Option<String>,
}
/// Combined per-page markdown extraction and layout classification result.
@@ -670,8 +574,6 @@ pub struct PagesExtractionResult {
pub pages_with_columns: Vec<u32>,
/// 1-indexed pages that need OCR (scanned/image-based).
pub pages_needing_ocr: Vec<u32>,
/// Machine-readable OCR reasons by 1-indexed page.
pub ocr_reasons_by_page: Vec<PageOcrReasons>,
/// True if any page has tables or columns.
pub is_complex: bool,
}
@@ -693,32 +595,23 @@ pub fn extract_pages_markdown(
) -> Result<PagesExtractionResult> {
let bytes: Vec<u8> = buffer.to_vec();
catch_panic("extract_pages_markdown", move || {
extract_pages_markdown_impl(&bytes, pages.as_deref())
})
}
fn extract_pages_markdown_impl(
bytes: &[u8],
pages: Option<&[u32]>,
) -> Result<PagesExtractionResult> {
let result = pdf_inspector::extract_pages_markdown_mem(bytes, pages)
.map_err(|e| to_napi_err(e, "extract_pages_markdown"))?;
Ok(PagesExtractionResult {
pages: result
.pages
.into_iter()
.map(|r| PageMarkdownResult {
page: r.page,
markdown: r.markdown,
needs_ocr: r.needs_ocr,
ocr_reason: r.ocr_reason,
})
.collect(),
pages_with_tables: result.pages_with_tables,
pages_with_columns: result.pages_with_columns,
pages_needing_ocr: result.pages_needing_ocr,
ocr_reasons_by_page: to_napi_page_ocr_reasons(result.ocr_reasons_by_page),
is_complex: result.is_complex,
let result = pdf_inspector::extract_pages_markdown_mem(&bytes, pages.as_deref())
.map_err(|e| to_napi_err(e, "extract_pages_markdown"))?;
Ok(PagesExtractionResult {
pages: result
.pages
.into_iter()
.map(|r| PageMarkdownResult {
page: r.page,
markdown: r.markdown,
needs_ocr: r.needs_ocr,
})
.collect(),
pages_with_tables: result.pages_with_tables,
pages_with_columns: result.pages_with_columns,
pages_needing_ocr: result.pages_needing_ocr,
is_complex: result.is_complex,
})
})
}
@@ -753,137 +646,8 @@ fn to_page_region_texts(results: Vec<pdf_inspector::PageRegionResult>) -> Vec<Pa
.map(|r| RegionText {
text: r.text,
needs_ocr: r.needs_ocr,
ocr_reason: r.ocr_reason,
})
.collect(),
})
.collect()
}
// ---------------------------------------------------------------------------
// Async variants (libuv thread pool via AsyncTask)
//
// The synchronous exports above parse on the calling thread, which in Node is
// the event loop. These `*Async` variants run the same shared implementations
// on the libuv thread pool and hand JavaScript a promise, so servers under
// concurrent load keep answering requests while a document parses. The sync
// exports keep their names, signatures, and behaviour.
//
// Each factory copies the input Buffer to an owned `Vec<u8>` on the calling
// (JS) thread — deliberately. JS execution is single-threaded, so no JS code
// can mutate the buffer while the synchronous part of the call copies it.
// Holding the napi `Buffer` and reading it from the worker instead would be
// zero-copy, but a caller mutating the buffer before the promise settles
// would then race the worker's reads — undefined behavior, not a recoverable
// error (a known napi-rs soundness hazard with cross-thread Buffer access).
// The copy is a one-time memcpy, negligible next to the parse it unblocks.
// ---------------------------------------------------------------------------
pub struct ProcessPdfTask {
bytes: Vec<u8>,
pages: Option<Vec<u32>>,
}
impl Task for ProcessPdfTask {
type Output = PdfResult;
type JsValue = PdfResult;
fn compute(&mut self) -> Result<Self::Output> {
let bytes = std::mem::take(&mut self.bytes);
let pages = self.pages.take();
// AssertUnwindSafe: `bytes`/`pages` are moved into the closure and
// dropped on unwind — no shared state can be observed broken.
catch_panic(
"process_pdf",
panic::AssertUnwindSafe(move || process_pdf_impl(&bytes, pages)),
)
}
fn resolve(&mut self, _env: Env, output: Self::Output) -> Result<Self::JsValue> {
Ok(output)
}
}
/// Async variant of [`processPdf`]: same result, but the parse runs on the
/// libuv thread pool instead of the event loop and the call returns a
/// promise. The buffer is copied before the call returns, so it may be
/// reused or mutated immediately.
// ts_return_type is required: napi-rs emits `Promise<unknown>` for
// `AsyncTask<T>` returns without it.
#[napi(ts_return_type = "Promise<PdfResult>")]
pub fn process_pdf_async(buffer: Buffer, pages: Option<Vec<u32>>) -> AsyncTask<ProcessPdfTask> {
AsyncTask::new(ProcessPdfTask {
bytes: buffer.to_vec(),
pages,
})
}
pub struct ClassifyPdfTask {
bytes: Vec<u8>,
}
impl Task for ClassifyPdfTask {
type Output = PdfClassification;
type JsValue = PdfClassification;
fn compute(&mut self) -> Result<Self::Output> {
let bytes = std::mem::take(&mut self.bytes);
catch_panic(
"classify_pdf",
panic::AssertUnwindSafe(move || classify_pdf_impl(&bytes)),
)
}
fn resolve(&mut self, _env: Env, output: Self::Output) -> Result<Self::JsValue> {
Ok(output)
}
}
/// Async variant of [`classifyPdf`]: same result, but the classification runs
/// on the libuv thread pool instead of the event loop and the call returns a
/// promise. The buffer is copied before the call returns, so it may be
/// reused or mutated immediately.
#[napi(ts_return_type = "Promise<PdfClassification>")]
pub fn classify_pdf_async(buffer: Buffer) -> AsyncTask<ClassifyPdfTask> {
AsyncTask::new(ClassifyPdfTask {
bytes: buffer.to_vec(),
})
}
pub struct ExtractPagesMarkdownTask {
bytes: Vec<u8>,
pages: Option<Vec<u32>>,
}
impl Task for ExtractPagesMarkdownTask {
type Output = PagesExtractionResult;
type JsValue = PagesExtractionResult;
fn compute(&mut self) -> Result<Self::Output> {
let bytes = std::mem::take(&mut self.bytes);
let pages = self.pages.take();
catch_panic(
"extract_pages_markdown",
panic::AssertUnwindSafe(move || extract_pages_markdown_impl(&bytes, pages.as_deref())),
)
}
fn resolve(&mut self, _env: Env, output: Self::Output) -> Result<Self::JsValue> {
Ok(output)
}
}
/// Async variant of [`extractPagesMarkdown`]: same result, but the extraction
/// runs on the libuv thread pool instead of the event loop and the call
/// returns a promise. The buffer is copied before the call returns, so it
/// may be reused or mutated immediately.
#[napi(ts_return_type = "Promise<PagesExtractionResult>")]
pub fn extract_pages_markdown_async(
buffer: Buffer,
pages: Option<Vec<u32>>,
) -> AsyncTask<ExtractPagesMarkdownTask> {
AsyncTask::new(ExtractPagesMarkdownTask {
bytes: buffer.to_vec(),
pages,
})
}
-110
View File
@@ -2,21 +2,16 @@ import { readFileSync } from 'fs';
import { strict as assert } from 'assert';
import {
processPdf,
processPdfAsync,
detectPdf,
classifyPdf,
classifyPdfAsync,
extractText,
extractTextWithPositions,
extractStructureElements,
extractTextInRegions,
detectVectorGridInRegion,
extractPagesMarkdown,
extractPagesMarkdownAsync,
} from './index.js';
const fixture = readFileSync('../tests/fixtures/thermo-freon12.pdf');
const taggedFixture = readFileSync('../tests/fixtures/firecrawl_docs_tagged.pdf');
// --- processPdf ---
console.log('Testing processPdf...');
@@ -84,46 +79,6 @@ assert.ok(page1Items.length > 0);
assert.ok(page1Items.every(i => i.page === 1));
console.log(' extractTextWithPositions with pages: OK');
// mcid: undefined on untagged PDFs, numeric on tagged marked content
assert.ok(items.every(i => i.mcid === undefined || typeof i.mcid === 'number'));
const taggedItems = extractTextWithPositions(taggedFixture);
assert.ok(
taggedItems.some(i => typeof i.mcid === 'number'),
'tagged PDF text items should carry Marked Content IDs',
);
console.log(' extractTextWithPositions mcid: OK');
// --- extractStructureElements ---
console.log('Testing extractStructureElements...');
const structureElements = extractStructureElements(taggedFixture);
assert.ok(structureElements.length > 0);
assert.ok(structureElements.every(e => typeof e.page === 'number'));
assert.ok(structureElements.every(e => typeof e.mcid === 'number'));
assert.ok(structureElements.every(e => typeof e.role === 'string' && e.role.length > 0));
assert.ok(
structureElements.some(e => e.role === 'H1'),
'tagged fixture should surface H1 heading roles',
);
// (page, mcid) joins against extractTextWithPositions to recover heading text
const h1Refs = new Set(
structureElements.filter(e => e.role === 'H1').map(e => `${e.page}:${e.mcid}`),
);
const h1Text = taggedItems
.filter(i => typeof i.mcid === 'number' && h1Refs.has(`${i.page}:${i.mcid}`))
.map(i => i.text)
.join('');
assert.ok(h1Text.trim().length > 0, 'H1 join should recover heading text');
// pages filter is 1-indexed, matching TextItem.page
const page1Elements = extractStructureElements(taggedFixture, [1]);
assert.ok(page1Elements.length > 0);
assert.ok(page1Elements.every(e => e.page === 1));
// untagged PDFs yield an empty array
assert.deepEqual(extractStructureElements(fixture), []);
console.log(' extractStructureElements: OK');
// --- extractTextInRegions ---
console.log('Testing extractTextInRegions...');
const regionResults = extractTextInRegions(fixture, [
@@ -169,75 +124,10 @@ assert.equal(picked.pages[0].page, 2);
assert.equal(picked.pages[1].page, 0);
console.log(' extractPagesMarkdown with pages: OK');
// --- Async variants ---
console.log('Testing async variants...');
// processPdfAsync returns a promise and matches the sync result
const asyncResultPromise = processPdfAsync(fixture);
assert.ok(asyncResultPromise instanceof Promise);
const asyncResult = await asyncResultPromise;
assert.equal(asyncResult.pdfType, result.pdfType);
assert.equal(asyncResult.pageCount, result.pageCount);
assert.equal(asyncResult.markdown, result.markdown);
console.log(' processPdfAsync: OK');
// processPdfAsync with pages
const asyncResult2 = await processPdfAsync(fixture, [1]);
assert.equal(asyncResult2.markdown, result2.markdown);
console.log(' processPdfAsync with pages: OK');
// classifyPdfAsync matches the sync result
const asyncClassified = await classifyPdfAsync(fixture);
assert.equal(asyncClassified.pdfType, classified.pdfType);
assert.equal(asyncClassified.pageCount, classified.pageCount);
assert.equal(asyncClassified.confidence, classified.confidence);
assert.deepEqual(asyncClassified.pagesNeedingOcr, classified.pagesNeedingOcr);
console.log(' classifyPdfAsync: OK');
// extractPagesMarkdownAsync matches the sync result
const asyncAllPages = await extractPagesMarkdownAsync(fixture);
assert.equal(asyncAllPages.pages.length, allPages.pages.length);
assert.deepEqual(
asyncAllPages.pages.map(p => p.markdown),
allPages.pages.map(p => p.markdown),
);
assert.equal(asyncAllPages.isComplex, allPages.isComplex);
console.log(' extractPagesMarkdownAsync: OK');
// selected pages preserve caller order
const asyncPicked = await extractPagesMarkdownAsync(fixture, [2, 0]);
assert.equal(asyncPicked.pages.length, 2);
assert.equal(asyncPicked.pages[0].page, 2);
assert.equal(asyncPicked.pages[1].page, 0);
console.log(' extractPagesMarkdownAsync with pages: OK');
// input buffer is copied at call time: mutating it immediately after the
// call must not affect the in-flight parse
const scratch = Buffer.from(fixture);
const inFlight = processPdfAsync(scratch);
scratch.fill(0);
const fromMutated = await inFlight;
assert.equal(fromMutated.markdown, result.markdown);
console.log(' processPdfAsync input copied at call time: OK');
// concurrent async calls all settle
const [c1, c2, c3] = await Promise.all([
processPdfAsync(fixture),
classifyPdfAsync(fixture),
extractPagesMarkdownAsync(fixture),
]);
assert.equal(c1.pdfType, 'TextBased');
assert.equal(c2.pdfType, 'TextBased');
assert.equal(c3.pages.length, 3);
console.log(' concurrent async calls: OK');
// --- Error handling ---
console.log('Testing error handling...');
assert.throws(() => processPdf(Buffer.from('not a pdf')), /process_pdf/);
assert.throws(() => classifyPdf(Buffer.from('')), /classify_pdf/);
await assert.rejects(processPdfAsync(Buffer.from('not a pdf')), /process_pdf/);
await assert.rejects(classifyPdfAsync(Buffer.from('')), /classify_pdf/);
await assert.rejects(extractPagesMarkdownAsync(Buffer.from('')), /extract_pages_markdown/);
console.log(' error handling: OK');
console.log('\nAll NAPI tests passed!');
-53
View File
@@ -10,9 +10,6 @@ class PdfResult:
page_count: int
processing_time_ms: int
pages_needing_ocr: list[int]
"""1-indexed page numbers that need OCR."""
ocr_reasons_by_page: list["PageOcrReasons"]
"""Machine-readable OCR reasons by 1-indexed page."""
title: Optional[str]
confidence: float
is_complex_layout: bool
@@ -20,13 +17,6 @@ class PdfResult:
pages_with_columns: list[int]
has_encoding_issues: bool
class PageOcrReasons:
"""OCR reasons for a single 1-indexed page."""
page: int
"""1-indexed page number."""
reasons: list[str]
"""Machine-readable OCR reason identifiers."""
class PdfClassification:
"""Lightweight PDF classification result."""
pdf_type: str
@@ -48,31 +38,13 @@ class TextItem:
page: int
is_bold: bool
is_italic: bool
is_underline: bool
is_strikeout: bool
item_type: str
mcid: Optional[int]
"""Marked Content ID from the content stream's BDC/BMC operator, None when
the text is not part of marked content. Join with the (page, mcid) pairs
from extract_structure_elements to attach structure-tree roles in tagged
PDFs."""
class StructureElement:
"""One structure-tree element reference from a tagged PDF."""
page: int
"""1-indexed page number (matches TextItem.page)."""
mcid: int
"""Marked Content ID from the page's content stream (matches TextItem.mcid)."""
role: str
"""Standard structure type name ("H1".."H6", "P", "Table", "TD", ...)."""
class RegionText:
"""Extracted text for a single region."""
text: str
needs_ocr: bool
"""True when the text should not be trusted."""
ocr_reason: Optional[str]
"""Machine-readable OCR reason when the cause is known."""
class PageRegionTexts:
"""Extracted text for one page's regions."""
@@ -88,8 +60,6 @@ class PageMarkdown:
"""Formatted markdown for this page (empty string when needs_ocr is True)."""
needs_ocr: bool
"""True when text on this page is unreliable and OCR should be used instead."""
ocr_reason: Optional[str]
"""Machine-readable OCR reason when the cause is known."""
class PagesExtractionResult:
"""Per-page markdown output with document-wide layout classification."""
@@ -101,8 +71,6 @@ class PagesExtractionResult:
"""1-indexed pages where multi-column layout was detected."""
pages_needing_ocr: list[int]
"""1-indexed pages that need OCR."""
ocr_reasons_by_page: list[PageOcrReasons]
"""Machine-readable OCR reasons by 1-indexed page."""
is_complex: bool
"""True if any page has tables or multi-column layout."""
@@ -146,27 +114,6 @@ def extract_text_with_positions_bytes(data: bytes, pages: Optional[list[int]] =
"""Extract text with position information from bytes."""
...
def extract_structure_elements(path: str, pages: Optional[list[int]] = None) -> list[StructureElement]:
"""Extract structure-tree element references from a tagged PDF file.
Returns one entry per marked-content reference, resolved to its 1-indexed
page, MCID, and structure type name ("H1".."H6", "P", "Table", ...), sorted
by (page, mcid). Returns an empty list when the PDF is not tagged.
Args:
path: Path to the PDF file.
pages: Optional list of 1-indexed pages (matching ``TextItem.page``).
When ``None`` (default), the whole document is returned.
"""
...
def extract_structure_elements_bytes(data: bytes, pages: Optional[list[int]] = None) -> list[StructureElement]:
"""Extract structure-tree element references from tagged PDF bytes.
See :func:`extract_structure_elements` for details.
"""
...
def extract_text_in_regions(
path: str,
page_regions: list[tuple[int, list[list[float]]]],
+1 -9
View File
@@ -4,11 +4,8 @@ build-backend = "maturin"
[project]
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.2"
version = "0.1.0"
description = "Fast PDF inspection, classification, and text extraction with smart scanned vs text-based detection"
readme = "docs/python.md"
license = { text = "MIT" }
requires-python = ">=3.8"
classifiers = [
@@ -20,10 +17,5 @@ classifiers = [
"Topic :: Text Processing",
]
[project.urls]
Homepage = "https://github.com/firecrawl/pdf-inspector"
Repository = "https://github.com/firecrawl/pdf-inspector"
Documentation = "https://github.com/firecrawl/pdf-inspector/blob/main/docs/python.md"
[tool.maturin]
features = ["python"]
-351
View File
@@ -1,351 +0,0 @@
#!/usr/bin/env python3
"""Run a paired pdf-inspector OpenDataLoader benchmark and report deltas."""
from __future__ import annotations
import argparse
import json
import math
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any
SCORE_KEYS = (
"overall_mean",
"nid_mean",
"nid_s_mean",
"teds_mean",
"teds_s_mean",
"mhs_mean",
"mhs_s_mean",
)
def _non_negative_int(value: str) -> int:
parsed = int(value)
if parsed < 0:
raise argparse.ArgumentTypeError("must be non-negative")
return parsed
def _non_negative_float(value: str) -> float:
parsed = float(value)
if not math.isfinite(parsed) or parsed < 0.0:
raise argparse.ArgumentTypeError("must be finite and non-negative")
return parsed
def _finite_float(value: str) -> float:
parsed = float(value)
if not math.isfinite(parsed):
raise argparse.ArgumentTypeError("must be finite")
return parsed
def _scores(evaluation: dict[str, Any]) -> dict[str, float]:
score = evaluation.get("metrics", {}).get("score", {})
return {key: float(score[key]) for key in SCORE_KEYS if score.get(key) is not None}
def _documents(evaluation: dict[str, Any]) -> dict[str, float]:
documents: dict[str, float] = {}
for document in evaluation.get("documents", []):
overall = document.get("scores", {}).get("overall")
if overall is not None:
documents[str(document["document_id"])] = float(overall)
return documents
def compare_evaluations(
baseline: dict[str, Any],
candidate: dict[str, Any],
reference: dict[str, Any] | None = None,
*,
top: int = 10,
) -> dict[str, Any]:
"""Build aggregate and per-document deltas from evaluator JSON payloads."""
baseline_scores = _scores(baseline)
candidate_scores = _scores(candidate)
metric_deltas = {
key: candidate_scores[key] - baseline_scores[key]
for key in SCORE_KEYS
if key in baseline_scores and key in candidate_scores
}
baseline_documents = _documents(baseline)
candidate_documents = _documents(candidate)
shared = sorted(baseline_documents.keys() & candidate_documents.keys())
document_deltas = [
{
"document_id": document_id,
"baseline": baseline_documents[document_id],
"candidate": candidate_documents[document_id],
"delta": candidate_documents[document_id] - baseline_documents[document_id],
}
for document_id in shared
]
epsilon = 1e-12
improvements = sorted(document_deltas, key=lambda item: item["delta"], reverse=True)
regressions = sorted(document_deltas, key=lambda item: item["delta"])
result: dict[str, Any] = {
"baseline": baseline_scores,
"candidate": candidate_scores,
"deltas": metric_deltas,
"missing_predictions": {
"baseline": int(baseline.get("metrics", {}).get("missing_predictions", 0)),
"candidate": int(candidate.get("metrics", {}).get("missing_predictions", 0)),
},
"documents": {
"shared": len(shared),
"improved": sum(item["delta"] > epsilon for item in document_deltas),
"regressed": sum(item["delta"] < -epsilon for item in document_deltas),
"unchanged": sum(abs(item["delta"]) <= epsilon for item in document_deltas),
"largest_improvements": [
item for item in improvements if item["delta"] > epsilon
][:top],
"largest_regressions": [
item for item in regressions if item["delta"] < -epsilon
][:top],
"worst_regression": next(
(item for item in regressions if item["delta"] < -epsilon), None
),
},
}
if reference is not None:
reference_scores = _scores(reference)
result["reference"] = reference_scores
result["candidate_vs_reference"] = {
key: candidate_scores[key] - reference_scores[key]
for key in SCORE_KEYS
if key in candidate_scores and key in reference_scores
}
return result
def evaluate_gates(
comparison: dict[str, Any],
*,
min_overall_delta: float,
max_document_regression: float | None,
max_missing: int,
require_reference_lead: bool,
) -> list[str]:
"""Return human-readable gate failures; an empty list means pass."""
failures: list[str] = []
overall_delta = comparison["deltas"].get("overall_mean")
if overall_delta is None or overall_delta < min_overall_delta:
failures.append(
f"overall delta {overall_delta!r} is below {min_overall_delta:+.6f}"
)
candidate_missing = comparison["missing_predictions"]["candidate"]
if candidate_missing > max_missing:
failures.append(
f"candidate has {candidate_missing} missing predictions (maximum {max_missing})"
)
if max_document_regression is not None:
regression = comparison["documents"].get("worst_regression")
if regression is not None and regression["delta"] < -max_document_regression:
failures.append(
"largest document regression "
f"{regression['document_id']}={regression['delta']:+.6f} "
f"exceeds {-max_document_regression:+.6f}"
)
if require_reference_lead:
reference_delta = comparison.get("candidate_vs_reference", {}).get("overall_mean")
if reference_delta is None:
failures.append("reference overall score is unavailable")
elif reference_delta < 0.0:
failures.append(
f"candidate trails reference overall by {reference_delta!r}"
)
return failures
def _run(command: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> None:
print("+", " ".join(command), flush=True)
subprocess.run(command, cwd=cwd, env=env, check=True)
def _run_engine(
*,
bench_dir: Path,
python: Path,
binary: Path,
label: str,
scratch_root: Path,
) -> dict[str, Any]:
env = os.environ.copy()
env["PDF_INSPECTOR_BINARY"] = str(binary)
source = bench_dir / "prediction" / "pdf-inspector"
if source.exists():
if source.is_dir():
shutil.rmtree(source)
else:
source.unlink()
_run(
[
str(python),
"src/pdf_parser.py",
"--engine",
"pdf-inspector",
"--log-level",
"WARNING",
],
cwd=bench_dir,
env=env,
)
if not source.is_dir():
raise RuntimeError(f"parser did not produce predictions: {source}")
destination = scratch_root / label
shutil.copytree(source, destination)
_run(
[
str(python),
"src/evaluator.py",
"--prediction-root",
str(scratch_root),
"--engine",
label,
"--log-level",
"WARNING",
],
cwd=bench_dir,
)
with (destination / "evaluation.json").open(encoding="utf-8") as handle:
return json.load(handle)
def _print_report(comparison: dict[str, Any]) -> None:
print("\nMetric baseline candidate delta")
print("-------------------- ---------- ---------- ----------")
for key in SCORE_KEYS:
if key not in comparison["deltas"]:
continue
print(
f"{key:<20} {comparison['baseline'][key]:>10.6f} "
f"{comparison['candidate'][key]:>10.6f} "
f"{comparison['deltas'][key]:>+10.6f}"
)
if "reference" in comparison:
delta = comparison["candidate_vs_reference"].get("overall_mean")
reference = comparison["reference"].get("overall_mean")
reference_display = f"{reference:.6f}" if reference is not None else "n/a"
delta_display = f"{delta:+.6f}" if delta is not None else "n/a"
print(f"\nReference overall: {reference_display}; candidate delta: {delta_display}")
documents = comparison["documents"]
print(
"\nDocuments: "
f"{documents['improved']} improved, {documents['regressed']} regressed, "
f"{documents['unchanged']} unchanged ({documents['shared']} shared)"
)
for heading, key in (
("Largest improvements", "largest_improvements"),
("Largest regressions", "largest_regressions"),
):
print(f"\n{heading}:")
rows = documents[key]
if not rows:
print(" none")
for row in rows:
print(
f" {row['document_id']}: {row['delta']:+.6f} "
f"({row['baseline']:.6f} -> {row['candidate']:.6f})"
)
def _arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--bench-dir", type=Path, required=True)
parser.add_argument("--baseline", type=Path, required=True)
parser.add_argument("--candidate", type=Path, required=True)
parser.add_argument("--python", type=Path)
parser.add_argument("--reference-evaluation", type=Path)
parser.add_argument("--json-output", type=Path)
parser.add_argument("--top", type=_non_negative_int, default=10)
parser.add_argument("--min-overall-delta", type=_finite_float, default=0.0)
parser.add_argument("--max-document-regression", type=_non_negative_float)
parser.add_argument("--max-missing", type=_non_negative_int, default=0)
parser.add_argument("--require-reference-lead", action="store_true")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = _arguments(argv)
bench_dir = args.bench_dir.resolve()
baseline = args.baseline.resolve()
candidate = args.candidate.resolve()
# Keep the virtualenv launcher path intact. Resolving its symlink would
# invoke the underlying system interpreter without the benchmark's site
# packages.
python = (args.python or bench_dir / ".venv" / "bin" / "python").absolute()
for path, description in (
(bench_dir / "src" / "pdf_parser.py", "OpenDataLoader parser"),
(bench_dir / "src" / "evaluator.py", "OpenDataLoader evaluator"),
(baseline, "baseline binary"),
(candidate, "candidate binary"),
(python, "Python interpreter"),
):
if not path.exists():
raise SystemExit(f"{description} not found: {path}")
with tempfile.TemporaryDirectory(prefix="pdf-inspector-opendataloader-") as temporary:
scratch_root = Path(temporary)
baseline_evaluation = _run_engine(
bench_dir=bench_dir,
python=python,
binary=baseline,
label="baseline",
scratch_root=scratch_root,
)
candidate_evaluation = _run_engine(
bench_dir=bench_dir,
python=python,
binary=candidate,
label="candidate",
scratch_root=scratch_root,
)
reference = None
if args.reference_evaluation is not None:
with args.reference_evaluation.resolve().open(encoding="utf-8") as handle:
reference = json.load(handle)
comparison = compare_evaluations(
baseline_evaluation,
candidate_evaluation,
reference,
top=args.top,
)
_print_report(comparison)
if args.json_output is not None:
args.json_output.resolve().write_text(
json.dumps(comparison, indent=2) + "\n", encoding="utf-8"
)
failures = evaluate_gates(
comparison,
min_overall_delta=args.min_overall_delta,
max_document_regression=args.max_document_regression,
max_missing=args.max_missing,
require_reference_lead=args.require_reference_lead,
)
if failures:
print("\nBenchmark gate failed:", file=sys.stderr)
for failure in failures:
print(f" - {failure}", file=sys.stderr)
return 1
print("\nBenchmark gate passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-351
View File
@@ -1,351 +0,0 @@
#!/usr/bin/env python3
"""Compare pdf-inspector evidence with optional MuPDF structured text.
This is an experiment and diagnostic tool, not an extraction fallback. It runs
MuPDF's deterministic ``stext.json`` backend without OCR and highlights pages
where that backend exposes materially different text or layout evidence.
"""
from __future__ import annotations
import argparse
from collections import Counter
import json
from pathlib import Path
import re
import shutil
import subprocess
import sys
from typing import Any, Iterable
TOKEN_PATTERN = re.compile(r"[^\W_]+(?:[\u2019'][^\W_]+)*", re.UNICODE)
def _tokens(texts: Iterable[str]) -> Counter[str]:
tokens: Counter[str] = Counter()
for text in texts:
for token in TOKEN_PATTERN.findall(text.casefold()):
# Lone letters are frequently bullets, chart labels, or fragmented
# glyphs. Digits remain useful even when they are one character.
if len(token) > 1 or token.isdigit():
tokens[token] += 1
return tokens
def _repeated_x_anchors(xs: Iterable[float], *, tolerance: float = 4.0) -> int:
buckets = Counter(round(float(x) / tolerance) for x in xs)
return sum(count >= 3 for count in buckets.values())
def local_pages(payload: dict[str, Any]) -> dict[int, dict[str, Any]]:
"""Summarize positioned ``pdf2md --items-json`` evidence by page."""
pages: dict[int, dict[str, Any]] = {}
for item in payload.get("items", []):
page_number = int(item["page"])
page = pages.setdefault(
page_number,
{"texts": [], "xs": [], "text_items": 0, "image_items": 0},
)
if item.get("item_type") == "image":
page["image_items"] += 1
continue
text = str(item.get("text", ""))
if text.strip():
page["texts"].append(text)
page["xs"].append(float(item.get("x", 0.0)))
page["text_items"] += 1
return pages
def alternate_pages(
payload: dict[str, Any] | list[dict[str, Any]],
) -> dict[int, dict[str, Any]]:
"""Summarize MuPDF ``stext.json`` evidence by page."""
pages: dict[int, dict[str, Any]] = {}
raw_pages = payload if isinstance(payload, list) else payload.get("pages", [])
for index, raw_page in enumerate(raw_pages, start=1):
page_number = int(raw_page.get("number", index))
page = {
"texts": [],
"xs": [],
"text_blocks": 0,
"text_lines": 0,
"image_blocks": 0,
}
for block in raw_page.get("blocks", []):
if block.get("type") == "image":
page["image_blocks"] += 1
continue
if block.get("type") != "text":
continue
page["text_blocks"] += 1
for line in block.get("lines", []):
text = str(line.get("text", ""))
if text.strip():
page["texts"].append(text)
bbox = line.get("bbox", {})
page["xs"].append(float(bbox.get("x", line.get("x", 0.0))))
page["text_lines"] += 1
pages[page_number] = page
return pages
def compare_page(
local: dict[str, Any],
alternate: dict[str, Any],
*,
min_token_gain: int,
min_alternate_only_ratio: float,
min_anchor_gain: int,
) -> dict[str, Any]:
"""Compare semantic and coarse layout evidence for one page."""
local_tokens = _tokens(local.get("texts", []))
alternate_tokens = _tokens(alternate.get("texts", []))
shared = local_tokens & alternate_tokens
alternate_only = alternate_tokens - local_tokens
local_only = local_tokens - alternate_tokens
local_total = sum(local_tokens.values())
alternate_total = sum(alternate_tokens.values())
shared_total = sum(shared.values())
alternate_only_total = sum(alternate_only.values())
local_only_total = sum(local_only.values())
net_token_gain = alternate_total - local_total
alternate_only_ratio = alternate_only_total / max(alternate_total, 1)
local_anchors = _repeated_x_anchors(local.get("xs", []))
alternate_anchors = _repeated_x_anchors(alternate.get("xs", []))
anchor_gain = alternate_anchors - local_anchors
image_gain = int(alternate.get("image_blocks", 0)) - int(
local.get("image_items", 0)
)
reasons: list[str] = []
if local_total == 0 and alternate_total >= max(5, min_token_gain // 2):
reasons.append("local_text_empty")
elif (
net_token_gain >= min_token_gain
and alternate_only_ratio >= min_alternate_only_ratio
):
reasons.append("alternate_has_more_text")
if anchor_gain >= min_anchor_gain:
reasons.append("alternate_has_more_alignment_anchors")
if image_gain > 0:
reasons.append("alternate_has_more_image_blocks")
if reasons:
classification = "investigate_alternate_evidence"
elif local_total - alternate_total >= min_token_gain:
classification = "local_has_more_text"
elif alternate_only_total + local_only_total:
classification = "different_segmentation_or_decoding"
else:
classification = "equivalent_text_evidence"
return {
"classification": classification,
"reasons": reasons,
"tokens": {
"local": local_total,
"alternate": alternate_total,
"shared": shared_total,
"net_alternate_gain": net_token_gain,
"alternate_only": alternate_only_total,
"local_only": local_only_total,
"alternate_only_ratio": alternate_only_ratio,
"alternate_only_sample": sorted(alternate_only)[:12],
"local_only_sample": sorted(local_only)[:12],
},
"layout": {
"local_text_items": int(local.get("text_items", 0)),
"local_image_items": int(local.get("image_items", 0)),
"local_repeated_x_anchors": local_anchors,
"alternate_text_blocks": int(alternate.get("text_blocks", 0)),
"alternate_text_lines": int(alternate.get("text_lines", 0)),
"alternate_image_blocks": int(alternate.get("image_blocks", 0)),
"alternate_repeated_x_anchors": alternate_anchors,
},
}
def compare_documents(
local_payload: dict[str, Any],
alternate_payload: dict[str, Any] | list[dict[str, Any]],
*,
min_token_gain: int = 20,
min_alternate_only_ratio: float = 0.15,
min_anchor_gain: int = 2,
) -> dict[str, Any]:
"""Return a page-level evidence report for already extracted payloads."""
local = local_pages(local_payload)
alternate = alternate_pages(alternate_payload)
page_numbers = sorted(local.keys() | alternate.keys())
pages = []
for page_number in page_numbers:
result = compare_page(
local.get(page_number, {}),
alternate.get(page_number, {}),
min_token_gain=min_token_gain,
min_alternate_only_ratio=min_alternate_only_ratio,
min_anchor_gain=min_anchor_gain,
)
result["page"] = page_number
pages.append(result)
flagged = [
page
for page in pages
if page["classification"] == "investigate_alternate_evidence"
]
return {
"summary": {
"pages": len(pages),
"flagged_pages": len(flagged),
"flagged_page_numbers": [page["page"] for page in flagged],
"local_tokens": sum(page["tokens"]["local"] for page in pages),
"alternate_tokens": sum(page["tokens"]["alternate"] for page in pages),
"alternate_only_tokens": sum(
page["tokens"]["alternate_only"] for page in pages
),
},
"pages": pages,
}
def _json_command(command: list[str]) -> Any:
try:
completed = subprocess.run(
command,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
except subprocess.CalledProcessError as error:
detail = error.stderr.strip() or error.stdout.strip() or "no diagnostic output"
raise RuntimeError(f"command failed: {' '.join(command)}\n{detail}") from error
try:
return json.loads(completed.stdout)
except json.JSONDecodeError as error:
raise RuntimeError(
f"command did not return JSON: {' '.join(command)}: {error}"
) from error
def probe_pdf(
pdf: Path,
*,
pdf2md: Path,
mutool: Path,
min_token_gain: int,
min_alternate_only_ratio: float,
min_anchor_gain: int,
) -> dict[str, Any]:
local_payload = _json_command([str(pdf2md), str(pdf), "--items-json"])
# `stext.json` is MuPDF's native structured text output. The OCR formats
# are intentionally not used so this remains a deterministic no-model
# comparison.
alternate_payload = _json_command(
[str(mutool), "draw", "-q", "-F", "stext.json", "-o", "-", str(pdf)]
)
report = compare_documents(
local_payload,
alternate_payload,
min_token_gain=min_token_gain,
min_alternate_only_ratio=min_alternate_only_ratio,
min_anchor_gain=min_anchor_gain,
)
report["pdf"] = str(pdf)
return report
def _arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pdf", type=Path, nargs="+")
parser.add_argument("--pdf2md", type=Path, default=Path("target/release/pdf2md"))
parser.add_argument("--mutool", type=Path)
parser.add_argument("--json-output", type=Path)
parser.add_argument("--min-token-gain", type=int, default=20)
parser.add_argument("--min-alternate-only-ratio", type=float, default=0.15)
parser.add_argument("--min-anchor-gain", type=int, default=2)
return parser.parse_args(argv)
def _print_report(result: dict[str, Any]) -> None:
summary = result["summary"]
print(f"\n{result['pdf']}")
print(
f" {summary['flagged_pages']}/{summary['pages']} pages flagged; "
f"tokens local={summary['local_tokens']} alternate={summary['alternate_tokens']} "
f"alternate-only={summary['alternate_only_tokens']}"
)
for page in result["pages"]:
if page["classification"] != "investigate_alternate_evidence":
continue
reasons = ", ".join(page["reasons"])
tokens = page["tokens"]
print(
f" page {page['page']}: {reasons}; "
f"net tokens={tokens['net_alternate_gain']:+d}, "
f"alternate-only={tokens['alternate_only']}"
)
def main(argv: list[str] | None = None) -> int:
args = _arguments(argv)
pdf2md = args.pdf2md.absolute()
mutool = args.mutool or (Path(found) if (found := shutil.which("mutool")) else None)
if not pdf2md.is_file():
print(f"error: pdf2md binary not found: {pdf2md}", file=sys.stderr)
return 2
if mutool is None or not mutool.is_file():
print("error: mutool not found; install MuPDF or pass --mutool", file=sys.stderr)
return 2
if (
args.min_token_gain < 0
or args.min_anchor_gain < 0
or not 0.0 <= args.min_alternate_only_ratio <= 1.0
):
print("error: thresholds must be non-negative and ratio must be in [0, 1]", file=sys.stderr)
return 2
results = []
for pdf in args.pdf:
path = pdf.absolute()
if not path.is_file():
print(f"error: PDF not found: {path}", file=sys.stderr)
return 2
try:
result = probe_pdf(
path,
pdf2md=pdf2md,
mutool=mutool,
min_token_gain=args.min_token_gain,
min_alternate_only_ratio=args.min_alternate_only_ratio,
min_anchor_gain=args.min_anchor_gain,
)
except RuntimeError as error:
print(f"error: {error}", file=sys.stderr)
return 1
results.append(result)
_print_report(result)
payload = {
"schema_version": 1,
"experiment": "optional_mupdf_stext_evidence",
"ocr": False,
"thresholds": {
"min_token_gain": args.min_token_gain,
"min_alternate_only_ratio": args.min_alternate_only_ratio,
"min_anchor_gain": args.min_anchor_gain,
},
"documents": results,
}
if args.json_output:
args.json_output.parent.mkdir(parents=True, exist_ok=True)
args.json_output.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-203
View File
@@ -1,203 +0,0 @@
import io
import json
import sys
import tempfile
import unittest
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from bench_opendataloader import (
_arguments,
_print_report,
_run_engine,
compare_evaluations,
evaluate_gates,
)
def evaluation(overall, documents, *, missing=0):
return {
"metrics": {
"score": {
"overall_mean": overall,
"nid_mean": overall + 0.01,
},
"missing_predictions": missing,
},
"documents": [
{
"document_id": document_id,
"scores": {"overall": score},
}
for document_id, score in documents.items()
],
}
class ComparisonTests(unittest.TestCase):
def test_reports_metric_and_document_deltas(self):
baseline = evaluation(0.80, {"a": 0.8, "b": 0.6, "c": 0.7})
candidate = evaluation(0.82, {"a": 0.9, "b": 0.5, "c": 0.7})
result = compare_evaluations(baseline, candidate, top=1)
self.assertAlmostEqual(result["deltas"]["overall_mean"], 0.02)
self.assertEqual(result["documents"]["improved"], 1)
self.assertEqual(result["documents"]["regressed"], 1)
self.assertEqual(result["documents"]["unchanged"], 1)
self.assertEqual(
result["documents"]["largest_improvements"][0]["document_id"], "a"
)
self.assertEqual(
result["documents"]["largest_regressions"][0]["document_id"], "b"
)
def test_reference_delta_is_reported(self):
baseline = evaluation(0.80, {})
candidate = evaluation(0.82, {})
reference = evaluation(0.81, {})
result = compare_evaluations(baseline, candidate, reference)
self.assertAlmostEqual(
result["candidate_vs_reference"]["overall_mean"], 0.01
)
def test_gates_cover_aggregate_document_missing_and_reference(self):
comparison = compare_evaluations(
evaluation(0.80, {"a": 0.8}),
evaluation(0.79, {"a": 0.7}, missing=1),
evaluation(0.81, {}),
)
failures = evaluate_gates(
comparison,
min_overall_delta=0.0,
max_document_regression=0.05,
max_missing=0,
require_reference_lead=True,
)
self.assertEqual(len(failures), 4)
def test_regression_gate_is_independent_of_report_limit(self):
comparison = compare_evaluations(
evaluation(0.80, {"a": 0.8}),
evaluation(0.80, {"a": 0.7}),
top=0,
)
failures = evaluate_gates(
comparison,
min_overall_delta=0.0,
max_document_regression=0.05,
max_missing=0,
require_reference_lead=False,
)
self.assertEqual(len(failures), 1)
self.assertIn("largest document regression", failures[0])
def test_report_handles_reference_without_overall_score(self):
result = compare_evaluations(
evaluation(0.80, {}),
evaluation(0.82, {}),
{"metrics": {"score": {"nid_mean": 0.81}}},
)
output = io.StringIO()
with redirect_stdout(output):
_print_report(result)
self.assertIn("Reference overall: n/a; candidate delta: n/a", output.getvalue())
def test_reference_gate_reports_missing_score_as_unavailable(self):
comparison = compare_evaluations(
evaluation(0.80, {}),
evaluation(0.82, {}),
)
failures = evaluate_gates(
comparison,
min_overall_delta=0.0,
max_document_regression=None,
max_missing=0,
require_reference_lead=True,
)
self.assertEqual(failures, ["reference overall score is unavailable"])
def test_arguments_reject_negative_counts_and_allow_zero_top(self):
required = [
"--bench-dir",
".",
"--baseline",
"baseline",
"--candidate",
"candidate",
]
self.assertEqual(_arguments(required + ["--top", "0"]).top, 0)
for option in ("--top", "--max-document-regression", "--max-missing"):
with self.subTest(option=option), redirect_stderr(io.StringIO()):
with self.assertRaises(SystemExit):
_arguments(required + [option, "-1"])
def test_arguments_reject_nonfinite_float_thresholds(self):
required = [
"--bench-dir",
".",
"--baseline",
"baseline",
"--candidate",
"candidate",
]
for option in ("--min-overall-delta", "--max-document-regression"):
for value in ("nan", "inf", "-inf"):
with self.subTest(option=option, value=value), redirect_stderr(
io.StringIO()
):
with self.assertRaises(SystemExit):
_arguments(required + [option, value])
def test_run_engine_clears_stale_predictions_before_parser(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
bench_dir = root / "bench"
source = bench_dir / "prediction" / "pdf-inspector"
source.mkdir(parents=True)
(source / "stale.md").write_text("stale", encoding="utf-8")
scratch = root / "scratch"
scratch.mkdir()
def fake_run(command, *, cwd, env=None):
if any(part.endswith("pdf_parser.py") for part in command):
self.assertFalse(source.exists())
(source / "markdown").mkdir(parents=True)
(source / "markdown" / "new.md").write_text(
"new", encoding="utf-8"
)
else:
destination = scratch / "candidate"
(destination / "evaluation.json").write_text(
json.dumps(evaluation(0.82, {})), encoding="utf-8"
)
with patch("bench_opendataloader._run", side_effect=fake_run):
result = _run_engine(
bench_dir=bench_dir,
python=Path("python"),
binary=Path("pdf2md"),
label="candidate",
scratch_root=scratch,
)
self.assertEqual(result["metrics"]["score"]["overall_mean"], 0.82)
self.assertFalse((source / "stale.md").exists())
self.assertFalse((scratch / "candidate" / "stale.md").exists())
if __name__ == "__main__":
unittest.main()
@@ -1,103 +0,0 @@
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from probe_backend_evidence import compare_documents
def local_payload(items):
return {"items": items}
def item(page, text, x=10, item_type="text"):
return {"page": page, "text": text, "x": x, "item_type": item_type}
def alternate_payload(pages):
return {"pages": pages}
def page(lines, *, images=0):
blocks = [
{
"type": "text",
"lines": [
{"text": text, "bbox": {"x": x, "y": index * 10, "w": 80, "h": 8}}
for index, (text, x) in enumerate(lines)
],
}
]
blocks.extend({"type": "image"} for _ in range(images))
return {"blocks": blocks}
class EvidenceComparisonTests(unittest.TestCase):
def test_accepts_real_top_level_page_array(self):
local = local_payload([item(1, "alpha beta")])
alternate = [page([("alpha beta gamma", 10)])]
result = compare_documents(local, alternate)["pages"][0]
self.assertEqual(result["tokens"]["alternate"], 3)
self.assertEqual(result["tokens"]["net_alternate_gain"], 1)
def test_flags_material_alternate_text_gain(self):
local = local_payload([item(1, "alpha beta")])
alternate = alternate_payload(
[page([("alpha beta gamma delta epsilon zeta", 10)])]
)
report = compare_documents(
local,
alternate,
min_token_gain=3,
min_alternate_only_ratio=0.2,
)
result = report["pages"][0]
self.assertEqual(result["classification"], "investigate_alternate_evidence")
self.assertIn("alternate_has_more_text", result["reasons"])
self.assertEqual(result["tokens"]["net_alternate_gain"], 4)
def test_repeated_alignment_and_image_evidence_are_reported(self):
local = local_payload([item(1, "one two", 10)])
alternate = alternate_payload(
[
page(
[
("one two", 10),
("row three", 100),
("row four", 100),
("row five", 100),
],
images=1,
)
]
)
result = compare_documents(
local,
alternate,
min_token_gain=99,
min_anchor_gain=1,
)["pages"][0]
self.assertIn("alternate_has_more_alignment_anchors", result["reasons"])
self.assertIn("alternate_has_more_image_blocks", result["reasons"])
self.assertEqual(result["layout"]["alternate_repeated_x_anchors"], 1)
def test_token_segmentation_difference_does_not_imply_more_evidence(self):
local = local_payload([item(1, "Revenue 2025")])
alternate = alternate_payload([page([("Revenue 2024", 10)])])
result = compare_documents(local, alternate, min_token_gain=2)["pages"][0]
self.assertEqual(result["classification"], "different_segmentation_or_decoding")
self.assertEqual(result["reasons"], [])
self.assertEqual(result["tokens"]["alternate_only_sample"], ["2024"])
if __name__ == "__main__":
unittest.main()
-111
View File
@@ -1,111 +0,0 @@
import json
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from version import PLATFORM_PACKAGES, check_versions, set_versions
class VersionTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.root = Path(self.temporary.name)
(self.root / "napi").mkdir()
(self.root / "site").mkdir()
(self.root / "wasm").mkdir()
self._write_manifest("Cargo.toml", "package", "0.1.0")
self._write_manifest("pyproject.toml", "project", "0.1.0")
self._write_manifest("napi/Cargo.toml", "package", "0.1.0")
self._write_manifest("wasm/Cargo.toml", "package", "0.1.0")
package = {
"name": "@firecrawl/pdf-inspector",
"version": "0.1.0",
"optionalDependencies": {
dependency: "0.1.0" for dependency in PLATFORM_PACKAGES
},
}
(self.root / "napi/package.json").write_text(
json.dumps(package), encoding="utf-8"
)
(self.root / "napi/bun.lock").write_text(
"\n".join(
f' "{dependency}": "0.1.0",'
for dependency in PLATFORM_PACKAGES
)
+ "\n",
encoding="utf-8",
)
(self.root / "site/index.html").write_text(
'https://cdn.jsdelivr.net/npm/@firecrawl/pdf-inspector-wasm@0.1.0/'
'pdf_inspector_wasm.js\n',
encoding="utf-8",
)
self._write_lock(
"napi/Cargo.lock", ("pdf-inspector", "pdf-inspector-napi")
)
self._write_lock(
"wasm/Cargo.lock", ("pdf-inspector", "pdf-inspector-wasm")
)
def tearDown(self):
self.temporary.cleanup()
def _write_manifest(self, relative, section, version):
(self.root / relative).write_text(
f'[{section}]\nname = "fixture"\nversion = "{version}"\n',
encoding="utf-8",
)
def _write_lock(self, relative, packages):
content = "\n".join(
f'[[package]]\nname = "{package}"\nversion = "0.1.0"\n'
for package in packages
)
(self.root / relative).write_text(content, encoding="utf-8")
def test_updates_every_version_location(self):
set_versions("1.14.0", self.root)
self.assertEqual(check_versions(self.root), "1.14.0")
def test_reports_a_divergent_package(self):
self._write_manifest("wasm/Cargo.toml", "package", "0.2.0")
with self.assertRaisesRegex(ValueError, "WASM package: 0.2.0"):
check_versions(self.root)
def test_rejects_an_invalid_version(self):
with self.assertRaisesRegex(ValueError, "Invalid semantic version"):
set_versions("next", self.root)
def test_rejects_numeric_prerelease_with_leading_zero(self):
before = (self.root / "Cargo.toml").read_text(encoding="utf-8")
with self.assertRaisesRegex(ValueError, "Invalid semantic version"):
set_versions("1.2.3-01", self.root)
self.assertEqual(
(self.root / "Cargo.toml").read_text(encoding="utf-8"), before
)
def test_preflight_failure_does_not_partially_update(self):
before = (self.root / "Cargo.toml").read_text(encoding="utf-8")
(self.root / "site/index.html").write_text(
"missing module URL\n", encoding="utf-8"
)
with self.assertRaisesRegex(ValueError, "Missing pinned WASM package URL"):
set_versions("1.14.0", self.root)
self.assertEqual(
(self.root / "Cargo.toml").read_text(encoding="utf-8"), before
)
if __name__ == "__main__":
unittest.main()
-262
View File
@@ -1,262 +0,0 @@
#!/usr/bin/env python3
"""Keep every pdf-inspector package on one release version."""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
PRERELEASE_IDENTIFIER = (
r"(?:0|[1-9]\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)"
)
SEMVER = re.compile(
r"^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
rf"(?:-{PRERELEASE_IDENTIFIER}(?:\.{PRERELEASE_IDENTIFIER})*)?"
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$"
)
VERSION_LINE = re.compile(r'^(\s*version\s*=\s*")[^"]+(".*)$')
SECTION_LINE = re.compile(r"^\s*\[([^]]+)]\s*$")
PLATFORM_PACKAGES = (
"@firecrawl/pdf-inspector-linux-x64-gnu",
"@firecrawl/pdf-inspector-linux-x64-musl",
"@firecrawl/pdf-inspector-linux-arm64-gnu",
"@firecrawl/pdf-inspector-linux-arm64-musl",
"@firecrawl/pdf-inspector-darwin-arm64",
"@firecrawl/pdf-inspector-win32-x64-msvc",
)
TOML_VERSIONS = (
("Rust crate", Path("Cargo.toml"), "package"),
("Python package", Path("pyproject.toml"), "project"),
("NAPI crate", Path("napi/Cargo.toml"), "package"),
("WASM package", Path("wasm/Cargo.toml"), "package"),
)
LOCK_VERSIONS = (
("NAPI lock: core", Path("napi/Cargo.lock"), "pdf-inspector"),
("NAPI lock: binding", Path("napi/Cargo.lock"), "pdf-inspector-napi"),
("WASM lock: core", Path("wasm/Cargo.lock"), "pdf-inspector"),
("WASM lock: binding", Path("wasm/Cargo.lock"), "pdf-inspector-wasm"),
)
SITE_WASM_VERSION = re.compile(
r"(@firecrawl/pdf-inspector-wasm@)([^/\"]+)(/pdf_inspector_wasm\.js)"
)
def _read_section_version(path: Path, section: str) -> str:
active = False
for line in path.read_text(encoding="utf-8").splitlines():
section_match = SECTION_LINE.match(line)
if section_match:
active = section_match.group(1) == section
elif active:
version_match = VERSION_LINE.match(line)
if version_match:
return line.split('"', 2)[1]
raise ValueError(f"No version found in [{section}] of {path}")
def _write_section_version(path: Path, section: str, version: str) -> None:
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
active = False
for index, line in enumerate(lines):
section_match = SECTION_LINE.match(line)
if section_match:
active = section_match.group(1) == section
elif active:
version_match = VERSION_LINE.match(line)
if version_match:
newline = "\n" if line.endswith("\n") else ""
replacement = (
f"{version_match.group(1)}{version}"
f"{version_match.group(2).rstrip()}"
)
lines[index] = (
f"{replacement}{newline}"
)
path.write_text("".join(lines), encoding="utf-8")
return
raise ValueError(f"No version found in [{section}] of {path}")
def _package_block(lines: list[str], package: str) -> tuple[int, int]:
for start, line in enumerate(lines):
if line.strip() != "[[package]]":
continue
end = next(
(
index
for index in range(start + 1, len(lines))
if lines[index].strip() == "[[package]]"
),
len(lines),
)
if any(line.strip() == f'name = "{package}"' for line in lines[start:end]):
return start, end
raise ValueError(f"No lockfile entry found for {package}")
def _read_lock_version(path: Path, package: str) -> str:
lines = path.read_text(encoding="utf-8").splitlines()
start, end = _package_block(lines, package)
for line in lines[start:end]:
version_match = VERSION_LINE.match(line)
if version_match:
return line.split('"', 2)[1]
raise ValueError(f"No version found for {package} in {path}")
def _write_lock_version(path: Path, package: str, version: str) -> None:
lines = path.read_text(encoding="utf-8").splitlines(keepends=True)
start, end = _package_block(lines, package)
for index in range(start, end):
version_match = VERSION_LINE.match(lines[index])
if version_match:
newline = "\n" if lines[index].endswith("\n") else ""
lines[index] = (
f'{version_match.group(1)}{version}{version_match.group(2).rstrip()}'
f"{newline}"
)
path.write_text("".join(lines), encoding="utf-8")
return
raise ValueError(f"No version found for {package} in {path}")
def _node_versions(root: Path) -> dict[str, str]:
package = json.loads((root / "napi/package.json").read_text(encoding="utf-8"))
versions = {"Node package": package["version"]}
optional = package.get("optionalDependencies", {})
for dependency in PLATFORM_PACKAGES:
if dependency not in optional:
raise ValueError(f"Missing Node optional dependency: {dependency}")
versions[f"Node optional dependency: {dependency}"] = optional[dependency]
return versions
def _bun_versions(root: Path) -> dict[str, str]:
text = (root / "napi/bun.lock").read_text(encoding="utf-8")
versions = {}
for dependency in PLATFORM_PACKAGES:
match = re.search(
rf'"{re.escape(dependency)}": "([^"]+)"[,]', text
)
if not match:
raise ValueError(f"Missing Bun lock dependency: {dependency}")
versions[f"Bun lock: {dependency}"] = match.group(1)
return versions
def _site_wasm_version(root: Path) -> str:
text = (root / "site/index.html").read_text(encoding="utf-8")
match = SITE_WASM_VERSION.search(text)
if not match:
raise ValueError("Missing pinned WASM package URL in site/index.html")
return match.group(2)
def package_versions(root: Path = ROOT) -> dict[str, str]:
versions = {
label: _read_section_version(root / relative, section)
for label, relative, section in TOML_VERSIONS
}
versions.update(_node_versions(root))
versions.update(_bun_versions(root))
versions["Website WASM module"] = _site_wasm_version(root)
versions.update(
{
label: _read_lock_version(root / relative, package)
for label, relative, package in LOCK_VERSIONS
}
)
return versions
def check_versions(root: Path = ROOT) -> str:
versions = package_versions(root)
expected = versions["Rust crate"]
if not SEMVER.fullmatch(expected):
raise ValueError(f"Rust crate has an invalid semantic version: {expected}")
mismatches = {
label: version for label, version in versions.items() if version != expected
}
if mismatches:
details = "\n".join(
f" - {label}: {version}" for label, version in mismatches.items()
)
raise ValueError(f"Expected every package to use {expected}:\n{details}")
return expected
def set_versions(version: str, root: Path = ROOT) -> None:
if not SEMVER.fullmatch(version):
raise ValueError(f"Invalid semantic version: {version}")
# Validate every expected location before writing the first file. This
# prevents a stale manifest or generated file from leaving a partial bump.
package_versions(root)
for _, relative, section in TOML_VERSIONS:
_write_section_version(root / relative, section, version)
package_path = root / "napi/package.json"
package = json.loads(package_path.read_text(encoding="utf-8"))
package["version"] = version
optional = package.get("optionalDependencies", {})
for dependency in PLATFORM_PACKAGES:
if dependency not in optional:
raise ValueError(f"Missing Node optional dependency: {dependency}")
optional[dependency] = version
package_path.write_text(json.dumps(package, indent=2) + "\n", encoding="utf-8")
bun_path = root / "napi/bun.lock"
bun_text = bun_path.read_text(encoding="utf-8")
for dependency in PLATFORM_PACKAGES:
pattern = rf'("{re.escape(dependency)}": ")[^"]+("[,])'
bun_text, count = re.subn(
pattern, rf"\g<1>{version}\g<2>", bun_text, count=1
)
if count != 1:
raise ValueError(f"Missing Bun lock dependency: {dependency}")
bun_path.write_text(bun_text, encoding="utf-8")
site_path = root / "site/index.html"
site_text = site_path.read_text(encoding="utf-8")
site_text, count = SITE_WASM_VERSION.subn(
rf"\g<1>{version}\g<3>", site_text, count=1
)
if count != 1:
raise ValueError("Missing pinned WASM package URL in site/index.html")
site_path.write_text(site_text, encoding="utf-8")
for _, relative, package_name in LOCK_VERSIONS:
_write_lock_version(root / relative, package_name, version)
check_versions(root)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("version", nargs="?", help="new shared semantic version")
parser.add_argument(
"--check", action="store_true", help="fail if package versions have diverged"
)
arguments = parser.parse_args()
if arguments.check == bool(arguments.version):
parser.error("provide either a version or --check")
try:
if arguments.check:
version = check_versions()
print(f"All packages use {version}")
else:
set_versions(arguments.version)
print(f"Updated all packages to {arguments.version}")
except ValueError as error:
parser.exit(1, f"{error}\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-3
View File
@@ -1,3 +0,0 @@
<svg width="200" height="284" viewBox="0 0 200 284" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M166.862 90.7716C155.812 94.0514 147.483 101.471 141.383 109.53C140.073 111.26 137.343 109.96 137.863 107.841C149.543 59.8136 134.113 19.896 86.0157 0.247269C83.5758 -0.752669 81.0359 1.43719 81.6759 3.99704C103.555 91.8416 11.5294 84.432 23.1588 184.016C23.3588 185.726 21.4389 186.896 20.039 185.896C15.6792 182.766 10.8095 176.236 7.46963 171.647C6.48968 170.297 4.36978 170.677 3.9198 172.287C1.25994 181.906 0 190.965 0 199.965C0 234.963 17.9891 265.771 45.2177 283.63C46.7777 284.65 48.7776 283.19 48.2476 281.4C46.8477 276.7 46.0577 271.74 45.9977 266.611C45.9977 263.461 46.1977 260.241 46.6877 257.241C47.8276 249.702 50.4475 242.522 54.8473 235.983C69.9365 213.334 100.185 191.455 95.3552 161.747C95.0453 159.867 97.2651 158.627 98.6651 159.917C119.974 179.386 124.194 205.575 120.694 229.063C120.394 231.103 122.954 232.193 124.244 230.593C127.504 226.513 131.483 222.933 135.813 220.244C136.893 219.574 138.333 220.084 138.743 221.284C141.153 228.293 144.733 234.873 148.113 241.452C152.152 249.362 154.302 258.391 153.962 267.951C153.792 272.6 153.022 277.1 151.732 281.38C151.182 283.19 153.162 284.7 154.752 283.66C182.001 265.801 200 234.993 200 199.975C200 187.806 197.87 175.876 193.84 164.697C185.391 141.248 163.952 123.64 169.372 93.0815C169.632 91.6216 168.282 90.3517 166.862 90.7716Z" fill="#FA5D19" style="fill:#FA5D19;fill:color(display-p3 0.9816 0.3634 0.0984);fill-opacity:1;"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

-12
View File
@@ -1,12 +0,0 @@
<svg width="172" height="40" viewBox="0 0 172 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M23.3606 12.8281C21.8137 13.2873 20.6476 14.3261 19.7936 15.4544C19.6102 15.6966 19.228 15.5146 19.3008 15.2178C20.936 8.49401 18.7759 2.90556 12.0422 0.154735C11.7006 0.0147436 11.345 0.321324 11.4346 0.679702C14.4977 12.9779 1.61412 11.9406 3.24224 25.8823C3.27024 26.1217 3.00145 26.2855 2.80546 26.1455C2.19509 25.7073 1.51332 24.7932 1.04575 24.1506C0.908555 23.9616 0.611769 24.0148 0.548773 24.2402C0.176391 25.5869 0 26.8553 0 28.1152C0 33.0149 2.51847 37.328 6.33048 39.8283C6.54887 39.9711 6.82886 39.7667 6.75466 39.5161C6.55867 38.8581 6.44808 38.1638 6.43968 37.4456C6.43968 37.0046 6.46768 36.5539 6.53627 36.1339C6.69587 35.0784 7.06265 34.0732 7.67862 33.1577C9.79111 29.9869 14.0259 26.9239 13.3497 22.7647C13.3063 22.5015 13.6171 22.328 13.8131 22.5085C16.7964 25.2342 17.3871 28.9005 16.8972 32.1889C16.8552 32.4745 17.2135 32.6271 17.3941 32.4031C17.8505 31.832 18.4077 31.3308 19.0138 30.9542C19.165 30.8604 19.3666 30.9318 19.424 31.0998C19.7614 32.0811 20.2626 33.0023 20.7358 33.9234C21.3013 35.0308 21.6023 36.2949 21.5547 37.6332C21.5309 38.2842 21.4231 38.9141 21.2425 39.5133C21.1655 39.7667 21.4427 39.9781 21.6653 39.8325C25.4801 37.3322 28 33.0191 28 28.1166C28 26.4129 27.7018 24.7428 27.1376 23.1777C25.9547 19.8949 22.9533 17.4297 23.712 13.1515C23.7484 12.9471 23.5594 12.7693 23.3606 12.8281Z" fill="#262626" style="fill:#262626;fill:color(display-p3 0.1500 0.1500 0.1500);fill-opacity:1;"/>
<path d="M41 34.0521V10.9618H55.7586V14.3264H44.7969V21.0226H53.8436V24.2882H44.7969V34.0521H41Z" fill="#262626" style="fill:#262626;fill:color(display-p3 0.1500 0.1500 0.1500);fill-opacity:1;"/>
<path d="M59.9569 14.7882C58.7352 14.7882 57.7777 13.8976 57.7777 12.6441C57.7777 11.3906 58.7352 10.5 59.9569 10.5C61.1785 10.5 62.136 11.3906 62.136 12.6441C62.136 13.8976 61.1785 14.7882 59.9569 14.7882ZM58.1409 34.0521V17.1632H61.7068V34.0521H58.1409Z" fill="#262626" style="fill:#262626;fill:color(display-p3 0.1500 0.1500 0.1500);fill-opacity:1;"/>
<path d="M73.5885 17.1632H74.3809V20.4948H72.796C69.6264 20.4948 68.6029 22.9687 68.6029 25.5747V34.0521H65.0371V17.1632H68.2067L68.6029 19.7031C69.4613 18.2847 70.815 17.1632 73.5885 17.1632Z" fill="#262626" style="fill:#262626;fill:color(display-p3 0.1500 0.1500 0.1500);fill-opacity:1;"/>
<path d="M83.632 34.25C78.3163 34.25 74.9816 30.8194 74.9816 25.6406C74.9816 20.4288 78.3163 16.9653 83.3019 16.9653C88.1884 16.9653 91.457 20.066 91.5561 25.0139C91.5561 25.4427 91.5231 25.9045 91.457 26.3663H78.7125V26.5972C78.8116 29.467 80.6275 31.3472 83.4339 31.3472C85.613 31.3472 87.1979 30.2587 87.6931 28.3785H91.2589C90.6646 31.7101 87.8252 34.25 83.632 34.25ZM78.8446 23.7604H87.8582C87.561 21.2535 85.8112 19.8351 83.3349 19.8351C81.0567 19.8351 79.1087 21.3524 78.8446 23.7604Z" fill="#262626" style="fill:#262626;fill:color(display-p3 0.1500 0.1500 0.1500);fill-opacity:1;"/>
<path d="M102.033 34.25C96.9151 34.25 93.6465 30.9184 93.6465 25.6406C93.6465 20.4288 97.0142 16.9653 102.132 16.9653C106.49 16.9653 109.197 19.3733 109.891 23.1997H106.16C105.698 21.2205 104.278 20 102.066 20C99.1933 20 97.3113 22.309 97.3113 25.6406C97.3113 28.9392 99.1933 31.2153 102.066 31.2153C104.245 31.2153 105.698 29.9618 106.127 28.0156H109.891C109.23 31.842 106.358 34.25 102.033 34.25Z" fill="#262626" style="fill:#262626;fill:color(display-p3 0.1500 0.1500 0.1500);fill-opacity:1;"/>
<path d="M121.006 17.1632H121.799V20.4948H120.214C117.044 20.4948 116.021 22.9687 116.021 25.5747V34.0521H112.455V17.1632H115.625L116.021 19.7031C116.879 18.2847 118.233 17.1632 121.006 17.1632Z" fill="#262626" style="fill:#262626;fill:color(display-p3 0.1500 0.1500 0.1500);fill-opacity:1;"/>
<path d="M130.614 16.9653C135.104 16.9653 137.679 19.1094 137.679 23.1007V34.0521H134.576L134.279 31.6441C133.123 33.1615 131.505 34.25 128.831 34.25C125.133 34.25 122.657 32.4358 122.657 29.3021C122.657 25.8385 125.166 23.8924 129.92 23.8924H134.147V22.8698C134.147 20.9896 132.793 19.8351 130.449 19.8351C128.336 19.8351 126.916 20.8247 126.652 22.309H123.152C123.515 19.0104 126.355 16.9653 130.614 16.9653ZM129.425 31.4792C132.397 31.4792 134.114 29.7309 134.147 27.125V26.5312H129.722C127.51 26.5312 126.289 27.3559 126.289 29.0712C126.289 30.4896 127.477 31.4792 129.425 31.4792Z" fill="#262626" style="fill:#262626;fill:color(display-p3 0.1500 0.1500 0.1500);fill-opacity:1;"/>
<path d="M144.653 34.0521L139.139 17.1632H142.903L146.766 30.0937L150.629 17.1632H153.897L157.595 30.0937L161.59 17.1632H165.222L159.609 34.0521H155.779L152.214 22.5729L148.516 34.0521H144.653Z" fill="#262626" style="fill:#262626;fill:color(display-p3 0.1500 0.1500 0.1500);fill-opacity:1;"/>
<path d="M166.934 34.0521V10.9618H170.5V34.0521H166.934Z" fill="#262626" style="fill:#262626;fill:color(display-p3 0.1500 0.1500 0.1500);fill-opacity:1;"/>
</svg>

Before

Width:  |  Height:  |  Size: 4.8 KiB

-1172
View File
File diff suppressed because it is too large Load Diff
+2 -44
View File
@@ -11,37 +11,6 @@ use std::process;
use std::time::Instant;
/// Escape a string for embedding in a JSON string value.
fn format_detector_ocr_reasons(reasons: &std::collections::BTreeMap<u32, Vec<String>>) -> String {
reasons
.iter()
.map(|(page, page_reasons)| {
let reasons_json = page_reasons
.iter()
.map(|reason| format!(r#""{}""#, json_escape(reason)))
.collect::<Vec<_>>()
.join(",");
format!(r#"{{"page":{},"reasons":[{}]}}"#, page, reasons_json)
})
.collect::<Vec<_>>()
.join(",")
}
fn format_ocr_reasons_by_page(reasons: &[pdf_inspector::PageOcrReasons]) -> String {
reasons
.iter()
.map(|entry| {
let reasons_json = entry
.reasons
.iter()
.map(|reason| format!(r#""{}""#, json_escape(reason)))
.collect::<Vec<_>>()
.join(",");
format!(r#"{{"page":{},"reasons":[{}]}}"#, entry.page, reasons_json)
})
.collect::<Vec<_>>()
.join(",")
}
fn json_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 16);
for ch in s.chars() {
@@ -63,7 +32,6 @@ fn json_escape(s: &str) -> String {
}
fn main() {
#[cfg(not(target_arch = "wasm32"))]
env_logger::init();
let args: Vec<String> = env::args().collect();
@@ -149,13 +117,11 @@ fn run_analyze(pdf_path: &str, json_output: bool, start: Instant) {
.iter()
.map(|p| p.to_string())
.collect();
let ocr_reasons = format_ocr_reasons_by_page(&result.ocr_reasons_by_page);
println!(
r#"{{"pdf_type":"{}","page_count":{},"pages_needing_ocr":[{}],"ocr_reasons_by_page":[{}],"is_complex":{},"pages_with_tables":[{}],"pages_with_columns":[{}],"detection_time_ms":{}}}"#,
r#"{{"pdf_type":"{}","page_count":{},"pages_needing_ocr":[{}],"is_complex":{},"pages_with_tables":[{}],"pages_with_columns":[{}],"detection_time_ms":{}}}"#,
pdf_type_str(&result.pdf_type),
result.page_count,
ocr_pages.join(","),
ocr_reasons,
result.layout.is_complex,
table_pages.join(","),
col_pages.join(","),
@@ -178,9 +144,6 @@ fn run_analyze(pdf_path: &str, json_output: bool, start: Instant) {
println!("Page count: {}", result.page_count);
if !result.pages_needing_ocr.is_empty() {
println!("Pages needing OCR: {:?}", result.pages_needing_ocr);
for entry in &result.ocr_reasons_by_page {
println!(" page {}: {}", entry.page, entry.reasons.join(", "));
}
}
println!();
if result.layout.is_complex {
@@ -221,9 +184,8 @@ fn run_detect_only(pdf_path: &str, json_output: bool, start: Instant) {
.iter()
.map(|p| p.to_string())
.collect();
let ocr_reasons = format_detector_ocr_reasons(&result.ocr_reasons_by_page);
println!(
r#"{{"pdf_type":"{}","page_count":{},"pages_sampled":{},"pages_with_text":{},"confidence":{:.2},"title":{},"ocr_recommended":{},"pages_needing_ocr":[{}],"ocr_reasons_by_page":[{}],"detection_time_ms":{}}}"#,
r#"{{"pdf_type":"{}","page_count":{},"pages_sampled":{},"pages_with_text":{},"confidence":{:.2},"title":{},"ocr_recommended":{},"pages_needing_ocr":[{}],"detection_time_ms":{}}}"#,
pdf_type_str(&result.pdf_type),
result.page_count,
result.pages_sampled,
@@ -236,7 +198,6 @@ fn run_detect_only(pdf_path: &str, json_output: bool, start: Instant) {
.unwrap_or_else(|| "null".to_string()),
result.ocr_recommended,
ocr_pages.join(","),
ocr_reasons,
elapsed.as_millis()
);
} else {
@@ -271,9 +232,6 @@ fn run_detect_only(pdf_path: &str, json_output: bool, start: Instant) {
result.pages_needing_ocr, result.page_count
);
}
for (page, reasons) in &result.ocr_reasons_by_page {
println!(" page {}: {}", page, reasons.join(", "));
}
}
if let Some(title) = &result.title {
println!("Title: {}", title);
+3 -449
View File
@@ -1,15 +1,6 @@
//! CLI tool for PDF to Markdown conversion
use pdf_inspector::extractor::ItemType;
#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
use pdf_inspector::vision::{
process_pdf_with_ocr, ModelDownloadPolicy, OcrMode, OcrOptions, OcrPdfOptions, OcrPdfResult,
PageContentSource, RenderOptions,
};
use pdf_inspector::{
extract_text_with_positions_pages_with_password, process_pdf_with_options, LayoutComplexity,
PdfOptions, PdfType, ProcessMode, TextItem,
};
use pdf_inspector::{process_pdf_with_options, LayoutComplexity, PdfOptions, PdfType, ProcessMode};
use std::collections::HashSet;
use std::env;
use std::fmt::Write;
@@ -40,265 +31,6 @@ fn json_escape(s: &str) -> String {
out
}
fn format_ocr_reasons_by_page(reasons: &[pdf_inspector::PageOcrReasons]) -> String {
reasons
.iter()
.map(|entry| {
let reasons_json = entry
.reasons
.iter()
.map(|reason| format!(r#""{}""#, json_escape(reason)))
.collect::<Vec<_>>()
.join(",");
format!(r#"{{"page":{},"reasons":[{}]}}"#, entry.page, reasons_json)
})
.collect::<Vec<_>>()
.join(",")
}
fn item_type_label(item_type: &ItemType) -> &'static str {
match item_type {
ItemType::Text => "text",
ItemType::Image => "image",
ItemType::Link(_) => "link",
ItemType::FormField => "form_field",
}
}
fn format_items_json(items: &[TextItem]) -> String {
let underlined_count = items.iter().filter(|item| item.is_underline).count();
let items_json = items
.iter()
.map(|item| {
let mcid = item
.mcid
.map(|value| value.to_string())
.unwrap_or_else(|| "null".to_string());
let link_url = match &item.item_type {
ItemType::Link(url) => format!(r#","url":"{}""#, json_escape(url)),
_ => String::new(),
};
format!(
r#"{{"text":"{}","page":{},"x":{:.2},"y":{:.2},"width":{:.2},"height":{:.2},"font":"{}","font_size":{:.2},"is_bold":{},"is_italic":{},"is_underline":{},"is_strikeout":{},"item_type":"{}","mcid":{}{}}}"#,
json_escape(&item.text),
item.page,
item.x,
item.y,
item.width,
item.height,
json_escape(&item.font),
item.font_size,
item.is_bold,
item.is_italic,
item.is_underline,
item.is_strikeout,
item_type_label(&item.item_type),
mcid,
link_url,
)
})
.collect::<Vec<_>>()
.join(",");
format!(
r#"{{"total_items":{},"underlined_count":{},"items":[{}]}}"#,
items.len(),
underlined_count,
items_json
)
}
#[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":{},"layout_ms":{},"assembly_ms":{}}},"warnings":[{}]}}"#,
provenance.page,
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.layout_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#"{{"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()
}
#[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>>,
password: Option<&str>,
) -> Result<String, pdf_inspector::PdfError> {
extract_text_with_positions_pages_with_password(pdf_path, page_filter, password)
.map(|items| format_items_json(&items))
}
#[cfg(test)]
mod tests {
use super::{extract_items_json, format_items_json};
use pdf_inspector::extractor::ItemType;
use pdf_inspector::TextItem;
#[test]
fn items_json_includes_position_and_underline_metadata() {
let items = vec![TextItem {
text: "A \"quoted\" item".to_string(),
x: 12.345,
y: 67.891,
width: 23.456,
height: 9.876,
font: "F1".to_string(),
font_size: 10.0,
page: 2,
is_bold: false,
is_italic: true,
is_underline: true,
is_strikeout: true,
item_type: ItemType::Text,
mcid: Some(7),
}];
let json = format_items_json(&items);
assert!(json.contains(r#""text":"A \"quoted\" item""#));
assert!(json.contains(r#""page":2"#));
assert!(json.contains(r#""x":12.35"#));
assert!(json.contains(r#""is_underline":true"#));
assert!(json.contains(r#""item_type":"text""#));
assert!(json.contains(r#""mcid":7"#));
}
#[test]
fn items_json_uses_supplied_pdf_password() {
let path = "tests/fixtures/encrypted-secret123.pdf";
let without_password = extract_items_json(path, None, None);
assert!(
without_password.is_err(),
"encrypted fixture unexpectedly extracted without a password"
);
let json = extract_items_json(path, None, Some("secret123"))
.expect("correct password should decrypt positioned text");
assert!(
json.contains("Procurement"),
"decrypted item JSON should contain fixture text, got {json}"
);
}
}
/// Parse a page specification like "1,3,5-10,20" into a HashSet of page numbers.
fn parse_page_spec(spec: &str) -> Result<HashSet<u32>, String> {
let mut pages = HashSet::new();
@@ -350,14 +82,12 @@ fn print_layout_info(layout: &LayoutComplexity) {
}
fn main() {
#[cfg(not(target_arch = "wasm32"))]
env_logger::init();
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
eprintln!("Usage: {} <pdf_file> [output_file]", args[0]);
eprintln!(" {} <pdf_file> --json", args[0]);
eprintln!(" {} <pdf_file> --items-json", args[0]);
eprintln!(" {} <pdf_file> --raw", args[0]);
eprintln!();
eprintln!("Converts PDF to Markdown with smart type detection.");
@@ -365,47 +95,20 @@ fn main() {
eprintln!();
eprintln!("Options:");
eprintln!(" --json Output result as JSON");
eprintln!(" --items-json Output positioned TextItem JSON");
eprintln!(" --raw Output only markdown (no headers)");
eprintln!(
" --compact Collapse token-heavy source formatting such as dot leaders"
);
eprintln!(" --pages Insert page break markers (<!-- Page N -->)");
eprintln!(" --select-pages N Only process specified pages (e.g. 1,3,5-10)");
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);
}
let pdf_path = &args[1];
let json_output = args.iter().any(|a| a == "--json");
let items_json_output = args.iter().any(|a| a == "--items-json");
let raw_output = args.iter().any(|a| a == "--raw");
let compact_output = args.iter().any(|a| a == "--compact");
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| {
args.get(i + 1)
.unwrap_or_else(|| {
eprintln!("Error: --password requires a value");
process::exit(1);
})
.clone()
});
// Parse --select-pages value
let page_filter = args
@@ -431,147 +134,6 @@ fn main() {
.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 {
eprintln!("Error: OCR options require --ocr off, --ocr auto, or --ocr force");
process::exit(1);
}
if let Some(mode) = ocr_mode_argument {
if items_json_output || detect_only || analyze {
eprintln!(
"Error: --ocr cannot be combined with --items-json, --detect-only, or --analyze"
);
process::exit(1);
}
#[cfg(not(all(feature = "ocr", not(target_arch = "wasm32"))))]
{
let _ = mode;
eprintln!("Error: this pdf2md build does not include OCR; rebuild with --features ocr");
process::exit(1);
}
#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
{
let mode = match mode {
"off" => OcrMode::Off,
"auto" => OcrMode::Auto,
"force" => OcrMode::Force,
value => {
eprintln!("Error: invalid --ocr mode {value:?}; expected off, auto, or force");
process::exit(1);
}
};
let dpi = float_argument(&args, "--ocr-dpi", 150.0).unwrap_or_else(|error| {
eprintln!("Error: {error}");
process::exit(1);
});
let minimum_confidence = float_argument(&args, "--ocr-min-confidence", 0.0)
.unwrap_or_else(|error| {
eprintln!("Error: {error}");
process::exit(1);
});
let hosted_threshold = float_argument(&args, "--ocr-hosted-threshold", 0.5)
.unwrap_or_else(|error| {
eprintln!("Error: {error}");
process::exit(1);
});
let model_directory =
argument_value(&args, "--ocr-model-dir").unwrap_or_else(|error| {
eprintln!("Error: {error}");
process::exit(1);
});
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.pages(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) => {
if json_output {
println!(r#"{{"error":"{}"}}"#, json_escape(&error.to_string()));
} else {
eprintln!("Error: {error}");
}
process::exit(1);
}
}
return;
}
}
if items_json_output {
match extract_items_json(pdf_path, page_filter.as_ref(), password.as_deref()) {
Ok(json) => println!("{}", json),
Err(e) => {
println!(r#"{{"error":"{}"}}"#, json_escape(&e.to_string()));
process::exit(1);
}
}
return;
}
let process_mode = if detect_only {
ProcessMode::DetectOnly
} else if analyze {
@@ -581,14 +143,10 @@ fn main() {
};
let mut options = PdfOptions::new().mode(process_mode);
if compact_output {
options.markdown.profile = pdf_inspector::MarkdownProfile::Compact;
}
options.markdown.include_page_numbers = page_numbers;
if let Some(pages) = page_filter {
options.page_filter = Some(pages);
}
options.password = password;
match process_pdf_with_options(pdf_path, options) {
Ok(result) => {
@@ -619,14 +177,12 @@ fn main() {
.iter()
.map(|p| p.to_string())
.collect();
let ocr_reasons = format_ocr_reasons_by_page(&result.ocr_reasons_by_page);
println!(
r#"{{"pdf_type":"{}","page_count":{},"processing_time_ms":{},"pages_needing_ocr":[{}],"ocr_reasons_by_page":[{}],"is_complex":{},"pages_with_tables":[{}],"pages_with_columns":[{}],"has_encoding_issues":{}}}"#,
r#"{{"pdf_type":"{}","page_count":{},"processing_time_ms":{},"pages_needing_ocr":[{}],"is_complex":{},"pages_with_tables":[{}],"pages_with_columns":[{}],"has_encoding_issues":{}}}"#,
pdf_type_str,
result.page_count,
result.processing_time_ms,
ocr_pages.join(","),
ocr_reasons,
result.layout.is_complex,
table_pages.join(","),
col_pages.join(","),
@@ -667,9 +223,8 @@ fn main() {
.iter()
.map(|p| p.to_string())
.collect();
let ocr_reasons = format_ocr_reasons_by_page(&result.ocr_reasons_by_page);
println!(
r#"{{"pdf_type":"{}","page_count":{},"has_text":{},"processing_time_ms":{},"markdown_length":{},"pages_needing_ocr":[{}],"ocr_reasons_by_page":[{}],"is_complex":{},"pages_with_tables":[{}],"pages_with_columns":[{}],"has_encoding_issues":{},"markdown":"{}"}}"#,
r#"{{"pdf_type":"{}","page_count":{},"has_text":{},"processing_time_ms":{},"markdown_length":{},"pages_needing_ocr":[{}],"is_complex":{},"pages_with_tables":[{}],"pages_with_columns":[{}],"has_encoding_issues":{},"markdown":"{}"}}"#,
match result.pdf_type {
PdfType::TextBased => "text_based",
PdfType::Scanned => "scanned",
@@ -681,7 +236,6 @@ fn main() {
result.processing_time_ms,
result.markdown.as_ref().map(|m| m.len()).unwrap_or(0),
ocr_pages.join(","),
ocr_reasons,
result.layout.is_complex,
table_pages.join(","),
col_pages.join(","),
+27 -274
View File
@@ -60,10 +60,6 @@ pub struct PdfTypeResult {
/// 1-indexed page numbers that need OCR (image-only or insufficient text).
/// Empty for TextBased. All pages for Scanned/ImageBased. Specific pages for Mixed.
pub pages_needing_ocr: Vec<u32>,
/// Per-page explanation for `pages_needing_ocr`: 1-indexed page → reason
/// codes (`scanned`, `no_text`, `vector_text`, `suspected_garbled_text`).
/// Only contains pages that need OCR.
pub ocr_reasons_by_page: std::collections::BTreeMap<u32, Vec<String>>,
}
/// Configuration for PDF type detection
@@ -386,12 +382,7 @@ pub(crate) fn detect_from_document(
let analysis = if let Some(cached) = analysis_cache.get(&page_num) {
cached.clone()
} else if let Some(&page_id) = pages.get(&page_num) {
// Cache the fresh analysis so the reason-classification pass
// below sees the real signals (vector_text, etc.) instead of
// defaulting to "scanned".
let a = analyze_page_content(doc, page_id);
analysis_cache.insert(page_num, a.clone());
a
analyze_page_content(doc, page_id)
} else {
continue;
};
@@ -438,9 +429,6 @@ pub(crate) fn detect_from_document(
let analysis = analyze_page_content(doc, page_id);
if analysis.has_identity_h_no_tounicode || analysis.has_only_type3_fonts {
pages_needing_ocr.push(page_num);
// Cache so the reason pass reports suspected_garbled_text
// rather than defaulting to "scanned".
analysis_cache.insert(page_num, analysis);
}
}
}
@@ -448,19 +436,6 @@ pub(crate) fn detect_from_document(
pages_needing_ocr.sort();
pages_needing_ocr.dedup();
// Explain each OCR-flagged page. Pages we analyzed get a signal-derived
// reason; pages flagged only by whole-document classification (unsampled
// pages of a Scanned/ImageBased doc) default to `scanned`.
let mut ocr_reasons_by_page: std::collections::BTreeMap<u32, Vec<String>> =
std::collections::BTreeMap::new();
for &page_num in &pages_needing_ocr {
let reasons = match analysis_cache.get(&page_num) {
Some(analysis) => page_ocr_reasons(analysis),
None => vec![crate::OCR_REASON_SCANNED],
};
ocr_reasons_by_page.insert(page_num, reasons.into_iter().map(String::from).collect());
}
// Try to get title from metadata
let title = get_document_title(doc);
@@ -473,7 +448,6 @@ pub(crate) fn detect_from_document(
title,
ocr_recommended,
pages_needing_ocr,
ocr_reasons_by_page,
})
}
@@ -513,7 +487,7 @@ fn distribute_pages(n: u32, total: u32) -> Vec<u32> {
}
/// Page content analysis result
#[derive(Clone, Default)]
#[derive(Clone)]
struct PageAnalysis {
text_operator_count: u32,
has_images: bool,
@@ -549,31 +523,6 @@ struct PageAnalysis {
has_decodable_text_fonts: bool,
}
/// Explain *why* a page needs OCR, from its content analysis. Priority:
/// undecodable fonts (`suspected_garbled_text`) and vector-outlined text
/// (`vector_text`) come first because they persist even when a text layer is
/// present; otherwise a page with no extractable text is `scanned` when an
/// image backs it or `no_text` when nothing does.
fn page_ocr_reasons(a: &PageAnalysis) -> Vec<&'static str> {
let mut reasons = Vec::new();
if a.has_identity_h_no_tounicode || a.has_only_type3_fonts {
reasons.push(crate::OCR_REASON_SUSPECTED_GARBLED_TEXT);
}
if a.has_vector_text {
reasons.push(crate::OCR_REASON_VECTOR_TEXT);
}
if reasons.is_empty() {
let has_extractable_text = a.text_operator_count > 0 && a.unique_text_chars > 0;
if !has_extractable_text && !a.has_images && !a.has_template_image {
reasons.push(crate::OCR_REASON_NO_TEXT);
} else {
// Image-backed with no usable text, or too little text to trust.
reasons.push(crate::OCR_REASON_SCANNED);
}
}
reasons
}
/// Extracted font information from a Resource dictionary entry.
/// Stores the properties needed for decodability/identity-h checks
/// without holding a reference to the document.
@@ -1382,13 +1331,7 @@ 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.
// 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;
// Simple state machine to find operators
let mut i = 0;
while i < content.len() {
let b = content[i];
@@ -1398,15 +1341,14 @@ 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')
&& preceding_operand_closer(content, i, operand_floor)
|| content[i + 2] == b'\r'
{
text_ops += 1;
collect_text_chars_before(content, i, unique_chars, operand_floor);
operand_floor = i;
// Scan backward for text string operand to collect unique chars
collect_text_chars_before(content, i, unique_chars);
}
} else if next == b'f' {
// Tf = set font operator
@@ -1422,10 +1364,12 @@ fn scan_content_for_text_operators(
|| content[i + 2] == b'<'
|| content[i + 2] == b'/'
{
if let Some(name) = extract_font_name_before_tf(content, i, operand_floor) {
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) {
used_font_names.insert(name);
font_changes += 1;
operand_floor = i;
}
}
}
@@ -1471,20 +1415,6 @@ 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`
@@ -1492,27 +1422,25 @@ fn preceding_operand_closer(content: &[u8], op_pos: usize, floor: usize) -> bool
/// whitespace to find the `/Name` token.
///
/// Returns the font name bytes (without the leading `/`), e.g. `b"F1"` for `/F1`.
/// `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>> {
fn extract_font_name_before_tf(content: &[u8], tf_pos: usize) -> Option<Vec<u8>> {
// Scan backward past whitespace before "Tf"
let mut j = tf_pos;
while j > floor && content[j - 1].is_ascii_whitespace() {
while j > 0 && content[j - 1].is_ascii_whitespace() {
j -= 1;
}
// Scan backward past the size number (digits, '.', '-')
while j > floor
while j > 0
&& (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 > floor && content[j - 1].is_ascii_whitespace() {
while j > 0 && 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 > floor && content[j - 1] != b'/' {
while j > 0 && 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')'
{
@@ -1520,7 +1448,7 @@ fn extract_font_name_before_tf(content: &[u8], tf_pos: usize, floor: usize) -> O
}
j -= 1;
}
if j <= floor || content[j - 1] != b'/' {
if j == 0 || content[j - 1] != b'/' {
return None;
}
// j-1 is the '/', font name is content[j..name_end]
@@ -1535,24 +1463,16 @@ fn extract_font_name_before_tf(content: &[u8], tf_pos: usize, floor: usize) -> O
/// and collect unique non-whitespace bytes from it.
///
/// Handles both literal strings `(...)` and hex strings `<...>`.
/// `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,
) {
fn collect_text_chars_before(content: &[u8], op_pos: usize, unique_chars: &mut HashSet<u8>) {
// Walk backward past whitespace to find the closing delimiter
let mut j = op_pos;
while j > floor {
while j > 0 {
j -= 1;
if !content[j].is_ascii_whitespace() {
break;
}
}
// All whitespace, or we landed on the previous operator token.
if j == floor {
if j == 0 {
return;
}
@@ -1562,7 +1482,7 @@ fn collect_text_chars_before(
// Literal string: scan backward for matching '('
let mut depth = 1i32;
let mut k = j;
while k > floor && depth > 0 {
while k > 0 && depth > 0 {
k -= 1;
match content[k] {
b')' if k == 0 || content[k - 1] != b'\\' => depth += 1,
@@ -1581,7 +1501,7 @@ fn collect_text_chars_before(
} else if closing == b'>' {
// Hex string: scan backward for '<'
let mut k = j;
while k > floor {
while k > 0 {
k -= 1;
if content[k] == b'<' {
break;
@@ -1611,7 +1531,7 @@ fn collect_text_chars_before(
} else if closing == b']' {
// TJ array: scan backward for '[' and collect from all strings inside
let mut k = j;
while k > floor {
while k > 0 {
k -= 1;
if content[k] == b'[' {
break;
@@ -1688,15 +1608,7 @@ fn hex_val(b: u8) -> Option<u8> {
/// Standard page: 612x792 points (US Letter) = ~485,000 sq points
/// At 2x resolution that's ~1.9M pixels, so we use 250K pixels as threshold
/// (accounting for varying DPI and page sizes)
/// Returns `(has_images, total_image_area, has_template_image)` for a page.
/// `has_template_image` means a single large (>50% page coverage)
/// background image — the signal `classify_pdf`/`detect_pdf_type` uses to
/// route a page to OCR regardless of any incidental native text drawn over
/// it. Exposed at crate visibility so extraction-side per-page `needs_ocr`
/// computation (`extract_pages_markdown_mem`) can consult the same signal
/// instead of maintaining its own, independent notion of "needs OCR" that
/// can silently disagree with detection — see #227.
pub(crate) fn analyze_page_images(doc: &Document, page_id: ObjectId) -> (bool, u64, bool) {
fn analyze_page_images(doc: &Document, page_id: ObjectId) -> (bool, u64, bool) {
// Threshold: image covering roughly half a page at 150+ DPI
// 612 * 792 / 2 * (150/72)^2 ≈ 1M pixels, but we'll be conservative
const TEMPLATE_IMAGE_THRESHOLD: u64 = 500_000; // 500K pixels
@@ -1778,62 +1690,6 @@ pub(crate) fn analyze_page_images(doc: &Document, page_id: ObjectId) -> (bool, u
(has_images, total_area, has_template_image)
}
/// Computes both `(needs_ocr_for_template_image, has_vector_text)` for a
/// page from a single shared `analyze_page_content` pass — that call
/// decompresses and scans every content stream (page + XObjects) plus
/// image coverage, so `extract_pages_markdown_mem` must not invoke it
/// twice per page (once per signal) the way `detect_from_document` avoids
/// by caching its per-page `PageAnalysis`.
///
/// `needs_ocr_for_template_image` is true when a page's template image
/// should be treated as a scan needing OCR — a single full-page background
/// image with little/no real text — rather than a text page that happens
/// to carry a watermark, letterhead, or figure. Mirrors the two distinct
/// signals classification uses to route a template-image page to OCR:
///
/// 1. `looks_like_scan`: image_count <= 1, few text operators (<50), and
/// 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.
///
/// `has_vector_text` is true when a page has vector-outlined text (glyphs
/// drawn as paths rather than shown via text-showing operators) —
/// `detect_from_document`'s Mixed-type per-page routing always sends
/// these pages to OCR, independent of any template-image check, since
/// outlined glyphs can't be extracted as text at all.
///
/// Exposed at crate visibility so `extract_pages_markdown_mem` can apply
/// the same gates classification needs elsewhere instead of treating the
/// raw signals alone as sufficient — see #227/#231.
pub(crate) fn page_ocr_signals(doc: &Document, page_id: ObjectId) -> (bool, bool) {
let analysis = analyze_page_content(doc, page_id);
let needs_ocr_for_template_image = if !analysis.has_template_image {
false
} else {
let alphanum_low = analysis.unique_alphanum_chars < 10
&& !(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;
let insufficient_text =
analysis.text_operator_count < DetectionConfig::default().min_text_ops_per_page;
looks_like_scan || insufficient_text
};
(needs_ocr_for_template_image, analysis.has_vector_text)
}
/// Recursively collect image dimensions from XObject resources,
/// including images nested inside Form XObjects.
fn collect_images_from_resources(
@@ -1953,64 +1809,6 @@ fn get_document_title(doc: &Document) -> Option<String> {
mod tests {
use super::*;
#[test]
fn page_ocr_reasons_classify() {
// Scanned: no text, full-page image.
let scanned = PageAnalysis {
has_template_image: true,
..Default::default()
};
assert_eq!(page_ocr_reasons(&scanned), vec![crate::OCR_REASON_SCANNED]);
// Image-only page (no template flag, but has an image).
let image_only = PageAnalysis {
has_images: true,
..Default::default()
};
assert_eq!(
page_ocr_reasons(&image_only),
vec![crate::OCR_REASON_SCANNED]
);
// No text, no image → no_text.
let blank = PageAnalysis::default();
assert_eq!(page_ocr_reasons(&blank), vec![crate::OCR_REASON_NO_TEXT]);
// Vector-outlined text.
let vector = PageAnalysis {
has_vector_text: true,
..Default::default()
};
assert_eq!(
page_ocr_reasons(&vector),
vec![crate::OCR_REASON_VECTOR_TEXT]
);
// Undecodable fonts → garbled, and it wins over the fall-through.
let garbled = PageAnalysis {
has_identity_h_no_tounicode: true,
has_images: true,
..Default::default()
};
assert_eq!(
page_ocr_reasons(&garbled),
vec![crate::OCR_REASON_SUSPECTED_GARBLED_TEXT]
);
// A page with real extractable text and an image is not flagged here
// as scanned/no_text (only reached for pages already needing OCR).
let text_with_image = PageAnalysis {
text_operator_count: 40,
unique_text_chars: 120,
has_images: true,
..Default::default()
};
assert_eq!(
page_ocr_reasons(&text_with_image),
vec![crate::OCR_REASON_SCANNED]
);
}
#[test]
fn test_scan_content_operators() {
let mut uchars = HashSet::new();
@@ -2043,51 +1841,6 @@ 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.
@@ -2846,14 +2599,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, 0); // 'T' is at index 6
let name = extract_font_name_before_tf(content, 6); // '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, 0);
let name = extract_font_name_before_tf(content, 18);
assert_eq!(name, Some(b"ArialMT-Bold".to_vec()));
}
-649
View File
@@ -1,649 +0,0 @@
//! Built-in glyph metrics for the 14 standard PDF fonts.
//!
//! PDFs may omit `/Widths` for non-embedded base-14 fonts (Times, Helvetica,
//! Courier, Symbol, ZapfDingbats); per the PDF spec the reader must supply
//! the metrics. Without them every text item gets width 0, which breaks
//! space synthesis, sub/superscript detection, and table column detection
//! (common in 1990s dvips/Distiller output).
//!
//! Tables are generated from the Adobe Core 14 AFM files (via reportlab's
//! `_fontdata`), keyed by Unicode char, sorted for binary search.
//! Generator: scratchpad/gen_base14.py (session tooling, not checked in).
/// Width in 1000ths of an em for `c` in the given base-14 font, or `None`
/// if the font is not one of the base 14 (after name normalization) or the
/// char has no glyph in its AFM.
pub(crate) fn base14_char_width(base_font: &str, c: char) -> Option<u16> {
let table = base14_table(base_font)?;
// AFM tables key visible glyphs only; alias the invisible variants the
// cp1252 fallback can produce so they get the metric of their visible
// counterpart instead of the generic default.
let c = match c {
'\u{00A0}' => ' ', // no-break space -> space
'\u{00AD}' => '-', // soft hyphen -> hyphen
_ => c,
};
table
.binary_search_by_key(&c, |&(ch, _)| ch)
.ok()
.map(|i| table[i].1)
}
/// True when the base font name normalizes to one of the standard 14 fonts.
pub(crate) fn is_base14_font(base_font: &str) -> bool {
base14_table(base_font).is_some()
}
/// Code → Unicode through the font's BUILT-IN encoding, for the base-14
/// fonts whose repertoire is not Latin (Symbol, ZapfDingbats). Their glyphs
/// live at byte positions that have nothing to do with cp1252 (Symbol 0x61
/// renders α, Zapf 0x21 renders ✁), so advance widths must be resolved
/// through this mapping — the renderer draws these glyphs regardless of how
/// the text decoder transliterates them. Returns `None` for the Latin text
/// fonts, which follow standard single-byte encodings.
pub(crate) fn builtin_encoding_char(base_font: &str, code: u8) -> Option<char> {
let table = base14_table(base_font)?;
let enc: &[(u8, char)] = if std::ptr::eq(table, SYMBOL) {
SYMBOL_ENCODING
} else if std::ptr::eq(table, ZAPFDINGBATS) {
ZAPFDINGBATS_ENCODING
} else {
return None;
};
enc.binary_search_by_key(&code, |&(b, _)| b)
.ok()
.map(|i| enc[i].1)
}
/// Map a BaseFont name (possibly subset-prefixed, e.g. "ABCDEF+Times-Bold",
/// or a common alias like "Arial" / "TimesNewRomanPSMT") to its width table.
fn base14_table(base_font: &str) -> Option<&'static [(char, u16)]> {
// Strip subset prefix "ABCDEF+"
let name = match base_font.split_once('+') {
Some((prefix, rest))
if prefix.len() == 6 && prefix.chars().all(|c| c.is_ascii_uppercase()) =>
{
rest
}
_ => base_font,
};
let lower = name.to_ascii_lowercase();
let bold = lower.contains("bold");
let italic = lower.contains("italic") || lower.contains("oblique");
if lower.contains("courier") {
return Some(match (bold, italic) {
(false, false) => COURIER,
(true, false) => COURIER_BOLD,
(false, true) => COURIER_OBLIQUE,
(true, true) => COURIER_BOLDOBLIQUE,
});
}
if lower.contains("helvetica") || lower.contains("arial") {
return Some(match (bold, italic) {
(false, false) => HELVETICA,
(true, false) => HELVETICA_BOLD,
(false, true) => HELVETICA_OBLIQUE,
(true, true) => HELVETICA_BOLDOBLIQUE,
});
}
if lower.contains("times") {
return Some(match (bold, italic) {
(false, false) => TIMES_ROMAN,
(true, false) => TIMES_BOLD,
(false, true) => TIMES_ITALIC,
(true, true) => TIMES_BOLDITALIC,
});
}
// Symbol and ZapfDingbats have unique glyph repertoires, so only exact
// names (plus the common MT/ITC aliases) qualify — a custom font that
// merely mentions "Symbol" in its name must not get these metrics.
match lower.as_str() {
"zapfdingbats" | "dingbats" | "itczapfdingbats" | "zapfdingbatsitc" => {
return Some(ZAPFDINGBATS)
}
"symbol" | "symbolmt" | "symbolitc" => return Some(SYMBOL),
_ => {}
}
None
}
#[rustfmt::skip]
static COURIER: &[(char, u16)] = &[
(' ', 600), ('!', 600), ('"', 600), ('#', 600), ('$', 600), ('%', 600),
('&', 600), ('\'', 600), ('(', 600), (')', 600), ('*', 600), ('+', 600),
(',', 600), ('-', 600), ('.', 600), ('/', 600), ('0', 600), ('1', 600),
('2', 600), ('3', 600), ('4', 600), ('5', 600), ('6', 600), ('7', 600),
('8', 600), ('9', 600), (':', 600), (';', 600), ('<', 600), ('=', 600),
('>', 600), ('?', 600), ('@', 600), ('A', 600), ('B', 600), ('C', 600),
('D', 600), ('E', 600), ('F', 600), ('G', 600), ('H', 600), ('I', 600),
('J', 600), ('K', 600), ('L', 600), ('M', 600), ('N', 600), ('O', 600),
('P', 600), ('Q', 600), ('R', 600), ('S', 600), ('T', 600), ('U', 600),
('V', 600), ('W', 600), ('X', 600), ('Y', 600), ('Z', 600), ('[', 600),
('\\', 600), (']', 600), ('^', 600), ('_', 600), ('`', 600), ('a', 600),
('b', 600), ('c', 600), ('d', 600), ('e', 600), ('f', 600), ('g', 600),
('h', 600), ('i', 600), ('j', 600), ('k', 600), ('l', 600), ('m', 600),
('n', 600), ('o', 600), ('p', 600), ('q', 600), ('r', 600), ('s', 600),
('t', 600), ('u', 600), ('v', 600), ('w', 600), ('x', 600), ('y', 600),
('z', 600), ('{', 600), ('|', 600), ('}', 600), ('~', 600), ('\u{00A1}', 600),
('\u{00A2}', 600), ('\u{00A3}', 600), ('\u{00A4}', 600), ('\u{00A5}', 600), ('\u{00A6}', 600), ('\u{00A7}', 600),
('\u{00A8}', 600), ('\u{00A9}', 600), ('\u{00AA}', 600), ('\u{00AB}', 600), ('\u{00AC}', 600), ('\u{00AE}', 600),
('\u{00AF}', 600), ('\u{00B0}', 600), ('\u{00B1}', 600), ('\u{00B2}', 600), ('\u{00B3}', 600), ('\u{00B4}', 600),
('\u{00B5}', 600), ('\u{00B6}', 600), ('\u{00B7}', 600), ('\u{00B8}', 600), ('\u{00B9}', 600), ('\u{00BA}', 600),
('\u{00BB}', 600), ('\u{00BC}', 600), ('\u{00BD}', 600), ('\u{00BE}', 600), ('\u{00BF}', 600), ('\u{00C0}', 600),
('\u{00C1}', 600), ('\u{00C2}', 600), ('\u{00C3}', 600), ('\u{00C4}', 600), ('\u{00C5}', 600), ('\u{00C6}', 600),
('\u{00C7}', 600), ('\u{00C8}', 600), ('\u{00C9}', 600), ('\u{00CA}', 600), ('\u{00CB}', 600), ('\u{00CC}', 600),
('\u{00CD}', 600), ('\u{00CE}', 600), ('\u{00CF}', 600), ('\u{00D0}', 600), ('\u{00D1}', 600), ('\u{00D2}', 600),
('\u{00D3}', 600), ('\u{00D4}', 600), ('\u{00D5}', 600), ('\u{00D6}', 600), ('\u{00D7}', 600), ('\u{00D8}', 600),
('\u{00D9}', 600), ('\u{00DA}', 600), ('\u{00DB}', 600), ('\u{00DC}', 600), ('\u{00DD}', 600), ('\u{00DE}', 600),
('\u{00DF}', 600), ('\u{00E0}', 600), ('\u{00E1}', 600), ('\u{00E2}', 600), ('\u{00E3}', 600), ('\u{00E4}', 600),
('\u{00E5}', 600), ('\u{00E6}', 600), ('\u{00E7}', 600), ('\u{00E8}', 600), ('\u{00E9}', 600), ('\u{00EA}', 600),
('\u{00EB}', 600), ('\u{00EC}', 600), ('\u{00ED}', 600), ('\u{00EE}', 600), ('\u{00EF}', 600), ('\u{00F0}', 600),
('\u{00F1}', 600), ('\u{00F2}', 600), ('\u{00F3}', 600), ('\u{00F4}', 600), ('\u{00F5}', 600), ('\u{00F6}', 600),
('\u{00F7}', 600), ('\u{00F8}', 600), ('\u{00F9}', 600), ('\u{00FA}', 600), ('\u{00FB}', 600), ('\u{00FC}', 600),
('\u{00FD}', 600), ('\u{00FE}', 600), ('\u{00FF}', 600), ('\u{0131}', 600), ('\u{0141}', 600), ('\u{0142}', 600),
('\u{0152}', 600), ('\u{0153}', 600), ('\u{0160}', 600), ('\u{0161}', 600), ('\u{0178}', 600), ('\u{017D}', 600),
('\u{017E}', 600), ('\u{0192}', 600), ('\u{02C6}', 600), ('\u{02C7}', 600), ('\u{02D8}', 600), ('\u{02D9}', 600),
('\u{02DA}', 600), ('\u{02DB}', 600), ('\u{02DC}', 600), ('\u{02DD}', 600), ('\u{2013}', 600), ('\u{2014}', 600),
('\u{2018}', 600), ('\u{2019}', 600), ('\u{201A}', 600), ('\u{201C}', 600), ('\u{201D}', 600), ('\u{201E}', 600),
('\u{2020}', 600), ('\u{2021}', 600), ('\u{2022}', 600), ('\u{2026}', 600), ('\u{2030}', 600), ('\u{2039}', 600),
('\u{203A}', 600), ('\u{2044}', 600), ('\u{20AC}', 600), ('\u{2122}', 600), ('\u{2212}', 600), ('\u{FB01}', 600),
('\u{FB02}', 600),
];
static COURIER_BOLD: &[(char, u16)] = COURIER;
static COURIER_OBLIQUE: &[(char, u16)] = COURIER;
static COURIER_BOLDOBLIQUE: &[(char, u16)] = COURIER;
#[rustfmt::skip]
static HELVETICA: &[(char, u16)] = &[
(' ', 278), ('!', 278), ('"', 355), ('#', 556), ('$', 556), ('%', 889),
('&', 667), ('\'', 191), ('(', 333), (')', 333), ('*', 389), ('+', 584),
(',', 278), ('-', 333), ('.', 278), ('/', 278), ('0', 556), ('1', 556),
('2', 556), ('3', 556), ('4', 556), ('5', 556), ('6', 556), ('7', 556),
('8', 556), ('9', 556), (':', 278), (';', 278), ('<', 584), ('=', 584),
('>', 584), ('?', 556), ('@', 1015), ('A', 667), ('B', 667), ('C', 722),
('D', 722), ('E', 667), ('F', 611), ('G', 778), ('H', 722), ('I', 278),
('J', 500), ('K', 667), ('L', 556), ('M', 833), ('N', 722), ('O', 778),
('P', 667), ('Q', 778), ('R', 722), ('S', 667), ('T', 611), ('U', 722),
('V', 667), ('W', 944), ('X', 667), ('Y', 667), ('Z', 611), ('[', 278),
('\\', 278), (']', 278), ('^', 469), ('_', 556), ('`', 333), ('a', 556),
('b', 556), ('c', 500), ('d', 556), ('e', 556), ('f', 278), ('g', 556),
('h', 556), ('i', 222), ('j', 222), ('k', 500), ('l', 222), ('m', 833),
('n', 556), ('o', 556), ('p', 556), ('q', 556), ('r', 333), ('s', 500),
('t', 278), ('u', 556), ('v', 500), ('w', 722), ('x', 500), ('y', 500),
('z', 500), ('{', 334), ('|', 260), ('}', 334), ('~', 584), ('\u{00A1}', 333),
('\u{00A2}', 556), ('\u{00A3}', 556), ('\u{00A4}', 556), ('\u{00A5}', 556), ('\u{00A6}', 260), ('\u{00A7}', 556),
('\u{00A8}', 333), ('\u{00A9}', 737), ('\u{00AA}', 370), ('\u{00AB}', 556), ('\u{00AC}', 584), ('\u{00AE}', 737),
('\u{00AF}', 333), ('\u{00B0}', 400), ('\u{00B1}', 584), ('\u{00B2}', 333), ('\u{00B3}', 333), ('\u{00B4}', 333),
('\u{00B5}', 556), ('\u{00B6}', 537), ('\u{00B7}', 278), ('\u{00B8}', 333), ('\u{00B9}', 333), ('\u{00BA}', 365),
('\u{00BB}', 556), ('\u{00BC}', 834), ('\u{00BD}', 834), ('\u{00BE}', 834), ('\u{00BF}', 611), ('\u{00C0}', 667),
('\u{00C1}', 667), ('\u{00C2}', 667), ('\u{00C3}', 667), ('\u{00C4}', 667), ('\u{00C5}', 667), ('\u{00C6}', 1000),
('\u{00C7}', 722), ('\u{00C8}', 667), ('\u{00C9}', 667), ('\u{00CA}', 667), ('\u{00CB}', 667), ('\u{00CC}', 278),
('\u{00CD}', 278), ('\u{00CE}', 278), ('\u{00CF}', 278), ('\u{00D0}', 722), ('\u{00D1}', 722), ('\u{00D2}', 778),
('\u{00D3}', 778), ('\u{00D4}', 778), ('\u{00D5}', 778), ('\u{00D6}', 778), ('\u{00D7}', 584), ('\u{00D8}', 778),
('\u{00D9}', 722), ('\u{00DA}', 722), ('\u{00DB}', 722), ('\u{00DC}', 722), ('\u{00DD}', 667), ('\u{00DE}', 667),
('\u{00DF}', 611), ('\u{00E0}', 556), ('\u{00E1}', 556), ('\u{00E2}', 556), ('\u{00E3}', 556), ('\u{00E4}', 556),
('\u{00E5}', 556), ('\u{00E6}', 889), ('\u{00E7}', 500), ('\u{00E8}', 556), ('\u{00E9}', 556), ('\u{00EA}', 556),
('\u{00EB}', 556), ('\u{00EC}', 278), ('\u{00ED}', 278), ('\u{00EE}', 278), ('\u{00EF}', 278), ('\u{00F0}', 556),
('\u{00F1}', 556), ('\u{00F2}', 556), ('\u{00F3}', 556), ('\u{00F4}', 556), ('\u{00F5}', 556), ('\u{00F6}', 556),
('\u{00F7}', 584), ('\u{00F8}', 611), ('\u{00F9}', 556), ('\u{00FA}', 556), ('\u{00FB}', 556), ('\u{00FC}', 556),
('\u{00FD}', 500), ('\u{00FE}', 556), ('\u{00FF}', 500), ('\u{0131}', 278), ('\u{0141}', 556), ('\u{0142}', 222),
('\u{0152}', 1000), ('\u{0153}', 944), ('\u{0160}', 667), ('\u{0161}', 500), ('\u{0178}', 667), ('\u{017D}', 611),
('\u{017E}', 500), ('\u{0192}', 556), ('\u{02C6}', 333), ('\u{02C7}', 333), ('\u{02D8}', 333), ('\u{02D9}', 333),
('\u{02DA}', 333), ('\u{02DB}', 333), ('\u{02DC}', 333), ('\u{02DD}', 333), ('\u{2013}', 556), ('\u{2014}', 1000),
('\u{2018}', 222), ('\u{2019}', 222), ('\u{201A}', 222), ('\u{201C}', 333), ('\u{201D}', 333), ('\u{201E}', 333),
('\u{2020}', 556), ('\u{2021}', 556), ('\u{2022}', 350), ('\u{2026}', 1000), ('\u{2030}', 1000), ('\u{2039}', 333),
('\u{203A}', 333), ('\u{2044}', 167), ('\u{20AC}', 556), ('\u{2122}', 1000), ('\u{2212}', 584), ('\u{FB01}', 500),
('\u{FB02}', 500),
];
#[rustfmt::skip]
static HELVETICA_BOLD: &[(char, u16)] = &[
(' ', 278), ('!', 333), ('"', 474), ('#', 556), ('$', 556), ('%', 889),
('&', 722), ('\'', 238), ('(', 333), (')', 333), ('*', 389), ('+', 584),
(',', 278), ('-', 333), ('.', 278), ('/', 278), ('0', 556), ('1', 556),
('2', 556), ('3', 556), ('4', 556), ('5', 556), ('6', 556), ('7', 556),
('8', 556), ('9', 556), (':', 333), (';', 333), ('<', 584), ('=', 584),
('>', 584), ('?', 611), ('@', 975), ('A', 722), ('B', 722), ('C', 722),
('D', 722), ('E', 667), ('F', 611), ('G', 778), ('H', 722), ('I', 278),
('J', 556), ('K', 722), ('L', 611), ('M', 833), ('N', 722), ('O', 778),
('P', 667), ('Q', 778), ('R', 722), ('S', 667), ('T', 611), ('U', 722),
('V', 667), ('W', 944), ('X', 667), ('Y', 667), ('Z', 611), ('[', 333),
('\\', 278), (']', 333), ('^', 584), ('_', 556), ('`', 333), ('a', 556),
('b', 611), ('c', 556), ('d', 611), ('e', 556), ('f', 333), ('g', 611),
('h', 611), ('i', 278), ('j', 278), ('k', 556), ('l', 278), ('m', 889),
('n', 611), ('o', 611), ('p', 611), ('q', 611), ('r', 389), ('s', 556),
('t', 333), ('u', 611), ('v', 556), ('w', 778), ('x', 556), ('y', 556),
('z', 500), ('{', 389), ('|', 280), ('}', 389), ('~', 584), ('\u{00A1}', 333),
('\u{00A2}', 556), ('\u{00A3}', 556), ('\u{00A4}', 556), ('\u{00A5}', 556), ('\u{00A6}', 280), ('\u{00A7}', 556),
('\u{00A8}', 333), ('\u{00A9}', 737), ('\u{00AA}', 370), ('\u{00AB}', 556), ('\u{00AC}', 584), ('\u{00AE}', 737),
('\u{00AF}', 333), ('\u{00B0}', 400), ('\u{00B1}', 584), ('\u{00B2}', 333), ('\u{00B3}', 333), ('\u{00B4}', 333),
('\u{00B5}', 611), ('\u{00B6}', 556), ('\u{00B7}', 278), ('\u{00B8}', 333), ('\u{00B9}', 333), ('\u{00BA}', 365),
('\u{00BB}', 556), ('\u{00BC}', 834), ('\u{00BD}', 834), ('\u{00BE}', 834), ('\u{00BF}', 611), ('\u{00C0}', 722),
('\u{00C1}', 722), ('\u{00C2}', 722), ('\u{00C3}', 722), ('\u{00C4}', 722), ('\u{00C5}', 722), ('\u{00C6}', 1000),
('\u{00C7}', 722), ('\u{00C8}', 667), ('\u{00C9}', 667), ('\u{00CA}', 667), ('\u{00CB}', 667), ('\u{00CC}', 278),
('\u{00CD}', 278), ('\u{00CE}', 278), ('\u{00CF}', 278), ('\u{00D0}', 722), ('\u{00D1}', 722), ('\u{00D2}', 778),
('\u{00D3}', 778), ('\u{00D4}', 778), ('\u{00D5}', 778), ('\u{00D6}', 778), ('\u{00D7}', 584), ('\u{00D8}', 778),
('\u{00D9}', 722), ('\u{00DA}', 722), ('\u{00DB}', 722), ('\u{00DC}', 722), ('\u{00DD}', 667), ('\u{00DE}', 667),
('\u{00DF}', 611), ('\u{00E0}', 556), ('\u{00E1}', 556), ('\u{00E2}', 556), ('\u{00E3}', 556), ('\u{00E4}', 556),
('\u{00E5}', 556), ('\u{00E6}', 889), ('\u{00E7}', 556), ('\u{00E8}', 556), ('\u{00E9}', 556), ('\u{00EA}', 556),
('\u{00EB}', 556), ('\u{00EC}', 278), ('\u{00ED}', 278), ('\u{00EE}', 278), ('\u{00EF}', 278), ('\u{00F0}', 611),
('\u{00F1}', 611), ('\u{00F2}', 611), ('\u{00F3}', 611), ('\u{00F4}', 611), ('\u{00F5}', 611), ('\u{00F6}', 611),
('\u{00F7}', 584), ('\u{00F8}', 611), ('\u{00F9}', 611), ('\u{00FA}', 611), ('\u{00FB}', 611), ('\u{00FC}', 611),
('\u{00FD}', 556), ('\u{00FE}', 611), ('\u{00FF}', 556), ('\u{0131}', 278), ('\u{0141}', 611), ('\u{0142}', 278),
('\u{0152}', 1000), ('\u{0153}', 944), ('\u{0160}', 667), ('\u{0161}', 556), ('\u{0178}', 667), ('\u{017D}', 611),
('\u{017E}', 500), ('\u{0192}', 556), ('\u{02C6}', 333), ('\u{02C7}', 333), ('\u{02D8}', 333), ('\u{02D9}', 333),
('\u{02DA}', 333), ('\u{02DB}', 333), ('\u{02DC}', 333), ('\u{02DD}', 333), ('\u{2013}', 556), ('\u{2014}', 1000),
('\u{2018}', 278), ('\u{2019}', 278), ('\u{201A}', 278), ('\u{201C}', 500), ('\u{201D}', 500), ('\u{201E}', 500),
('\u{2020}', 556), ('\u{2021}', 556), ('\u{2022}', 350), ('\u{2026}', 1000), ('\u{2030}', 1000), ('\u{2039}', 333),
('\u{203A}', 333), ('\u{2044}', 167), ('\u{20AC}', 556), ('\u{2122}', 1000), ('\u{2212}', 584), ('\u{FB01}', 611),
('\u{FB02}', 611),
];
static HELVETICA_OBLIQUE: &[(char, u16)] = HELVETICA;
static HELVETICA_BOLDOBLIQUE: &[(char, u16)] = HELVETICA_BOLD;
#[rustfmt::skip]
static TIMES_ROMAN: &[(char, u16)] = &[
(' ', 250), ('!', 333), ('"', 408), ('#', 500), ('$', 500), ('%', 833),
('&', 778), ('\'', 180), ('(', 333), (')', 333), ('*', 500), ('+', 564),
(',', 250), ('-', 333), ('.', 250), ('/', 278), ('0', 500), ('1', 500),
('2', 500), ('3', 500), ('4', 500), ('5', 500), ('6', 500), ('7', 500),
('8', 500), ('9', 500), (':', 278), (';', 278), ('<', 564), ('=', 564),
('>', 564), ('?', 444), ('@', 921), ('A', 722), ('B', 667), ('C', 667),
('D', 722), ('E', 611), ('F', 556), ('G', 722), ('H', 722), ('I', 333),
('J', 389), ('K', 722), ('L', 611), ('M', 889), ('N', 722), ('O', 722),
('P', 556), ('Q', 722), ('R', 667), ('S', 556), ('T', 611), ('U', 722),
('V', 722), ('W', 944), ('X', 722), ('Y', 722), ('Z', 611), ('[', 333),
('\\', 278), (']', 333), ('^', 469), ('_', 500), ('`', 333), ('a', 444),
('b', 500), ('c', 444), ('d', 500), ('e', 444), ('f', 333), ('g', 500),
('h', 500), ('i', 278), ('j', 278), ('k', 500), ('l', 278), ('m', 778),
('n', 500), ('o', 500), ('p', 500), ('q', 500), ('r', 333), ('s', 389),
('t', 278), ('u', 500), ('v', 500), ('w', 722), ('x', 500), ('y', 500),
('z', 444), ('{', 480), ('|', 200), ('}', 480), ('~', 541), ('\u{00A1}', 333),
('\u{00A2}', 500), ('\u{00A3}', 500), ('\u{00A4}', 500), ('\u{00A5}', 500), ('\u{00A6}', 200), ('\u{00A7}', 500),
('\u{00A8}', 333), ('\u{00A9}', 760), ('\u{00AA}', 276), ('\u{00AB}', 500), ('\u{00AC}', 564), ('\u{00AE}', 760),
('\u{00AF}', 333), ('\u{00B0}', 400), ('\u{00B1}', 564), ('\u{00B2}', 300), ('\u{00B3}', 300), ('\u{00B4}', 333),
('\u{00B5}', 500), ('\u{00B6}', 453), ('\u{00B7}', 250), ('\u{00B8}', 333), ('\u{00B9}', 300), ('\u{00BA}', 310),
('\u{00BB}', 500), ('\u{00BC}', 750), ('\u{00BD}', 750), ('\u{00BE}', 750), ('\u{00BF}', 444), ('\u{00C0}', 722),
('\u{00C1}', 722), ('\u{00C2}', 722), ('\u{00C3}', 722), ('\u{00C4}', 722), ('\u{00C5}', 722), ('\u{00C6}', 889),
('\u{00C7}', 667), ('\u{00C8}', 611), ('\u{00C9}', 611), ('\u{00CA}', 611), ('\u{00CB}', 611), ('\u{00CC}', 333),
('\u{00CD}', 333), ('\u{00CE}', 333), ('\u{00CF}', 333), ('\u{00D0}', 722), ('\u{00D1}', 722), ('\u{00D2}', 722),
('\u{00D3}', 722), ('\u{00D4}', 722), ('\u{00D5}', 722), ('\u{00D6}', 722), ('\u{00D7}', 564), ('\u{00D8}', 722),
('\u{00D9}', 722), ('\u{00DA}', 722), ('\u{00DB}', 722), ('\u{00DC}', 722), ('\u{00DD}', 722), ('\u{00DE}', 556),
('\u{00DF}', 500), ('\u{00E0}', 444), ('\u{00E1}', 444), ('\u{00E2}', 444), ('\u{00E3}', 444), ('\u{00E4}', 444),
('\u{00E5}', 444), ('\u{00E6}', 667), ('\u{00E7}', 444), ('\u{00E8}', 444), ('\u{00E9}', 444), ('\u{00EA}', 444),
('\u{00EB}', 444), ('\u{00EC}', 278), ('\u{00ED}', 278), ('\u{00EE}', 278), ('\u{00EF}', 278), ('\u{00F0}', 500),
('\u{00F1}', 500), ('\u{00F2}', 500), ('\u{00F3}', 500), ('\u{00F4}', 500), ('\u{00F5}', 500), ('\u{00F6}', 500),
('\u{00F7}', 564), ('\u{00F8}', 500), ('\u{00F9}', 500), ('\u{00FA}', 500), ('\u{00FB}', 500), ('\u{00FC}', 500),
('\u{00FD}', 500), ('\u{00FE}', 500), ('\u{00FF}', 500), ('\u{0131}', 278), ('\u{0141}', 611), ('\u{0142}', 278),
('\u{0152}', 889), ('\u{0153}', 722), ('\u{0160}', 556), ('\u{0161}', 389), ('\u{0178}', 722), ('\u{017D}', 611),
('\u{017E}', 444), ('\u{0192}', 500), ('\u{02C6}', 333), ('\u{02C7}', 333), ('\u{02D8}', 333), ('\u{02D9}', 333),
('\u{02DA}', 333), ('\u{02DB}', 333), ('\u{02DC}', 333), ('\u{02DD}', 333), ('\u{2013}', 500), ('\u{2014}', 1000),
('\u{2018}', 333), ('\u{2019}', 333), ('\u{201A}', 333), ('\u{201C}', 444), ('\u{201D}', 444), ('\u{201E}', 444),
('\u{2020}', 500), ('\u{2021}', 500), ('\u{2022}', 350), ('\u{2026}', 1000), ('\u{2030}', 1000), ('\u{2039}', 333),
('\u{203A}', 333), ('\u{2044}', 167), ('\u{20AC}', 500), ('\u{2122}', 980), ('\u{2212}', 564), ('\u{FB01}', 556),
('\u{FB02}', 556),
];
#[rustfmt::skip]
static TIMES_BOLD: &[(char, u16)] = &[
(' ', 250), ('!', 333), ('"', 555), ('#', 500), ('$', 500), ('%', 1000),
('&', 833), ('\'', 278), ('(', 333), (')', 333), ('*', 500), ('+', 570),
(',', 250), ('-', 333), ('.', 250), ('/', 278), ('0', 500), ('1', 500),
('2', 500), ('3', 500), ('4', 500), ('5', 500), ('6', 500), ('7', 500),
('8', 500), ('9', 500), (':', 333), (';', 333), ('<', 570), ('=', 570),
('>', 570), ('?', 500), ('@', 930), ('A', 722), ('B', 667), ('C', 722),
('D', 722), ('E', 667), ('F', 611), ('G', 778), ('H', 778), ('I', 389),
('J', 500), ('K', 778), ('L', 667), ('M', 944), ('N', 722), ('O', 778),
('P', 611), ('Q', 778), ('R', 722), ('S', 556), ('T', 667), ('U', 722),
('V', 722), ('W', 1000), ('X', 722), ('Y', 722), ('Z', 667), ('[', 333),
('\\', 278), (']', 333), ('^', 581), ('_', 500), ('`', 333), ('a', 500),
('b', 556), ('c', 444), ('d', 556), ('e', 444), ('f', 333), ('g', 500),
('h', 556), ('i', 278), ('j', 333), ('k', 556), ('l', 278), ('m', 833),
('n', 556), ('o', 500), ('p', 556), ('q', 556), ('r', 444), ('s', 389),
('t', 333), ('u', 556), ('v', 500), ('w', 722), ('x', 500), ('y', 500),
('z', 444), ('{', 394), ('|', 220), ('}', 394), ('~', 520), ('\u{00A1}', 333),
('\u{00A2}', 500), ('\u{00A3}', 500), ('\u{00A4}', 500), ('\u{00A5}', 500), ('\u{00A6}', 220), ('\u{00A7}', 500),
('\u{00A8}', 333), ('\u{00A9}', 747), ('\u{00AA}', 300), ('\u{00AB}', 500), ('\u{00AC}', 570), ('\u{00AE}', 747),
('\u{00AF}', 333), ('\u{00B0}', 400), ('\u{00B1}', 570), ('\u{00B2}', 300), ('\u{00B3}', 300), ('\u{00B4}', 333),
('\u{00B5}', 556), ('\u{00B6}', 540), ('\u{00B7}', 250), ('\u{00B8}', 333), ('\u{00B9}', 300), ('\u{00BA}', 330),
('\u{00BB}', 500), ('\u{00BC}', 750), ('\u{00BD}', 750), ('\u{00BE}', 750), ('\u{00BF}', 500), ('\u{00C0}', 722),
('\u{00C1}', 722), ('\u{00C2}', 722), ('\u{00C3}', 722), ('\u{00C4}', 722), ('\u{00C5}', 722), ('\u{00C6}', 1000),
('\u{00C7}', 722), ('\u{00C8}', 667), ('\u{00C9}', 667), ('\u{00CA}', 667), ('\u{00CB}', 667), ('\u{00CC}', 389),
('\u{00CD}', 389), ('\u{00CE}', 389), ('\u{00CF}', 389), ('\u{00D0}', 722), ('\u{00D1}', 722), ('\u{00D2}', 778),
('\u{00D3}', 778), ('\u{00D4}', 778), ('\u{00D5}', 778), ('\u{00D6}', 778), ('\u{00D7}', 570), ('\u{00D8}', 778),
('\u{00D9}', 722), ('\u{00DA}', 722), ('\u{00DB}', 722), ('\u{00DC}', 722), ('\u{00DD}', 722), ('\u{00DE}', 611),
('\u{00DF}', 556), ('\u{00E0}', 500), ('\u{00E1}', 500), ('\u{00E2}', 500), ('\u{00E3}', 500), ('\u{00E4}', 500),
('\u{00E5}', 500), ('\u{00E6}', 722), ('\u{00E7}', 444), ('\u{00E8}', 444), ('\u{00E9}', 444), ('\u{00EA}', 444),
('\u{00EB}', 444), ('\u{00EC}', 278), ('\u{00ED}', 278), ('\u{00EE}', 278), ('\u{00EF}', 278), ('\u{00F0}', 500),
('\u{00F1}', 556), ('\u{00F2}', 500), ('\u{00F3}', 500), ('\u{00F4}', 500), ('\u{00F5}', 500), ('\u{00F6}', 500),
('\u{00F7}', 570), ('\u{00F8}', 500), ('\u{00F9}', 556), ('\u{00FA}', 556), ('\u{00FB}', 556), ('\u{00FC}', 556),
('\u{00FD}', 500), ('\u{00FE}', 556), ('\u{00FF}', 500), ('\u{0131}', 278), ('\u{0141}', 667), ('\u{0142}', 278),
('\u{0152}', 1000), ('\u{0153}', 722), ('\u{0160}', 556), ('\u{0161}', 389), ('\u{0178}', 722), ('\u{017D}', 667),
('\u{017E}', 444), ('\u{0192}', 500), ('\u{02C6}', 333), ('\u{02C7}', 333), ('\u{02D8}', 333), ('\u{02D9}', 333),
('\u{02DA}', 333), ('\u{02DB}', 333), ('\u{02DC}', 333), ('\u{02DD}', 333), ('\u{2013}', 500), ('\u{2014}', 1000),
('\u{2018}', 333), ('\u{2019}', 333), ('\u{201A}', 333), ('\u{201C}', 500), ('\u{201D}', 500), ('\u{201E}', 500),
('\u{2020}', 500), ('\u{2021}', 500), ('\u{2022}', 350), ('\u{2026}', 1000), ('\u{2030}', 1000), ('\u{2039}', 333),
('\u{203A}', 333), ('\u{2044}', 167), ('\u{20AC}', 500), ('\u{2122}', 1000), ('\u{2212}', 570), ('\u{FB01}', 556),
('\u{FB02}', 556),
];
#[rustfmt::skip]
static TIMES_ITALIC: &[(char, u16)] = &[
(' ', 250), ('!', 333), ('"', 420), ('#', 500), ('$', 500), ('%', 833),
('&', 778), ('\'', 214), ('(', 333), (')', 333), ('*', 500), ('+', 675),
(',', 250), ('-', 333), ('.', 250), ('/', 278), ('0', 500), ('1', 500),
('2', 500), ('3', 500), ('4', 500), ('5', 500), ('6', 500), ('7', 500),
('8', 500), ('9', 500), (':', 333), (';', 333), ('<', 675), ('=', 675),
('>', 675), ('?', 500), ('@', 920), ('A', 611), ('B', 611), ('C', 667),
('D', 722), ('E', 611), ('F', 611), ('G', 722), ('H', 722), ('I', 333),
('J', 444), ('K', 667), ('L', 556), ('M', 833), ('N', 667), ('O', 722),
('P', 611), ('Q', 722), ('R', 611), ('S', 500), ('T', 556), ('U', 722),
('V', 611), ('W', 833), ('X', 611), ('Y', 556), ('Z', 556), ('[', 389),
('\\', 278), (']', 389), ('^', 422), ('_', 500), ('`', 333), ('a', 500),
('b', 500), ('c', 444), ('d', 500), ('e', 444), ('f', 278), ('g', 500),
('h', 500), ('i', 278), ('j', 278), ('k', 444), ('l', 278), ('m', 722),
('n', 500), ('o', 500), ('p', 500), ('q', 500), ('r', 389), ('s', 389),
('t', 278), ('u', 500), ('v', 444), ('w', 667), ('x', 444), ('y', 444),
('z', 389), ('{', 400), ('|', 275), ('}', 400), ('~', 541), ('\u{00A1}', 389),
('\u{00A2}', 500), ('\u{00A3}', 500), ('\u{00A4}', 500), ('\u{00A5}', 500), ('\u{00A6}', 275), ('\u{00A7}', 500),
('\u{00A8}', 333), ('\u{00A9}', 760), ('\u{00AA}', 276), ('\u{00AB}', 500), ('\u{00AC}', 675), ('\u{00AE}', 760),
('\u{00AF}', 333), ('\u{00B0}', 400), ('\u{00B1}', 675), ('\u{00B2}', 300), ('\u{00B3}', 300), ('\u{00B4}', 333),
('\u{00B5}', 500), ('\u{00B6}', 523), ('\u{00B7}', 250), ('\u{00B8}', 333), ('\u{00B9}', 300), ('\u{00BA}', 310),
('\u{00BB}', 500), ('\u{00BC}', 750), ('\u{00BD}', 750), ('\u{00BE}', 750), ('\u{00BF}', 500), ('\u{00C0}', 611),
('\u{00C1}', 611), ('\u{00C2}', 611), ('\u{00C3}', 611), ('\u{00C4}', 611), ('\u{00C5}', 611), ('\u{00C6}', 889),
('\u{00C7}', 667), ('\u{00C8}', 611), ('\u{00C9}', 611), ('\u{00CA}', 611), ('\u{00CB}', 611), ('\u{00CC}', 333),
('\u{00CD}', 333), ('\u{00CE}', 333), ('\u{00CF}', 333), ('\u{00D0}', 722), ('\u{00D1}', 667), ('\u{00D2}', 722),
('\u{00D3}', 722), ('\u{00D4}', 722), ('\u{00D5}', 722), ('\u{00D6}', 722), ('\u{00D7}', 675), ('\u{00D8}', 722),
('\u{00D9}', 722), ('\u{00DA}', 722), ('\u{00DB}', 722), ('\u{00DC}', 722), ('\u{00DD}', 556), ('\u{00DE}', 611),
('\u{00DF}', 500), ('\u{00E0}', 500), ('\u{00E1}', 500), ('\u{00E2}', 500), ('\u{00E3}', 500), ('\u{00E4}', 500),
('\u{00E5}', 500), ('\u{00E6}', 667), ('\u{00E7}', 444), ('\u{00E8}', 444), ('\u{00E9}', 444), ('\u{00EA}', 444),
('\u{00EB}', 444), ('\u{00EC}', 278), ('\u{00ED}', 278), ('\u{00EE}', 278), ('\u{00EF}', 278), ('\u{00F0}', 500),
('\u{00F1}', 500), ('\u{00F2}', 500), ('\u{00F3}', 500), ('\u{00F4}', 500), ('\u{00F5}', 500), ('\u{00F6}', 500),
('\u{00F7}', 675), ('\u{00F8}', 500), ('\u{00F9}', 500), ('\u{00FA}', 500), ('\u{00FB}', 500), ('\u{00FC}', 500),
('\u{00FD}', 444), ('\u{00FE}', 500), ('\u{00FF}', 444), ('\u{0131}', 278), ('\u{0141}', 556), ('\u{0142}', 278),
('\u{0152}', 944), ('\u{0153}', 667), ('\u{0160}', 500), ('\u{0161}', 389), ('\u{0178}', 556), ('\u{017D}', 556),
('\u{017E}', 389), ('\u{0192}', 500), ('\u{02C6}', 333), ('\u{02C7}', 333), ('\u{02D8}', 333), ('\u{02D9}', 333),
('\u{02DA}', 333), ('\u{02DB}', 333), ('\u{02DC}', 333), ('\u{02DD}', 333), ('\u{2013}', 500), ('\u{2014}', 889),
('\u{2018}', 333), ('\u{2019}', 333), ('\u{201A}', 333), ('\u{201C}', 556), ('\u{201D}', 556), ('\u{201E}', 556),
('\u{2020}', 500), ('\u{2021}', 500), ('\u{2022}', 350), ('\u{2026}', 889), ('\u{2030}', 1000), ('\u{2039}', 333),
('\u{203A}', 333), ('\u{2044}', 167), ('\u{20AC}', 500), ('\u{2122}', 980), ('\u{2212}', 675), ('\u{FB01}', 500),
('\u{FB02}', 500),
];
#[rustfmt::skip]
static TIMES_BOLDITALIC: &[(char, u16)] = &[
(' ', 250), ('!', 389), ('"', 555), ('#', 500), ('$', 500), ('%', 833),
('&', 778), ('\'', 278), ('(', 333), (')', 333), ('*', 500), ('+', 570),
(',', 250), ('-', 333), ('.', 250), ('/', 278), ('0', 500), ('1', 500),
('2', 500), ('3', 500), ('4', 500), ('5', 500), ('6', 500), ('7', 500),
('8', 500), ('9', 500), (':', 333), (';', 333), ('<', 570), ('=', 570),
('>', 570), ('?', 500), ('@', 832), ('A', 667), ('B', 667), ('C', 667),
('D', 722), ('E', 667), ('F', 667), ('G', 722), ('H', 778), ('I', 389),
('J', 500), ('K', 667), ('L', 611), ('M', 889), ('N', 722), ('O', 722),
('P', 611), ('Q', 722), ('R', 667), ('S', 556), ('T', 611), ('U', 722),
('V', 667), ('W', 889), ('X', 667), ('Y', 611), ('Z', 611), ('[', 333),
('\\', 278), (']', 333), ('^', 570), ('_', 500), ('`', 333), ('a', 500),
('b', 500), ('c', 444), ('d', 500), ('e', 444), ('f', 333), ('g', 500),
('h', 556), ('i', 278), ('j', 278), ('k', 500), ('l', 278), ('m', 778),
('n', 556), ('o', 500), ('p', 500), ('q', 500), ('r', 389), ('s', 389),
('t', 278), ('u', 556), ('v', 444), ('w', 667), ('x', 500), ('y', 444),
('z', 389), ('{', 348), ('|', 220), ('}', 348), ('~', 570), ('\u{00A1}', 389),
('\u{00A2}', 500), ('\u{00A3}', 500), ('\u{00A4}', 500), ('\u{00A5}', 500), ('\u{00A6}', 220), ('\u{00A7}', 500),
('\u{00A8}', 333), ('\u{00A9}', 747), ('\u{00AA}', 266), ('\u{00AB}', 500), ('\u{00AC}', 606), ('\u{00AE}', 747),
('\u{00AF}', 333), ('\u{00B0}', 400), ('\u{00B1}', 570), ('\u{00B2}', 300), ('\u{00B3}', 300), ('\u{00B4}', 333),
('\u{00B5}', 576), ('\u{00B6}', 500), ('\u{00B7}', 250), ('\u{00B8}', 333), ('\u{00B9}', 300), ('\u{00BA}', 300),
('\u{00BB}', 500), ('\u{00BC}', 750), ('\u{00BD}', 750), ('\u{00BE}', 750), ('\u{00BF}', 500), ('\u{00C0}', 667),
('\u{00C1}', 667), ('\u{00C2}', 667), ('\u{00C3}', 667), ('\u{00C4}', 667), ('\u{00C5}', 667), ('\u{00C6}', 944),
('\u{00C7}', 667), ('\u{00C8}', 667), ('\u{00C9}', 667), ('\u{00CA}', 667), ('\u{00CB}', 667), ('\u{00CC}', 389),
('\u{00CD}', 389), ('\u{00CE}', 389), ('\u{00CF}', 389), ('\u{00D0}', 722), ('\u{00D1}', 722), ('\u{00D2}', 722),
('\u{00D3}', 722), ('\u{00D4}', 722), ('\u{00D5}', 722), ('\u{00D6}', 722), ('\u{00D7}', 570), ('\u{00D8}', 722),
('\u{00D9}', 722), ('\u{00DA}', 722), ('\u{00DB}', 722), ('\u{00DC}', 722), ('\u{00DD}', 611), ('\u{00DE}', 611),
('\u{00DF}', 500), ('\u{00E0}', 500), ('\u{00E1}', 500), ('\u{00E2}', 500), ('\u{00E3}', 500), ('\u{00E4}', 500),
('\u{00E5}', 500), ('\u{00E6}', 722), ('\u{00E7}', 444), ('\u{00E8}', 444), ('\u{00E9}', 444), ('\u{00EA}', 444),
('\u{00EB}', 444), ('\u{00EC}', 278), ('\u{00ED}', 278), ('\u{00EE}', 278), ('\u{00EF}', 278), ('\u{00F0}', 500),
('\u{00F1}', 556), ('\u{00F2}', 500), ('\u{00F3}', 500), ('\u{00F4}', 500), ('\u{00F5}', 500), ('\u{00F6}', 500),
('\u{00F7}', 570), ('\u{00F8}', 500), ('\u{00F9}', 556), ('\u{00FA}', 556), ('\u{00FB}', 556), ('\u{00FC}', 556),
('\u{00FD}', 444), ('\u{00FE}', 500), ('\u{00FF}', 444), ('\u{0131}', 278), ('\u{0141}', 611), ('\u{0142}', 278),
('\u{0152}', 944), ('\u{0153}', 722), ('\u{0160}', 556), ('\u{0161}', 389), ('\u{0178}', 611), ('\u{017D}', 611),
('\u{017E}', 389), ('\u{0192}', 500), ('\u{02C6}', 333), ('\u{02C7}', 333), ('\u{02D8}', 333), ('\u{02D9}', 333),
('\u{02DA}', 333), ('\u{02DB}', 333), ('\u{02DC}', 333), ('\u{02DD}', 333), ('\u{2013}', 500), ('\u{2014}', 1000),
('\u{2018}', 333), ('\u{2019}', 333), ('\u{201A}', 333), ('\u{201C}', 500), ('\u{201D}', 500), ('\u{201E}', 500),
('\u{2020}', 500), ('\u{2021}', 500), ('\u{2022}', 350), ('\u{2026}', 1000), ('\u{2030}', 1000), ('\u{2039}', 333),
('\u{203A}', 333), ('\u{2044}', 167), ('\u{20AC}', 500), ('\u{2122}', 1000), ('\u{2212}', 606), ('\u{FB01}', 556),
('\u{FB02}', 556),
];
#[rustfmt::skip]
static SYMBOL: &[(char, u16)] = &[
(' ', 250), ('!', 333), ('#', 500), ('%', 833), ('&', 778), ('(', 333),
(')', 333), ('+', 549), (',', 250), ('.', 250), ('/', 278), ('0', 500),
('1', 500), ('2', 500), ('3', 500), ('4', 500), ('5', 500), ('6', 500),
('7', 500), ('8', 500), ('9', 500), (':', 278), (';', 278), ('<', 549),
('=', 549), ('>', 549), ('?', 444), ('[', 333), (']', 333), ('_', 500),
('{', 480), ('|', 200), ('}', 480), ('\u{00AC}', 713), ('\u{00B0}', 400), ('\u{00B1}', 549),
('\u{00B5}', 576), ('\u{00D7}', 549), ('\u{00F7}', 549), ('\u{0192}', 500), ('\u{0391}', 722), ('\u{0392}', 667),
('\u{0393}', 603), ('\u{0395}', 611), ('\u{0396}', 611), ('\u{0397}', 722), ('\u{0398}', 741), ('\u{0399}', 333),
('\u{039A}', 722), ('\u{039B}', 686), ('\u{039C}', 889), ('\u{039D}', 722), ('\u{039E}', 645), ('\u{039F}', 722),
('\u{03A0}', 768), ('\u{03A1}', 556), ('\u{03A3}', 592), ('\u{03A4}', 611), ('\u{03A5}', 690), ('\u{03A6}', 763),
('\u{03A7}', 722), ('\u{03A8}', 795), ('\u{03B1}', 631), ('\u{03B2}', 549), ('\u{03B3}', 411), ('\u{03B4}', 494),
('\u{03B5}', 439), ('\u{03B6}', 494), ('\u{03B7}', 603), ('\u{03B8}', 521), ('\u{03B9}', 329), ('\u{03BA}', 549),
('\u{03BB}', 549), ('\u{03BD}', 521), ('\u{03BE}', 493), ('\u{03BF}', 549), ('\u{03C0}', 549), ('\u{03C1}', 549),
('\u{03C2}', 439), ('\u{03C3}', 603), ('\u{03C4}', 439), ('\u{03C5}', 576), ('\u{03C6}', 521), ('\u{03C7}', 549),
('\u{03C8}', 686), ('\u{03C9}', 686), ('\u{03D1}', 631), ('\u{03D2}', 620), ('\u{03D5}', 603), ('\u{03D6}', 713),
('\u{2022}', 460), ('\u{2026}', 1000), ('\u{2032}', 247), ('\u{2033}', 411), ('\u{2044}', 167), ('\u{20AC}', 750),
('\u{2111}', 686), ('\u{2118}', 987), ('\u{211C}', 795), ('\u{2126}', 768), ('\u{2135}', 823), ('\u{2190}', 987),
('\u{2191}', 603), ('\u{2192}', 987), ('\u{2193}', 603), ('\u{2194}', 1042), ('\u{21B5}', 658), ('\u{21D0}', 987),
('\u{21D1}', 603), ('\u{21D2}', 987), ('\u{21D3}', 603), ('\u{21D4}', 1042), ('\u{2200}', 713), ('\u{2202}', 494),
('\u{2203}', 549), ('\u{2205}', 823), ('\u{2206}', 612), ('\u{2207}', 713), ('\u{2208}', 713), ('\u{2209}', 713),
('\u{220B}', 439), ('\u{220F}', 823), ('\u{2211}', 713), ('\u{2212}', 549), ('\u{2217}', 500), ('\u{221A}', 549),
('\u{221D}', 713), ('\u{221E}', 713), ('\u{2220}', 768), ('\u{2227}', 603), ('\u{2228}', 603), ('\u{2229}', 768),
('\u{222A}', 768), ('\u{222B}', 274), ('\u{2234}', 863), ('\u{223C}', 549), ('\u{2245}', 549), ('\u{2248}', 549),
('\u{2260}', 549), ('\u{2261}', 549), ('\u{2264}', 549), ('\u{2265}', 549), ('\u{2282}', 713), ('\u{2283}', 713),
('\u{2284}', 713), ('\u{2286}', 713), ('\u{2287}', 713), ('\u{2295}', 768), ('\u{2297}', 768), ('\u{22A5}', 658),
('\u{22C5}', 250), ('\u{2320}', 686), ('\u{2321}', 686), ('\u{2329}', 329), ('\u{232A}', 329), ('\u{25CA}', 494),
('\u{2660}', 753), ('\u{2663}', 753), ('\u{2665}', 753), ('\u{2666}', 753), ('\u{F6D9}', 790), ('\u{F6DA}', 790),
('\u{F6DB}', 890), ('\u{F8E5}', 500), ('\u{F8E6}', 603), ('\u{F8E7}', 1000), ('\u{F8E8}', 790), ('\u{F8E9}', 790),
('\u{F8EA}', 786), ('\u{F8EB}', 384), ('\u{F8EC}', 384), ('\u{F8ED}', 384), ('\u{F8EE}', 384), ('\u{F8EF}', 384),
('\u{F8F0}', 384), ('\u{F8F1}', 494), ('\u{F8F2}', 494), ('\u{F8F3}', 494), ('\u{F8F4}', 494), ('\u{F8F5}', 686),
('\u{F8F6}', 384), ('\u{F8F7}', 384), ('\u{F8F8}', 384), ('\u{F8F9}', 384), ('\u{F8FA}', 384), ('\u{F8FB}', 384),
('\u{F8FC}', 494), ('\u{F8FD}', 494), ('\u{F8FE}', 494), ('\u{F8FF}', 790),
];
#[rustfmt::skip]
static ZAPFDINGBATS: &[(char, u16)] = &[
(' ', 278), ('\u{2192}', 838), ('\u{2194}', 1016), ('\u{2195}', 458), ('\u{2460}', 788), ('\u{2461}', 788),
('\u{2462}', 788), ('\u{2463}', 788), ('\u{2464}', 788), ('\u{2465}', 788), ('\u{2466}', 788), ('\u{2467}', 788),
('\u{2468}', 788), ('\u{2469}', 788), ('\u{25A0}', 761), ('\u{25B2}', 892), ('\u{25BC}', 892), ('\u{25C6}', 788),
('\u{25CF}', 791), ('\u{25D7}', 438), ('\u{2605}', 816), ('\u{260E}', 719), ('\u{261B}', 960), ('\u{261E}', 939),
('\u{2660}', 626), ('\u{2663}', 776), ('\u{2665}', 694), ('\u{2666}', 595), ('\u{2701}', 974), ('\u{2702}', 961),
('\u{2703}', 974), ('\u{2704}', 980), ('\u{2706}', 789), ('\u{2707}', 790), ('\u{2708}', 791), ('\u{2709}', 690),
('\u{270C}', 549), ('\u{270D}', 855), ('\u{270E}', 911), ('\u{270F}', 933), ('\u{2710}', 911), ('\u{2711}', 945),
('\u{2712}', 974), ('\u{2713}', 755), ('\u{2714}', 846), ('\u{2715}', 762), ('\u{2716}', 761), ('\u{2717}', 571),
('\u{2718}', 677), ('\u{2719}', 763), ('\u{271A}', 760), ('\u{271B}', 759), ('\u{271C}', 754), ('\u{271D}', 494),
('\u{271E}', 552), ('\u{271F}', 537), ('\u{2720}', 577), ('\u{2721}', 692), ('\u{2722}', 786), ('\u{2723}', 788),
('\u{2724}', 788), ('\u{2725}', 790), ('\u{2726}', 793), ('\u{2727}', 794), ('\u{2729}', 823), ('\u{272A}', 789),
('\u{272B}', 841), ('\u{272C}', 823), ('\u{272D}', 833), ('\u{272E}', 816), ('\u{272F}', 831), ('\u{2730}', 923),
('\u{2731}', 744), ('\u{2732}', 723), ('\u{2733}', 749), ('\u{2734}', 790), ('\u{2735}', 792), ('\u{2736}', 695),
('\u{2737}', 776), ('\u{2738}', 768), ('\u{2739}', 792), ('\u{273A}', 759), ('\u{273B}', 707), ('\u{273C}', 708),
('\u{273D}', 682), ('\u{273E}', 701), ('\u{273F}', 826), ('\u{2740}', 815), ('\u{2741}', 789), ('\u{2742}', 789),
('\u{2743}', 707), ('\u{2744}', 687), ('\u{2745}', 696), ('\u{2746}', 689), ('\u{2747}', 786), ('\u{2748}', 787),
('\u{2749}', 713), ('\u{274A}', 791), ('\u{274B}', 785), ('\u{274D}', 873), ('\u{274F}', 762), ('\u{2750}', 762),
('\u{2751}', 759), ('\u{2752}', 759), ('\u{2756}', 784), ('\u{2758}', 138), ('\u{2759}', 277), ('\u{275A}', 415),
('\u{275B}', 392), ('\u{275C}', 392), ('\u{275D}', 668), ('\u{275E}', 668), ('\u{2761}', 732), ('\u{2762}', 544),
('\u{2763}', 544), ('\u{2764}', 910), ('\u{2765}', 667), ('\u{2766}', 760), ('\u{2767}', 760), ('\u{2768}', 390),
('\u{2769}', 390), ('\u{276A}', 317), ('\u{276B}', 317), ('\u{276C}', 276), ('\u{276D}', 276), ('\u{276E}', 509),
('\u{276F}', 509), ('\u{2770}', 410), ('\u{2771}', 410), ('\u{2772}', 234), ('\u{2773}', 234), ('\u{2774}', 334),
('\u{2775}', 334), ('\u{2776}', 788), ('\u{2777}', 788), ('\u{2778}', 788), ('\u{2779}', 788), ('\u{277A}', 788),
('\u{277B}', 788), ('\u{277C}', 788), ('\u{277D}', 788), ('\u{277E}', 788), ('\u{277F}', 788), ('\u{2780}', 788),
('\u{2781}', 788), ('\u{2782}', 788), ('\u{2783}', 788), ('\u{2784}', 788), ('\u{2785}', 788), ('\u{2786}', 788),
('\u{2787}', 788), ('\u{2788}', 788), ('\u{2789}', 788), ('\u{278A}', 788), ('\u{278B}', 788), ('\u{278C}', 788),
('\u{278D}', 788), ('\u{278E}', 788), ('\u{278F}', 788), ('\u{2790}', 788), ('\u{2791}', 788), ('\u{2792}', 788),
('\u{2793}', 788), ('\u{2794}', 894), ('\u{2798}', 748), ('\u{2799}', 924), ('\u{279A}', 748), ('\u{279B}', 918),
('\u{279C}', 927), ('\u{279D}', 928), ('\u{279E}', 928), ('\u{279F}', 834), ('\u{27A0}', 873), ('\u{27A1}', 828),
('\u{27A2}', 924), ('\u{27A3}', 924), ('\u{27A4}', 917), ('\u{27A5}', 930), ('\u{27A6}', 931), ('\u{27A7}', 463),
('\u{27A8}', 883), ('\u{27A9}', 836), ('\u{27AA}', 836), ('\u{27AB}', 867), ('\u{27AC}', 867), ('\u{27AD}', 696),
('\u{27AE}', 696), ('\u{27AF}', 874), ('\u{27B1}', 874), ('\u{27B2}', 760), ('\u{27B3}', 946), ('\u{27B4}', 771),
('\u{27B5}', 865), ('\u{27B6}', 771), ('\u{27B7}', 888), ('\u{27B8}', 967), ('\u{27B9}', 888), ('\u{27BA}', 831),
('\u{27BB}', 873), ('\u{27BC}', 927), ('\u{27BD}', 970), ('\u{27BE}', 918),
];
#[rustfmt::skip]
static SYMBOL_ENCODING: &[(u8, char)] = &[
(0x20, ' '), (0x21, '!'), (0x22, '\u{2200}'), (0x23, '#'), (0x24, '\u{2203}'), (0x25, '%'),
(0x26, '&'), (0x27, '\u{220B}'), (0x28, '('), (0x29, ')'), (0x2A, '\u{2217}'), (0x2B, '+'),
(0x2C, ','), (0x2D, '\u{2212}'), (0x2E, '.'), (0x2F, '/'), (0x30, '0'), (0x31, '1'),
(0x32, '2'), (0x33, '3'), (0x34, '4'), (0x35, '5'), (0x36, '6'), (0x37, '7'),
(0x38, '8'), (0x39, '9'), (0x3A, ':'), (0x3B, ';'), (0x3C, '<'), (0x3D, '='),
(0x3E, '>'), (0x3F, '?'), (0x40, '\u{2245}'), (0x41, '\u{0391}'), (0x42, '\u{0392}'), (0x43, '\u{03A7}'),
(0x44, '\u{2206}'), (0x45, '\u{0395}'), (0x46, '\u{03A6}'), (0x47, '\u{0393}'), (0x48, '\u{0397}'), (0x49, '\u{0399}'),
(0x4A, '\u{03D1}'), (0x4B, '\u{039A}'), (0x4C, '\u{039B}'), (0x4D, '\u{039C}'), (0x4E, '\u{039D}'), (0x4F, '\u{039F}'),
(0x50, '\u{03A0}'), (0x51, '\u{0398}'), (0x52, '\u{03A1}'), (0x53, '\u{03A3}'), (0x54, '\u{03A4}'), (0x55, '\u{03A5}'),
(0x56, '\u{03C2}'), (0x57, '\u{2126}'), (0x58, '\u{039E}'), (0x59, '\u{03A8}'), (0x5A, '\u{0396}'), (0x5B, '['),
(0x5C, '\u{2234}'), (0x5D, ']'), (0x5E, '\u{22A5}'), (0x5F, '_'), (0x60, '\u{F8E5}'), (0x61, '\u{03B1}'),
(0x62, '\u{03B2}'), (0x63, '\u{03C7}'), (0x64, '\u{03B4}'), (0x65, '\u{03B5}'), (0x66, '\u{03C6}'), (0x67, '\u{03B3}'),
(0x68, '\u{03B7}'), (0x69, '\u{03B9}'), (0x6A, '\u{03D5}'), (0x6B, '\u{03BA}'), (0x6C, '\u{03BB}'), (0x6D, '\u{00B5}'),
(0x6E, '\u{03BD}'), (0x6F, '\u{03BF}'), (0x70, '\u{03C0}'), (0x71, '\u{03B8}'), (0x72, '\u{03C1}'), (0x73, '\u{03C3}'),
(0x74, '\u{03C4}'), (0x75, '\u{03C5}'), (0x76, '\u{03D6}'), (0x77, '\u{03C9}'), (0x78, '\u{03BE}'), (0x79, '\u{03C8}'),
(0x7A, '\u{03B6}'), (0x7B, '{'), (0x7C, '|'), (0x7D, '}'), (0x7E, '\u{223C}'), (0xA0, '\u{20AC}'),
(0xA1, '\u{03D2}'), (0xA2, '\u{2032}'), (0xA3, '\u{2264}'), (0xA4, '\u{2044}'), (0xA5, '\u{221E}'), (0xA6, '\u{0192}'),
(0xA7, '\u{2663}'), (0xA8, '\u{2666}'), (0xA9, '\u{2665}'), (0xAA, '\u{2660}'), (0xAB, '\u{2194}'), (0xAC, '\u{2190}'),
(0xAD, '\u{2191}'), (0xAE, '\u{2192}'), (0xAF, '\u{2193}'), (0xB0, '\u{00B0}'), (0xB1, '\u{00B1}'), (0xB2, '\u{2033}'),
(0xB3, '\u{2265}'), (0xB4, '\u{00D7}'), (0xB5, '\u{221D}'), (0xB6, '\u{2202}'), (0xB7, '\u{2022}'), (0xB8, '\u{00F7}'),
(0xB9, '\u{2260}'), (0xBA, '\u{2261}'), (0xBB, '\u{2248}'), (0xBC, '\u{2026}'), (0xBD, '\u{F8E6}'), (0xBE, '\u{F8E7}'),
(0xBF, '\u{21B5}'), (0xC0, '\u{2135}'), (0xC1, '\u{2111}'), (0xC2, '\u{211C}'), (0xC3, '\u{2118}'), (0xC4, '\u{2297}'),
(0xC5, '\u{2295}'), (0xC6, '\u{2205}'), (0xC7, '\u{2229}'), (0xC8, '\u{222A}'), (0xC9, '\u{2283}'), (0xCA, '\u{2287}'),
(0xCB, '\u{2284}'), (0xCC, '\u{2282}'), (0xCD, '\u{2286}'), (0xCE, '\u{2208}'), (0xCF, '\u{2209}'), (0xD0, '\u{2220}'),
(0xD1, '\u{2207}'), (0xD2, '\u{F6DA}'), (0xD3, '\u{F6D9}'), (0xD4, '\u{F6DB}'), (0xD5, '\u{220F}'), (0xD6, '\u{221A}'),
(0xD7, '\u{22C5}'), (0xD8, '\u{00AC}'), (0xD9, '\u{2227}'), (0xDA, '\u{2228}'), (0xDB, '\u{21D4}'), (0xDC, '\u{21D0}'),
(0xDD, '\u{21D1}'), (0xDE, '\u{21D2}'), (0xDF, '\u{21D3}'), (0xE0, '\u{25CA}'), (0xE1, '\u{2329}'), (0xE2, '\u{F8E8}'),
(0xE3, '\u{F8E9}'), (0xE4, '\u{F8EA}'), (0xE5, '\u{2211}'), (0xE6, '\u{F8EB}'), (0xE7, '\u{F8EC}'), (0xE8, '\u{F8ED}'),
(0xE9, '\u{F8EE}'), (0xEA, '\u{F8EF}'), (0xEB, '\u{F8F0}'), (0xEC, '\u{F8F1}'), (0xED, '\u{F8F2}'), (0xEE, '\u{F8F3}'),
(0xEF, '\u{F8F4}'), (0xF1, '\u{232A}'), (0xF2, '\u{222B}'), (0xF3, '\u{2320}'), (0xF4, '\u{F8F5}'), (0xF5, '\u{2321}'),
(0xF6, '\u{F8F6}'), (0xF7, '\u{F8F7}'), (0xF8, '\u{F8F8}'), (0xF9, '\u{F8F9}'), (0xFA, '\u{F8FA}'), (0xFB, '\u{F8FB}'),
(0xFC, '\u{F8FC}'), (0xFD, '\u{F8FD}'), (0xFE, '\u{F8FE}'),
];
#[rustfmt::skip]
static ZAPFDINGBATS_ENCODING: &[(u8, char)] = &[
(0x20, ' '), (0x21, '\u{2701}'), (0x22, '\u{2702}'), (0x23, '\u{2703}'), (0x24, '\u{2704}'), (0x25, '\u{260E}'),
(0x26, '\u{2706}'), (0x27, '\u{2707}'), (0x28, '\u{2708}'), (0x29, '\u{2709}'), (0x2A, '\u{261B}'), (0x2B, '\u{261E}'),
(0x2C, '\u{270C}'), (0x2D, '\u{270D}'), (0x2E, '\u{270E}'), (0x2F, '\u{270F}'), (0x30, '\u{2710}'), (0x31, '\u{2711}'),
(0x32, '\u{2712}'), (0x33, '\u{2713}'), (0x34, '\u{2714}'), (0x35, '\u{2715}'), (0x36, '\u{2716}'), (0x37, '\u{2717}'),
(0x38, '\u{2718}'), (0x39, '\u{2719}'), (0x3A, '\u{271A}'), (0x3B, '\u{271B}'), (0x3C, '\u{271C}'), (0x3D, '\u{271D}'),
(0x3E, '\u{271E}'), (0x3F, '\u{271F}'), (0x40, '\u{2720}'), (0x41, '\u{2721}'), (0x42, '\u{2722}'), (0x43, '\u{2723}'),
(0x44, '\u{2724}'), (0x45, '\u{2725}'), (0x46, '\u{2726}'), (0x47, '\u{2727}'), (0x48, '\u{2605}'), (0x49, '\u{2729}'),
(0x4A, '\u{272A}'), (0x4B, '\u{272B}'), (0x4C, '\u{272C}'), (0x4D, '\u{272D}'), (0x4E, '\u{272E}'), (0x4F, '\u{272F}'),
(0x50, '\u{2730}'), (0x51, '\u{2731}'), (0x52, '\u{2732}'), (0x53, '\u{2733}'), (0x54, '\u{2734}'), (0x55, '\u{2735}'),
(0x56, '\u{2736}'), (0x57, '\u{2737}'), (0x58, '\u{2738}'), (0x59, '\u{2739}'), (0x5A, '\u{273A}'), (0x5B, '\u{273B}'),
(0x5C, '\u{273C}'), (0x5D, '\u{273D}'), (0x5E, '\u{273E}'), (0x5F, '\u{273F}'), (0x60, '\u{2740}'), (0x61, '\u{2741}'),
(0x62, '\u{2742}'), (0x63, '\u{2743}'), (0x64, '\u{2744}'), (0x65, '\u{2745}'), (0x66, '\u{2746}'), (0x67, '\u{2747}'),
(0x68, '\u{2748}'), (0x69, '\u{2749}'), (0x6A, '\u{274A}'), (0x6B, '\u{274B}'), (0x6C, '\u{25CF}'), (0x6D, '\u{274D}'),
(0x6E, '\u{25A0}'), (0x6F, '\u{274F}'), (0x70, '\u{2750}'), (0x71, '\u{2751}'), (0x72, '\u{2752}'), (0x73, '\u{25B2}'),
(0x74, '\u{25BC}'), (0x75, '\u{25C6}'), (0x76, '\u{2756}'), (0x77, '\u{25D7}'), (0x78, '\u{2758}'), (0x79, '\u{2759}'),
(0x7A, '\u{275A}'), (0x7B, '\u{275B}'), (0x7C, '\u{275C}'), (0x7D, '\u{275D}'), (0x7E, '\u{275E}'), (0x80, '\u{2768}'),
(0x81, '\u{2769}'), (0x82, '\u{276A}'), (0x83, '\u{276B}'), (0x84, '\u{276C}'), (0x85, '\u{276D}'), (0x86, '\u{276E}'),
(0x87, '\u{276F}'), (0x88, '\u{2770}'), (0x89, '\u{2771}'), (0x8A, '\u{2772}'), (0x8B, '\u{2773}'), (0x8C, '\u{2774}'),
(0x8D, '\u{2775}'), (0xA1, '\u{2761}'), (0xA2, '\u{2762}'), (0xA3, '\u{2763}'), (0xA4, '\u{2764}'), (0xA5, '\u{2765}'),
(0xA6, '\u{2766}'), (0xA7, '\u{2767}'), (0xA8, '\u{2663}'), (0xA9, '\u{2666}'), (0xAA, '\u{2665}'), (0xAB, '\u{2660}'),
(0xAC, '\u{2460}'), (0xAD, '\u{2461}'), (0xAE, '\u{2462}'), (0xAF, '\u{2463}'), (0xB0, '\u{2464}'), (0xB1, '\u{2465}'),
(0xB2, '\u{2466}'), (0xB3, '\u{2467}'), (0xB4, '\u{2468}'), (0xB5, '\u{2469}'), (0xB6, '\u{2776}'), (0xB7, '\u{2777}'),
(0xB8, '\u{2778}'), (0xB9, '\u{2779}'), (0xBA, '\u{277A}'), (0xBB, '\u{277B}'), (0xBC, '\u{277C}'), (0xBD, '\u{277D}'),
(0xBE, '\u{277E}'), (0xBF, '\u{277F}'), (0xC0, '\u{2780}'), (0xC1, '\u{2781}'), (0xC2, '\u{2782}'), (0xC3, '\u{2783}'),
(0xC4, '\u{2784}'), (0xC5, '\u{2785}'), (0xC6, '\u{2786}'), (0xC7, '\u{2787}'), (0xC8, '\u{2788}'), (0xC9, '\u{2789}'),
(0xCA, '\u{278A}'), (0xCB, '\u{278B}'), (0xCC, '\u{278C}'), (0xCD, '\u{278D}'), (0xCE, '\u{278E}'), (0xCF, '\u{278F}'),
(0xD0, '\u{2790}'), (0xD1, '\u{2791}'), (0xD2, '\u{2792}'), (0xD3, '\u{2793}'), (0xD4, '\u{2794}'), (0xD5, '\u{2192}'),
(0xD6, '\u{2194}'), (0xD7, '\u{2195}'), (0xD8, '\u{2798}'), (0xD9, '\u{2799}'), (0xDA, '\u{279A}'), (0xDB, '\u{279B}'),
(0xDC, '\u{279C}'), (0xDD, '\u{279D}'), (0xDE, '\u{279E}'), (0xDF, '\u{279F}'), (0xE0, '\u{27A0}'), (0xE1, '\u{27A1}'),
(0xE2, '\u{27A2}'), (0xE3, '\u{27A3}'), (0xE4, '\u{27A4}'), (0xE5, '\u{27A5}'), (0xE6, '\u{27A6}'), (0xE7, '\u{27A7}'),
(0xE8, '\u{27A8}'), (0xE9, '\u{27A9}'), (0xEA, '\u{27AA}'), (0xEB, '\u{27AB}'), (0xEC, '\u{27AC}'), (0xED, '\u{27AD}'),
(0xEE, '\u{27AE}'), (0xEF, '\u{27AF}'), (0xF1, '\u{27B1}'), (0xF2, '\u{27B2}'), (0xF3, '\u{27B3}'), (0xF4, '\u{27B4}'),
(0xF5, '\u{27B5}'), (0xF6, '\u{27B6}'), (0xF7, '\u{27B7}'), (0xF8, '\u{27B8}'), (0xF9, '\u{27B9}'), (0xFA, '\u{27BA}'),
(0xFB, '\u{27BB}'), (0xFC, '\u{27BC}'), (0xFD, '\u{27BD}'), (0xFE, '\u{27BE}'),
];
/// Every width table, for exhaustive testing.
#[cfg(test)]
static ALL_TABLES: &[(&str, &[(char, u16)])] = &[
("COURIER", COURIER),
("COURIER_BOLD", COURIER_BOLD),
("COURIER_OBLIQUE", COURIER_OBLIQUE),
("COURIER_BOLDOBLIQUE", COURIER_BOLDOBLIQUE),
("HELVETICA", HELVETICA),
("HELVETICA_BOLD", HELVETICA_BOLD),
("HELVETICA_OBLIQUE", HELVETICA_OBLIQUE),
("HELVETICA_BOLDOBLIQUE", HELVETICA_BOLDOBLIQUE),
("TIMES_ROMAN", TIMES_ROMAN),
("TIMES_BOLD", TIMES_BOLD),
("TIMES_ITALIC", TIMES_ITALIC),
("TIMES_BOLDITALIC", TIMES_BOLDITALIC),
("SYMBOL", SYMBOL),
("ZAPFDINGBATS", ZAPFDINGBATS),
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn times_roman_ascii_widths() {
assert_eq!(base14_char_width("Times-Roman", ' '), Some(250));
assert_eq!(base14_char_width("Times-Roman", 'M'), Some(889));
assert_eq!(base14_char_width("Times-Roman", 'i'), Some(278));
}
#[test]
fn subset_prefix_and_aliases_normalize() {
assert_eq!(
base14_char_width("ABCDEF+Times-Bold", ' '),
base14_char_width("Times-Bold", ' ')
);
assert!(base14_char_width("ArialMT", 'a').is_some());
assert!(base14_char_width("TimesNewRomanPSMT", 'a').is_some());
}
#[test]
fn non_base14_returns_none() {
assert_eq!(base14_char_width("DejaVuSans", 'a'), None);
assert!(!is_base14_font("Garamond"));
}
#[test]
fn builtin_encoding_resolves_symbol_and_zapf_codes() {
// Symbol 0x61 renders alpha; Zapf 0x21 renders U+2701.
assert_eq!(builtin_encoding_char("Symbol", 0x61), Some('\u{03B1}'));
assert_eq!(builtin_encoding_char("Symbol", 0xA5), Some('\u{221E}'));
assert_eq!(
builtin_encoding_char("ZapfDingbats", 0x21),
Some('\u{2701}')
);
// Latin text fonts follow standard encodings — no builtin override.
assert_eq!(builtin_encoding_char("Times-Roman", 0x61), None);
// The resolved chars have real AFM widths.
let alpha_w = base14_char_width("Symbol", '\u{03B1}');
assert!(alpha_w.is_some() && alpha_w != Some(500));
}
#[test]
fn encoding_tables_are_sorted_for_binary_search() {
for table in [SYMBOL_ENCODING, ZAPFDINGBATS_ENCODING] {
assert!(table.windows(2).all(|w| w[0].0 < w[1].0));
}
}
#[test]
fn tables_are_sorted_for_binary_search() {
// Every table is queried by binary search, so all of them must be
// sorted — not just a sample.
for (name, table) in ALL_TABLES {
assert!(
table.windows(2).all(|w| w[0].0 < w[1].0),
"{name} is not sorted"
);
}
}
}
-328
View File
@@ -1,328 +0,0 @@
//! 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());
}
}
File diff suppressed because it is too large Load Diff
+45 -1236
View File
File diff suppressed because it is too large Load Diff
+43 -1344
View File
File diff suppressed because it is too large Load Diff
+7 -405
View File
@@ -2,51 +2,11 @@
use crate::types::{ItemType, TextItem};
use lopdf::{Document, Object, ObjectId};
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use super::fonts::{resolve_array, resolve_dict};
use super::get_number;
/// Upper bound on the number of form-field nodes visited during a single
/// `extract_form_fields` pass. A crafted PDF can chain thousands of distinct
/// `/Kids` fields to blow the stack even without an outright reference cycle,
/// so we cap total traversal work in addition to detecting cycles.
const MAX_FORM_FIELD_NODES: usize = 100_000;
/// Upper bound on `/Kids` recursion depth. Real AcroForm hierarchies are only
/// a few levels deep (fields → child fields → widgets); a crafted PDF can chain
/// tens of thousands of distinct fields into a linear `/Kids` list that would
/// overflow the stack via depth-first recursion long before the node budget is
/// reached. This depth cap bounds the stack independently of total node count.
const MAX_FORM_FIELD_DEPTH: usize = 100;
/// Traversal budget for the AcroForm field walk. Bounds both the number of
/// distinct nodes visited *and* the total number of `/Fields`/`/Kids` entries
/// examined.
///
/// Counting `visited` alone is not enough: invalid entries (non-references) and
/// duplicate references never grow `visited`, so an oversized array full of them
/// would iterate to completion no matter how large. Charging every examined
/// entry against the same budget makes it a real cap on traversal work.
pub(crate) struct FieldWalkBudget {
visited: HashSet<ObjectId>,
examined: usize,
}
impl FieldWalkBudget {
fn new() -> Self {
Self {
visited: HashSet::new(),
examined: 0,
}
}
/// True once the budget is spent; callers must stop iterating and recursing.
fn exhausted(&self) -> bool {
self.visited.len() >= MAX_FORM_FIELD_NODES || self.examined >= MAX_FORM_FIELD_NODES
}
}
pub fn extract_page_links(doc: &Document, page_id: ObjectId, page_num: u32) -> Vec<TextItem> {
let mut links = Vec::new();
@@ -118,8 +78,6 @@ pub fn extract_page_links(doc: &Document, page_id: ObjectId, page_num: u32) -> V
page: page_num,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: ItemType::Link(url),
mcid: None,
});
@@ -186,104 +144,32 @@ pub(crate) fn extract_form_fields(
Err(_) => return items,
};
// Borrow the array rather than cloning it: a crafted `/Fields` can be huge,
// and cloning would pay an O(n) allocation/copy before the budget check
// below can stop the work.
let fields = match acroform.get(b"Fields") {
Ok(obj) => match resolve_array(doc, obj) {
Some(arr) => arr,
Some(arr) => arr.clone(),
None => return items,
},
Err(_) => return items,
};
if fields.is_empty() {
return items;
}
let annotation_pages = annotation_page_map(doc, page_map);
// Bound the walk so a crafted PDF cannot send us into unbounded recursion
// via a `/Kids` cycle, a deep chain, or an oversized array of invalid or
// duplicate entries.
let mut budget = FieldWalkBudget::new();
for field_obj in fields {
// Stop once the budget is spent so a `/Fields` array wider than the
// budget can't burn CPU iterating entries whose walk would no-op. Charge
// every entry (including invalid ones) against the budget.
if budget.exhausted() {
break;
}
budget.examined += 1;
for field_obj in &fields {
if let Ok(field_ref) = field_obj.as_reference() {
walk_form_fields(
doc,
field_ref,
None,
"",
page_map,
&annotation_pages,
&mut items,
&mut budget,
0,
);
walk_form_fields(doc, field_ref, None, "", page_map, &mut items);
}
}
items
}
/// Map widget annotation objects back to the page whose `/Annots` array owns
/// them. Some valid widgets omit `/P`, so the page tree is the only reliable
/// ownership signal available for page-filtered extraction.
fn annotation_page_map(
doc: &Document,
page_map: &HashMap<ObjectId, u32>,
) -> HashMap<ObjectId, u32> {
let mut annotation_pages = HashMap::new();
for (&page_id, &page_num) in page_map {
let Some(annotations) = doc
.get_dictionary(page_id)
.ok()
.and_then(|page| page.get(b"Annots").ok())
.and_then(|annotations| resolve_array(doc, annotations))
else {
continue;
};
for annotation in annotations {
if let Ok(annotation_id) = annotation.as_reference() {
annotation_pages.insert(annotation_id, page_num);
}
}
}
annotation_pages
}
/// Recursively walk the form field tree, extracting leaf field values.
#[allow(clippy::too_many_arguments)]
pub(crate) fn walk_form_fields(
doc: &Document,
field_id: ObjectId,
parent_ft: Option<&[u8]>,
parent_name: &str,
page_map: &HashMap<ObjectId, u32>,
annotation_pages: &HashMap<ObjectId, u32>,
items: &mut Vec<TextItem>,
budget: &mut FieldWalkBudget,
depth: usize,
) {
// Guard against `/Kids` cycles and pathologically large field trees.
// Exceeding the depth cap means the chain is too deep to be a legitimate
// form (and would overflow the stack); an exhausted budget means the tree is
// too large. Both checks run *before* inserting so the visited set can never
// grow past the budget.
if depth > MAX_FORM_FIELD_DEPTH || budget.exhausted() {
return;
}
// Revisiting an object ID means we hit a `/Kids` cycle.
if !budget.visited.insert(field_id) {
return;
}
let field_dict = match doc.get_dictionary(field_id) {
Ok(d) => d,
Err(_) => return,
@@ -314,31 +200,11 @@ pub(crate) fn walk_form_fields(
// Check for /Kids — if present, recurse into children
if let Ok(kids_obj) = field_dict.get(b"Kids") {
// Iterate the borrowed array directly — cloning a crafted, oversized
// `/Kids` would allocate and copy every entry before the budget check
// below could stop the work.
if let Some(kids) = resolve_array(doc, kids_obj) {
for kid in kids {
// Stop once the budget is spent so a `/Kids` array wider than the
// budget can't burn CPU iterating entries whose walk would no-op.
// Charge every entry (including invalid/duplicate ones) against
// the budget so this is a true traversal-work cap.
if budget.exhausted() {
break;
}
budget.examined += 1;
let kids = kids.clone();
for kid in &kids {
if let Ok(kid_ref) = kid.as_reference() {
walk_form_fields(
doc,
kid_ref,
ft,
&full_name,
page_map,
annotation_pages,
items,
budget,
depth + 1,
);
walk_form_fields(doc, kid_ref, ft, &full_name, page_map, items);
}
}
return;
@@ -431,7 +297,6 @@ pub(crate) fn walk_form_fields(
.ok()
.and_then(|o| o.as_reference().ok())
.and_then(|p| page_map.get(&p).copied())
.or_else(|| annotation_pages.get(&field_id).copied())
.unwrap_or(1);
let text = if full_name.is_empty() {
@@ -451,270 +316,7 @@ pub(crate) fn walk_form_fields(
page: page_num,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: ItemType::FormField,
mcid: None,
});
}
#[cfg(test)]
mod tests {
use super::*;
use lopdf::{dictionary, Object};
#[test]
fn widget_without_page_reference_uses_owning_page_annotation() {
let mut doc = Document::new();
let widget_id = doc.add_object(dictionary! {
"Type" => "Annot",
"Subtype" => "Widget",
"FT" => "Tx",
"T" => Object::string_literal("customer"),
"V" => Object::string_literal("Alice"),
"Rect" => vec![10.into(), 20.into(), 110.into(), 40.into()],
});
let page_one_id = doc.add_object(dictionary! {
"Type" => "Page",
});
let page_two_id = doc.add_object(dictionary! {
"Type" => "Page",
"Annots" => vec![Object::Reference(widget_id)],
});
let catalog_id = doc.add_object(dictionary! {
"Type" => "Catalog",
"AcroForm" => dictionary! {
"Fields" => vec![Object::Reference(widget_id)],
},
});
doc.trailer.set("Root", Object::Reference(catalog_id));
let page_map = HashMap::from([(page_one_id, 1), (page_two_id, 2)]);
let items = extract_form_fields(&doc, &page_map);
assert_eq!(items.len(), 1);
assert_eq!(items[0].page, 2);
assert_eq!(items[0].text, "customer: Alice");
}
#[test]
fn kids_self_cycle_does_not_overflow_stack() {
// A crafted AcroForm field that lists itself in `/Kids` must not send
// the traversal into unbounded recursion.
let mut doc = Document::new();
let field_id = doc.new_object_id();
doc.set_object(
field_id,
dictionary! {
"FT" => "Tx",
"T" => Object::string_literal("loop"),
"Kids" => vec![Object::Reference(field_id)],
},
);
let catalog_id = doc.add_object(dictionary! {
"Type" => "Catalog",
"AcroForm" => dictionary! {
"Fields" => vec![Object::Reference(field_id)],
},
});
doc.trailer.set("Root", Object::Reference(catalog_id));
let page_map = HashMap::new();
// Completes (rather than overflowing the stack) and yields no items.
let items = extract_form_fields(&doc, &page_map);
assert!(items.is_empty());
}
#[test]
fn kids_mutual_cycle_terminates() {
// Two fields that reference each other via `/Kids` form a cycle that
// must also terminate.
let mut doc = Document::new();
let field_a = doc.new_object_id();
let field_b = doc.new_object_id();
doc.set_object(
field_a,
dictionary! {
"T" => Object::string_literal("a"),
"Kids" => vec![Object::Reference(field_b)],
},
);
doc.set_object(
field_b,
dictionary! {
"T" => Object::string_literal("b"),
"Kids" => vec![Object::Reference(field_a)],
},
);
let catalog_id = doc.add_object(dictionary! {
"Type" => "Catalog",
"AcroForm" => dictionary! {
"Fields" => vec![Object::Reference(field_a)],
},
});
doc.trailer.set("Root", Object::Reference(catalog_id));
let page_map = HashMap::new();
let items = extract_form_fields(&doc, &page_map);
assert!(items.is_empty());
}
#[test]
fn deep_acyclic_kids_chain_does_not_overflow_stack() {
// A long chain of *distinct* fields (no cycle) must also terminate:
// the visited set alone would still recurse to the chain length, so
// the depth cap is what prevents a stack overflow here.
let mut doc = Document::new();
let n = MAX_FORM_FIELD_DEPTH * 500;
let ids: Vec<ObjectId> = (0..=n).map(|_| doc.new_object_id()).collect();
for i in 0..n {
doc.set_object(
ids[i],
dictionary! {
"FT" => "Tx",
"Kids" => vec![Object::Reference(ids[i + 1])],
},
);
}
// Leaf carries a value; it sits far below the depth cap so it is never
// reached, proving traversal stops early rather than crashing.
doc.set_object(
ids[n],
dictionary! {
"FT" => "Tx",
"T" => Object::string_literal("leaf"),
"V" => Object::string_literal("x"),
"Rect" => vec![10.into(), 20.into(), 110.into(), 40.into()],
},
);
let catalog_id = doc.add_object(dictionary! {
"Type" => "Catalog",
"AcroForm" => dictionary! {
"Fields" => vec![Object::Reference(ids[0])],
},
});
doc.trailer.set("Root", Object::Reference(catalog_id));
let page_map = HashMap::new();
let items = extract_form_fields(&doc, &page_map);
assert!(items.is_empty());
}
#[test]
fn wide_tree_traversal_stops_at_node_budget() {
// A single field with a `/Kids` array wider than the node budget must
// stop traversal at the cap rather than growing `visited` (and the work)
// without bound. Each processed leaf emits one item, so the item count
// is bounded by the budget and reaches right up to it (a couple of
// slots go to the root and the boundary node charged against the cap).
let mut doc = Document::new();
let fanout = MAX_FORM_FIELD_NODES + 50;
let leaf_ids: Vec<ObjectId> = (0..fanout).map(|_| doc.new_object_id()).collect();
for &leaf in &leaf_ids {
doc.set_object(
leaf,
dictionary! {
"FT" => "Tx",
"V" => Object::string_literal("v"),
"Rect" => vec![10.into(), 20.into(), 110.into(), 40.into()],
},
);
}
let kids: Vec<Object> = leaf_ids.iter().map(|&id| Object::Reference(id)).collect();
let root_id = doc.add_object(dictionary! {
"T" => Object::string_literal("root"),
"Kids" => kids,
});
let catalog_id = doc.add_object(dictionary! {
"Type" => "Catalog",
"AcroForm" => dictionary! {
"Fields" => vec![Object::Reference(root_id)],
},
});
doc.trailer.set("Root", Object::Reference(catalog_id));
let page_map = HashMap::new();
let items = extract_form_fields(&doc, &page_map);
// Extraction stops at the budget: bounded above by the cap, and it gets
// right up to it (allowing a small delta for the root/boundary nodes
// charged against the budget).
assert!(items.len() <= MAX_FORM_FIELD_NODES);
assert!(items.len() >= MAX_FORM_FIELD_NODES - 3);
}
#[test]
fn wide_top_level_fields_stop_at_node_budget() {
// A top-level `/Fields` array wider than the budget must also stop at
// the cap: the item count is bounded by the budget and reaches right up
// to it.
let mut doc = Document::new();
let fanout = MAX_FORM_FIELD_NODES + 50;
let leaf_ids: Vec<ObjectId> = (0..fanout).map(|_| doc.new_object_id()).collect();
for &leaf in &leaf_ids {
doc.set_object(
leaf,
dictionary! {
"FT" => "Tx",
"V" => Object::string_literal("v"),
"Rect" => vec![10.into(), 20.into(), 110.into(), 40.into()],
},
);
}
let fields: Vec<Object> = leaf_ids.iter().map(|&id| Object::Reference(id)).collect();
let catalog_id = doc.add_object(dictionary! {
"Type" => "Catalog",
"AcroForm" => dictionary! {
"Fields" => fields,
},
});
doc.trailer.set("Root", Object::Reference(catalog_id));
let page_map = HashMap::new();
let items = extract_form_fields(&doc, &page_map);
assert!(items.len() <= MAX_FORM_FIELD_NODES);
assert!(items.len() >= MAX_FORM_FIELD_NODES - 3);
}
#[test]
fn duplicate_and_invalid_kids_entries_stop_at_budget() {
// Duplicate references and non-reference junk never grow `visited`, so
// without charging examined entries against the budget an oversized
// array of them would iterate to completion. The walk must still
// terminate and extract the single real leaf exactly once.
let mut doc = Document::new();
let leaf_id = doc.new_object_id();
doc.set_object(
leaf_id,
dictionary! {
"FT" => "Tx",
"V" => Object::string_literal("v"),
"Rect" => vec![10.into(), 20.into(), 110.into(), 40.into()],
},
);
// A `/Kids` array far wider than the budget: half duplicate references
// to the same leaf, half invalid (null) entries.
let mut kids: Vec<Object> = Vec::new();
for i in 0..(MAX_FORM_FIELD_NODES * 2) {
if i % 2 == 0 {
kids.push(Object::Reference(leaf_id));
} else {
kids.push(Object::Null);
}
}
let root_id = doc.add_object(dictionary! {
"T" => Object::string_literal("root"),
"Kids" => kids,
});
let catalog_id = doc.add_object(dictionary! {
"Type" => "Catalog",
"AcroForm" => dictionary! {
"Fields" => vec![Object::Reference(root_id)],
},
});
doc.trailer.set("Root", Object::Reference(catalog_id));
let page_map = HashMap::new();
let items = extract_form_fields(&doc, &page_map);
assert_eq!(items.len(), 1);
}
}
+37 -2083
View File
File diff suppressed because it is too large Load Diff
-591
View File
@@ -1,591 +0,0 @@
//! Region-graph evidence for page reading order.
//!
//! Whole-page column histograms fail when images or spanning captions occupy
//! only part of a page. This module turns image geometry and repeated row
//! gutters into a small directed acyclic graph: content above a local column
//! band, the left flow, the right flow, and content below it. The graph is
//! deliberately evidence-gated; ordinary pages keep the established layout
//! path.
use crate::text_utils::{effective_width, is_cjk_char, is_rtl_text};
use crate::types::TextItem;
const MIN_IMAGE_WIDTH: f32 = 60.0;
const MIN_IMAGE_HEIGHT: f32 = 40.0;
const MIN_ROW_GUTTER: f32 = 8.0;
const SPLIT_CLUSTER_TOLERANCE: f32 = 20.0;
const MIN_ALIGNED_ROWS: usize = 4;
pub(crate) type ImageRegion = (f32, f32, f32, f32);
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct ColumnFlowBand {
pub(crate) split_x: f32,
pub(crate) y_bottom: f32,
pub(crate) y_top: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RegionKind {
FullWidth,
Column,
}
#[derive(Debug)]
pub(crate) struct RegionNode {
pub(crate) kind: RegionKind,
pub(crate) items: Vec<TextItem>,
}
#[derive(Debug)]
struct Row<'a> {
y: f32,
items: Vec<&'a TextItem>,
}
fn page_x_bounds(items: &[TextItem], images: &[ImageRegion]) -> Option<(f32, f32)> {
let text_min = items
.iter()
.map(|item| item.x)
.fold(f32::INFINITY, f32::min);
let text_max = items
.iter()
.map(|item| item.x + effective_width(item))
.fold(f32::NEG_INFINITY, f32::max);
let image_min = images
.iter()
.map(|region| region.0.min(region.2))
.fold(f32::INFINITY, f32::min);
let image_max = images
.iter()
.map(|region| region.0.max(region.2))
.fold(f32::NEG_INFINITY, f32::max);
let x_min = text_min.min(image_min);
let x_max = text_max.max(image_max);
(x_min.is_finite() && x_max.is_finite() && x_max > x_min).then_some((x_min, x_max))
}
fn group_rows(items: &[TextItem]) -> Vec<Row<'_>> {
const Y_TOLERANCE: f32 = 3.0;
let mut sorted: Vec<&TextItem> = items.iter().collect();
sorted.sort_by(|left, right| right.y.total_cmp(&left.y));
let mut rows: Vec<Row<'_>> = Vec::new();
for item in sorted {
if let Some(row) = rows
.last_mut()
.filter(|row| (row.y - item.y).abs() <= Y_TOLERANCE)
{
row.items.push(item);
row.y = row.items.iter().map(|member| member.y).sum::<f32>() / row.items.len() as f32;
} else {
rows.push(Row {
y: item.y,
items: vec![item],
});
}
}
for row in &mut rows {
row.items.sort_by(|left, right| left.x.total_cmp(&right.x));
}
rows
}
fn side_is_prose(items: &[&TextItem]) -> bool {
let text = items
.iter()
.map(|item| item.text.trim())
.collect::<Vec<_>>()
.join(" ");
let alphabetic_count = text
.chars()
.filter(|character| character.is_alphabetic())
.count();
let cjk_count = text
.chars()
.filter(|character| is_cjk_char(*character))
.count();
(text.split_whitespace().count() >= 3 || cjk_count >= 10) && alphabetic_count >= 10
}
fn aligned_row_split(row: &Row<'_>, x_min: f32, x_max: f32) -> Option<f32> {
if row.items.len() < 2 {
return None;
}
let page_width = x_max - x_min;
let center_low = x_min + page_width * 0.25;
let center_high = x_min + page_width * 0.75;
row.items
.windows(2)
.filter_map(|pair| {
let left_end = pair[0].x + effective_width(pair[0]);
let right_start = pair[1].x;
let gap = right_start - left_end;
let split_x = (left_end + right_start) / 2.0;
if gap < MIN_ROW_GUTTER || split_x < center_low || split_x > center_high {
return None;
}
let left: Vec<&TextItem> = row
.items
.iter()
.copied()
.filter(|item| item.x + effective_width(item) / 2.0 < split_x)
.collect();
let right: Vec<&TextItem> = row
.items
.iter()
.copied()
.filter(|item| item.x + effective_width(item) / 2.0 >= split_x)
.collect();
(side_is_prose(&left) && side_is_prose(&right)).then_some((split_x, gap))
})
.max_by(|left, right| left.1.total_cmp(&right.1))
.map(|candidate| candidate.0)
}
fn local_flow_below_full_width_image(
items: &[TextItem],
images: &[ImageRegion],
x_min: f32,
x_max: f32,
) -> Option<ColumnFlowBand> {
let page_width = x_max - x_min;
let full_width_images: Vec<ImageRegion> = images
.iter()
.copied()
.filter(|&(x0, y0, x1, y1)| {
let width = (x1 - x0).abs();
let height = (y1 - y0).abs();
width >= page_width * 0.65 && height >= 60.0
})
.collect();
// A local column flow below an image is only unambiguous for a single,
// nearly square hero/figure. Wide report banners and full-page artwork
// frequently sit above unrelated page furniture whose aligned labels can
// mimic prose columns.
if full_width_images.len() != 1 {
return None;
}
let (image_x0, _, image_x1, _) = full_width_images[0];
let anchor_width = (image_x1 - image_x0).abs();
let anchor_height = (full_width_images[0].3 - full_width_images[0].1).abs();
if anchor_width < page_width * 0.85
|| anchor_height < anchor_width * 0.85
|| anchor_height > anchor_width * 1.2
{
return None;
}
let image_bottom = full_width_images
.iter()
.map(|&(_, y0, _, y1)| y0.min(y1))
.fold(f32::NEG_INFINITY, f32::max);
if !image_bottom.is_finite() {
return None;
}
let below: Vec<TextItem> = items
.iter()
.filter(|item| item.y < image_bottom && item.y >= image_bottom - 220.0)
.cloned()
.collect();
let candidates: Vec<(f32, f32)> = group_rows(&below)
.into_iter()
.filter_map(|row| aligned_row_split(&row, x_min, x_max).map(|split| (split, row.y)))
.collect();
if candidates.len() < MIN_ALIGNED_ROWS {
return None;
}
let mut clusters: Vec<Vec<(f32, f32)>> = Vec::new();
for candidate in candidates {
if let Some(cluster) = clusters.iter_mut().find(|cluster| {
let mean = cluster.iter().map(|entry| entry.0).sum::<f32>() / cluster.len() as f32;
(mean - candidate.0).abs() <= SPLIT_CLUSTER_TOLERANCE
}) {
cluster.push(candidate);
} else {
clusters.push(vec![candidate]);
}
}
let dominant = clusters.into_iter().max_by_key(Vec::len)?;
if dominant.len() < MIN_ALIGNED_ROWS {
return None;
}
let split_x = dominant.iter().map(|entry| entry.0).sum::<f32>() / dominant.len() as f32;
let y_top = dominant
.iter()
.map(|entry| entry.1)
.fold(f32::NEG_INFINITY, f32::max)
+ 3.0;
let image_gap = image_bottom - y_top;
if !(60.0..=120.0).contains(&image_gap) {
return None;
}
let y_bottom = dominant
.iter()
.map(|entry| entry.1)
.fold(f32::INFINITY, f32::min)
- 3.0;
if y_top - y_bottom > 130.0 {
return None;
}
log::debug!(
"page {}: full-width image flow images={} aligned_rows={} split={:.1} page=[{:.1}..{:.1}] image_bottom={:.1} y=[{:.1}..{:.1}] full_width={:?}",
items.first().map_or(0, |item| item.page),
images.len(),
dominant.len(),
split_x,
x_min,
x_max,
image_bottom,
y_bottom,
y_top,
full_width_images
);
Some(ColumnFlowBand {
split_x,
y_bottom,
y_top,
})
}
fn paired_column_images(
items: &[TextItem],
images: &[ImageRegion],
split_x: f32,
x_min: f32,
x_max: f32,
) -> Option<ColumnFlowBand> {
let page_width = x_max - x_min;
if split_x < x_min + page_width * 0.4 || split_x > x_min + page_width * 0.6 {
return None;
}
let qualifying: Vec<ImageRegion> = images
.iter()
.copied()
.filter(|&(x0, y0, x1, y1)| {
let image_left = x0.min(x1);
let image_right = x0.max(x1);
let confined_to_one_column = image_right <= split_x || image_left >= split_x;
confined_to_one_column
&& (x1 - x0).abs() >= MIN_IMAGE_WIDTH
&& (y1 - y0).abs() >= MIN_IMAGE_HEIGHT
})
.collect();
let wide_images: Vec<ImageRegion> = qualifying
.iter()
.copied()
.filter(|(x0, _, x1, _)| (x1 - x0).abs() >= page_width * 0.35)
.collect();
if qualifying.len() < 3 || wide_images.len() < 3 {
return None;
}
let has_left = qualifying
.iter()
.any(|&(x0, _, x1, _)| (x0 + x1) / 2.0 < split_x);
let has_right = qualifying
.iter()
.any(|&(x0, _, x1, _)| (x0 + x1) / 2.0 >= split_x);
if !has_left || !has_right {
return None;
}
// A meaningful image-backed column flow spans multiple vertical panels.
// Three same-row header/logo images can otherwise satisfy the image count
// and send an ordinary asymmetric page through sequential column order.
let image_y_min = wide_images
.iter()
.map(|region| region.1.min(region.3))
.fold(f32::INFINITY, f32::min);
let image_y_max = wide_images
.iter()
.map(|region| region.1.max(region.3))
.fold(f32::NEG_INFINITY, f32::max);
let has_vertical_stack = wide_images.iter().enumerate().any(|(index, left)| {
wide_images.iter().skip(index + 1).any(|right| {
let same_side =
((left.0 + left.2) / 2.0 < split_x) == ((right.0 + right.2) / 2.0 < split_x);
let left_center = (left.1 + left.3) / 2.0;
let right_center = (right.1 + right.3) / 2.0;
let left_height = (left.3 - left.1).abs();
let right_height = (right.3 - right.1).abs();
let vertical_gap = if left.1.max(left.3) < right.1.min(right.3) {
right.1.min(right.3) - left.1.max(left.3)
} else if right.1.max(right.3) < left.1.min(left.3) {
left.1.min(left.3) - right.1.max(right.3)
} else {
0.0
};
same_side
&& (left_center - right_center).abs() >= left_height.min(right_height) * 0.5
&& vertical_gap <= left_height.max(right_height) * 0.5
})
});
if image_y_max - image_y_min < page_width * 0.45 || !has_vertical_stack {
return None;
}
let y_top = qualifying
.iter()
.map(|region| region.1.max(region.3))
.fold(f32::NEG_INFINITY, f32::max)
+ 3.0;
// Only column-confined text proves the lower extent of the flow. A
// spanning heading or caption below the columns must become the trailing
// full-width node rather than stretching the column band to the page foot.
let y_bottom = items
.iter()
.filter(|item| {
let item_right = item.x + effective_width(item);
item.y <= y_top && (item_right <= split_x || item.x >= split_x)
})
.map(|item| item.y)
.fold(f32::INFINITY, f32::min)
- 3.0;
if !y_bottom.is_finite() {
return None;
}
let distinct_rows = |right: bool| {
let mut ys: Vec<f32> = items
.iter()
.filter(|item| {
item.y <= y_top && (item.x + effective_width(item) / 2.0 >= split_x) == right
})
.map(|item| item.y)
.collect();
ys.sort_by(|left, right| left.total_cmp(right));
ys.dedup_by(|left, right| (*left - *right).abs() <= 3.0);
ys.len()
};
let left_rows = distinct_rows(false);
let right_rows = distinct_rows(true);
let line_balance = left_rows.min(right_rows) as f32 / left_rows.max(right_rows).max(1) as f32;
(left_rows >= 5 && right_rows >= 5 && line_balance < 0.55).then(|| {
log::debug!(
"page {}: paired-image flow qualifying_images={} rows={}/{} split={:.1} page=[{:.1}..{:.1}] y=[{:.1}..{:.1}] images={:?}",
items.first().map_or(0, |item| item.page),
qualifying.len(),
left_rows,
right_rows,
split_x,
x_min,
x_max,
y_bottom,
y_top,
qualifying
);
ColumnFlowBand {
split_x,
y_bottom,
y_top,
}
})
}
pub(crate) fn infer_image_anchored_flow(
items: &[TextItem],
images: &[ImageRegion],
detected_split: Option<f32>,
) -> Option<ColumnFlowBand> {
if items.is_empty() || images.is_empty() {
return None;
}
let (x_min, x_max) = page_x_bounds(items, images)?;
detected_split
.and_then(|split_x| paired_column_images(items, images, split_x, x_min, x_max))
.or_else(|| local_flow_below_full_width_image(items, images, x_min, x_max))
}
/// Partition a page into the topological order `above -> left -> right -> below`.
/// These edges encode the reading-order DAG; empty nodes are omitted.
pub(crate) fn build_region_graph(items: Vec<TextItem>, band: ColumnFlowBand) -> Vec<RegionNode> {
let mut above = Vec::new();
let mut left = Vec::new();
let mut right = Vec::new();
let mut below = Vec::new();
for item in items {
if item.y > band.y_top {
above.push(item);
} else if item.y < band.y_bottom {
below.push(item);
} else if item.x + effective_width(&item) / 2.0 < band.split_x {
left.push(item);
} else {
right.push(item);
}
}
let rtl = is_rtl_text(left.iter().chain(right.iter()).map(|item| &item.text));
let mut ordered = vec![(RegionKind::FullWidth, above)];
if rtl {
ordered.push((RegionKind::Column, right));
ordered.push((RegionKind::Column, left));
} else {
ordered.push((RegionKind::Column, left));
ordered.push((RegionKind::Column, right));
}
ordered.push((RegionKind::FullWidth, below));
ordered
.into_iter()
.filter_map(|(kind, items)| (!items.is_empty()).then_some(RegionNode { kind, items }))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ItemType;
fn item(text: &str, x: f32, y: f32, width: f32) -> TextItem {
TextItem {
text: text.into(),
x,
y,
width,
height: 11.0,
font: "F1".into(),
font_size: 11.0,
page: 1,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: ItemType::Text,
mcid: None,
}
}
#[test]
fn full_width_image_anchors_local_two_column_flow() {
let mut items = vec![
item("A full width caption", 55.0, 230.0, 430.0),
item("A trailing full width heading", 55.0, 80.0, 430.0),
];
for index in 0..5 {
let y = 170.0 - index as f32 * 14.0;
items.push(item("left column prose words", 55.0, y, 210.0));
items.push(item("right column prose words", 280.0, y, 210.0));
}
let images = vec![(55.0, 250.0, 490.0, 680.0)];
let band = infer_image_anchored_flow(&items, &images, None).unwrap();
assert!((band.split_x - 272.5).abs() < 2.0);
let graph = build_region_graph(items, band);
assert_eq!(graph.len(), 4);
assert_eq!(graph[0].kind, RegionKind::FullWidth);
assert_eq!(graph[1].kind, RegionKind::Column);
assert_eq!(graph[2].kind, RegionKind::Column);
assert_eq!(graph[3].kind, RegionKind::FullWidth);
assert_eq!(graph[3].items[0].text, "A trailing full width heading");
}
#[test]
fn full_width_image_anchors_cjk_column_flow() {
let mut items = Vec::new();
for index in 0..5 {
let y = 170.0 - index as f32 * 14.0;
items.push(item("左栏这是没有空格的正文内容", 55.0, y, 210.0));
items.push(item("右栏这是没有空格的正文内容", 280.0, y, 210.0));
}
let images = vec![(55.0, 250.0, 490.0, 680.0)];
assert!(infer_image_anchored_flow(&items, &images, None).is_some());
}
#[test]
fn paired_images_anchor_unbalanced_column_flows() {
let mut items = vec![
item("running header", 55.0, 700.0, 430.0),
item("trailing full width caption", 55.0, 300.0, 430.0),
];
for index in 0..5 {
items.push(item(
"left prose words",
55.0,
500.0 - index as f32 * 14.0,
200.0,
));
items.push(item(
"right prose words",
280.0,
520.0 - index as f32 * 14.0,
200.0,
));
}
for index in 5..12 {
items.push(item(
"right continuation prose words",
280.0,
520.0 - index as f32 * 14.0,
200.0,
));
}
let images = vec![
(55.0, 530.0, 255.0, 680.0),
(55.0, 380.0, 255.0, 530.0),
(280.0, 560.0, 490.0, 680.0),
];
let band = infer_image_anchored_flow(&items, &images, Some(270.0)).unwrap();
let graph = build_region_graph(items, band);
assert_eq!(graph[0].kind, RegionKind::FullWidth);
assert_eq!(graph[1].kind, RegionKind::Column);
assert_eq!(graph[2].kind, RegionKind::Column);
assert_eq!(graph[3].kind, RegionKind::FullWidth);
assert_eq!(graph[3].items[0].text, "trailing full width caption");
}
#[test]
fn rtl_region_graph_reads_right_column_first() {
let items = vec![
item("A long English report header", 55.0, 250.0, 430.0),
item("نص العمود الأيسر", 55.0, 150.0, 180.0),
item("نص العمود الأيمن", 300.0, 150.0, 180.0),
];
let graph = build_region_graph(
items,
ColumnFlowBand {
split_x: 270.0,
y_bottom: 100.0,
y_top: 200.0,
},
);
assert_eq!(graph.len(), 3);
assert_eq!(graph[0].kind, RegionKind::FullWidth);
assert!(graph[1].items[0].x > graph[2].items[0].x);
}
#[test]
fn paired_header_logos_do_not_anchor_page_columns() {
let mut items = Vec::new();
for index in 0..7 {
items.push(item(
"left prose words",
55.0,
700.0 - index as f32 * 14.0,
200.0,
));
}
for index in 0..30 {
items.push(item(
"right prose words",
280.0,
700.0 - index as f32 * 14.0,
200.0,
));
}
let images = vec![
(55.0, 720.0, 205.0, 770.0),
(60.0, 718.0, 210.0, 768.0),
(280.0, 720.0, 450.0, 770.0),
];
assert!(infer_image_anchored_flow(&items, &images, Some(270.0)).is_none());
}
#[test]
fn wide_banner_does_not_anchor_local_columns() {
let mut items = Vec::new();
for index in 0..7 {
let y = 270.0 - index as f32 * 14.0;
items.push(item("left column prose words", 55.0, y, 210.0));
items.push(item("right column prose words", 280.0, y, 210.0));
}
let images = vec![(55.0, 310.0, 490.0, 550.0)];
assert!(infer_image_anchored_flow(&items, &images, None).is_none());
}
}
File diff suppressed because it is too large Load Diff
+42 -676
View File
@@ -1,6 +1,5 @@
//! Form XObject and image XObject extraction.
use super::fonts::descriptor_style_flags;
use crate::text_utils::{effective_font_size, expand_ligatures, is_bold_font, is_italic_font};
use crate::tounicode::FontCMaps;
use crate::types::{ItemType, TextItem};
@@ -8,84 +7,13 @@ use lopdf::{Document, Encoding, Object, ObjectId};
use std::collections::HashMap;
use super::fonts::{
build_font_encodings, build_font_widths, build_type3_scales, compute_string_width_ts,
extract_text_from_operand, get_font_file2_obj_num, get_operand_bytes, CMapDecisionCache,
FontStyleCache,
build_font_encodings, build_font_widths, compute_string_width_ts, extract_text_from_operand,
get_font_file2_obj_num, get_operand_bytes, CMapDecisionCache,
};
use super::{get_number, image_bbox_from_ctm, multiply_matrices};
use super::{get_number, 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),
@@ -179,7 +107,6 @@ 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,
@@ -187,8 +114,6 @@ pub(crate) fn extract_form_xobject_text(
font_cmaps: &FontCMaps,
parent_ctm: &[f32; 6],
cmap_decisions: &mut CMapDecisionCache,
style_cache: &mut FontStyleCache,
budget: &mut FormWalkBudget,
) -> Vec<TextItem> {
extract_form_xobject_text_inner(
doc,
@@ -197,13 +122,10 @@ pub(crate) fn extract_form_xobject_text(
font_cmaps,
parent_ctm,
cmap_decisions,
style_cache,
0,
budget,
)
}
#[allow(clippy::too_many_arguments)]
fn extract_form_xobject_text_inner(
doc: &Document,
form_id: ObjectId,
@@ -211,15 +133,11 @@ fn extract_form_xobject_text_inner(
font_cmaps: &FontCMaps,
parent_ctm: &[f32; 6],
cmap_decisions: &mut CMapDecisionCache,
style_cache: &mut FontStyleCache,
depth: u8,
budget: &mut FormWalkBudget,
) -> Vec<TextItem> {
let mut items = Vec::new();
use lopdf::content::Content;
if !budget.charge_invocation() {
return items;
}
let mut items = Vec::new();
// Get the Form XObject stream
let Ok(Object::Stream(stream)) = doc.get_object(form_id) else {
@@ -232,29 +150,23 @@ fn extract_form_xobject_text_inner(
Err(_) => stream.content.clone(),
};
// 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 {
// Decode the content stream
let Ok(content) = Content::decode(&content_data) else {
return items;
};
// Get fonts from the Form's Resources
let form_fonts = get_form_fonts(doc, &stream.dict);
let (font_encodings, _has_gid_fonts) = build_font_encodings(doc, &form_fonts, font_cmaps);
let (font_encodings, _has_gid_fonts) = build_font_encodings(doc, &form_fonts);
// Build font width info for the form
let font_widths = build_font_widths(doc, &form_fonts);
let type3_scales = build_type3_scales(doc, &form_fonts);
// Build font base names and ToUnicode refs for the form
let mut font_base_names: HashMap<String, String> = HashMap::new();
let mut font_tounicode_refs: HashMap<String, u32> = HashMap::new();
let mut inline_cmaps: HashMap<String, crate::tounicode::CMapEntry> = HashMap::new();
let mut font_style_flags: HashMap<String, (bool, bool)> = HashMap::new();
for (font_name, font_dict) in &form_fonts {
let resource_name = String::from_utf8_lossy(font_name).to_string();
if let Ok(base_font) = font_dict.get(b"BaseFont") {
@@ -263,10 +175,6 @@ fn extract_form_xobject_text_inner(
font_base_names.insert(resource_name.clone(), base_name);
}
}
let style = descriptor_style_flags(doc, font_dict, style_cache);
if style != (false, false) {
font_style_flags.insert(resource_name.clone(), style);
}
match font_dict.get(b"ToUnicode") {
Ok(tounicode) => {
if let Ok(obj_ref) = tounicode.as_reference() {
@@ -326,55 +234,19 @@ 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;
// 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();
let mut ctm_stack: Vec<[f32; 6]> = Vec::new();
for op in &content.operations {
if !budget.charge_operation() {
break;
}
match op.operator.as_str() {
"q" => {
ctm_stack.push(GraphicsState {
ctm,
char_spacing,
word_spacing,
text_leading,
current_font: current_font.clone(),
current_font_size,
fill_is_white,
});
ctm_stack.push(ctm);
}
"Q" => {
if let Some(saved) = ctm_stack.pop() {
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;
ctm = saved;
}
}
"cm" => {
@@ -390,47 +262,19 @@ fn extract_form_xobject_text_inner(
if !op.operands.is_empty() {
if let Ok(name) = op.operands[0].as_name() {
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 && !budget.exhausted() {
let nested_items = extract_form_xobject_text_inner(
doc,
*nested_id,
page_num,
font_cmaps,
&ctm,
cmap_decisions,
style_cache,
depth + 1,
budget,
);
items.extend(nested_items);
}
if let Some(XObjectType::Form(nested_id)) = form_xobjects.get(&xobj_name) {
if depth < MAX_FORM_XOBJECT_DEPTH {
let nested_items = extract_form_xobject_text_inner(
doc,
*nested_id,
page_num,
font_cmaps,
&ctm,
cmap_decisions,
depth + 1,
);
items.extend(nested_items);
}
Some(XObjectType::Image) => {
// Mirror the top-level Image-XObject emission
// in content_stream.rs so figures embedded
// inside Form XObjects (common in print-to-PDF
// workflows) aren't silently dropped.
let (x, y, width, height) = image_bbox_from_ctm(&ctm);
items.push(TextItem {
text: format!("[Image: {}]", xobj_name),
x,
y,
width,
height,
font: String::new(),
font_size: 0.0,
page: page_num,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: ItemType::Image,
mcid: None,
});
}
None => {}
}
}
}
@@ -438,7 +282,6 @@ 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;
@@ -451,33 +294,12 @@ 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);
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;
}
text_matrix[4] += tx * text_matrix[0] + ty * text_matrix[2];
text_matrix[5] += tx * text_matrix[1] + ty * text_matrix[3];
}
}
"Tm" => {
@@ -486,20 +308,8 @@ 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;
@@ -535,33 +345,17 @@ fn extract_form_xobject_text_inner(
_ => fill_is_white = false,
}
}
"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()) {
"Tj" => {
if in_text_block && !op.operands.is_empty() {
if fill_is_white {
if let Some(font_info) = font_widths.get(&current_font) {
if let Some(raw_bytes) = get_operand_bytes(show_operand) {
if let Some(raw_bytes) = get_operand_bytes(&op.operands[0]) {
let w_ts = compute_string_width_ts(
raw_bytes,
font_info,
current_font_size,
char_spacing,
word_spacing,
0.0,
0.0,
);
text_matrix[4] += w_ts * text_matrix[0];
text_matrix[5] += w_ts * text_matrix[1];
@@ -570,7 +364,7 @@ fn extract_form_xobject_text_inner(
continue;
}
if let Some(text) = extract_text_from_operand(
show_operand,
&op.operands[0],
&current_font,
font_base_names.get(&current_font).map(|s| s.as_str()),
font_cmaps,
@@ -579,20 +373,18 @@ fn extract_form_xobject_text_inner(
&font_encodings,
&encoding_cache,
cmap_decisions,
&font_widths,
) {
let combined = multiply_matrices(&text_matrix, &ctm);
let rendered_size = effective_font_size(current_font_size, &combined)
* type3_scales.get(&current_font).copied().unwrap_or(1.0);
let rendered_size = effective_font_size(current_font_size, &combined);
let (x, y) = (combined[4], combined[5]);
let width = if let Some(font_info) = font_widths.get(&current_font) {
if let Some(raw_bytes) = get_operand_bytes(show_operand) {
if let Some(raw_bytes) = get_operand_bytes(&op.operands[0]) {
let w_ts = compute_string_width_ts(
raw_bytes,
font_info,
current_font_size,
char_spacing,
word_spacing,
0.0,
0.0,
);
text_matrix[4] += w_ts * text_matrix[0];
text_matrix[5] += w_ts * text_matrix[1];
@@ -610,10 +402,6 @@ fn extract_form_xobject_text_inner(
.get(&current_font)
.map(|s| s.as_str())
.unwrap_or(&current_font);
let (desc_italic, desc_bold) = font_style_flags
.get(&current_font)
.copied()
.unwrap_or((false, false));
items.push(TextItem {
text: expand_ligatures(&text),
x,
@@ -623,10 +411,8 @@ fn extract_form_xobject_text_inner(
font: current_font.clone(),
font_size: rendered_size,
page: page_num,
is_bold: is_bold_font(base_font) || desc_bold,
is_italic: is_italic_font(base_font) || desc_italic,
is_underline: false,
is_strikeout: false,
is_bold: is_bold_font(base_font),
is_italic: is_italic_font(base_font),
item_type: ItemType::Text,
mcid: None,
});
@@ -715,8 +501,8 @@ fn extract_form_xobject_text_inner(
raw_bytes,
fi,
current_font_size,
char_spacing,
word_spacing,
0.0,
0.0,
);
}
}
@@ -731,7 +517,6 @@ fn extract_form_xobject_text_inner(
&font_encodings,
&encoding_cache,
cmap_decisions,
&font_widths,
) {
current_text.push_str(&text);
}
@@ -742,16 +527,11 @@ fn extract_form_xobject_text_inner(
}
if !sub_items.is_empty() {
let combined = multiply_matrices(&text_matrix, &ctm);
let rendered_size = effective_font_size(current_font_size, &combined)
* type3_scales.get(&current_font).copied().unwrap_or(1.0);
let rendered_size = effective_font_size(current_font_size, &combined);
let base_font = font_base_names
.get(&current_font)
.map(|s| s.as_str())
.unwrap_or(&current_font);
let (desc_italic, desc_bold) = font_style_flags
.get(&current_font)
.copied()
.unwrap_or((false, false));
let scale_x = text_matrix[0] * ctm[0] + text_matrix[1] * ctm[2];
for (text, start_w, end_w) in &sub_items {
let offset_tm = [
@@ -778,10 +558,8 @@ fn extract_form_xobject_text_inner(
font: current_font.clone(),
font_size: rendered_size,
page: page_num,
is_bold: is_bold_font(base_font) || desc_bold,
is_italic: is_italic_font(base_font) || desc_italic,
is_underline: false,
is_strikeout: false,
is_bold: is_bold_font(base_font),
is_italic: is_italic_font(base_font),
item_type: ItemType::Text,
mcid: None,
});
@@ -850,415 +628,3 @@ 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
);
}
}
+3 -40
View File
@@ -4566,13 +4566,9 @@ pub fn glyph_to_char(name: &str) -> Option<char> {
}
}
// Try to parse uniXXXX format.
// Use `get` rather than a byte-length check + slice: `name` can contain
// non-ASCII bytes (e.g. U+FFFD from lossy UTF-8 decoding of an attacker
// controlled /Differences name), so byte index 7 may not be a char
// boundary and `&name[3..7]` would panic.
if let Some(hex) = name.strip_prefix("uni").and_then(|rest| rest.get(..4)) {
if let Ok(code) = u32::from_str_radix(hex, 16) {
// Try to parse uniXXXX format
if name.starts_with("uni") && name.len() >= 7 {
if let Ok(code) = u32::from_str_radix(&name[3..7], 16) {
// Strip PUA F000 offset: uniF0XX → U+00XX (Windows Symbol encoding convention)
let code = if (0xF000..=0xF0FF).contains(&code) {
code - 0xF000
@@ -4592,36 +4588,3 @@ pub fn glyph_to_char(name: &str) -> Option<char> {
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uni_hex_parsing() {
assert_eq!(glyph_to_char("uni0041"), Some('A'));
assert_eq!(glyph_to_char("uni00e9"), Some('\u{00e9}'));
// PUA F0xx symbol-encoding offset is stripped.
assert_eq!(glyph_to_char("uniF041"), Some('A'));
}
#[test]
fn u_hex_parsing() {
assert_eq!(glyph_to_char("u0041"), Some('A'));
assert_eq!(glyph_to_char("u1F600"), Some('\u{1F600}'));
}
#[test]
fn non_ascii_uni_name_does_not_panic() {
// A crafted /Differences name like `/uni#80#80#80#80` decodes via
// from_utf8_lossy into "uni" followed by four U+FFFD replacements.
// Byte index 7 lands mid-character, so a naive `&name[3..7]` slice
// would panic. It must be handled gracefully instead.
let crafted = format!("uni{0}{0}{0}{0}", '\u{FFFD}');
assert_eq!(glyph_to_char(&crafted), None);
// Assorted non-ASCII bytes right after the "uni" prefix.
assert_eq!(glyph_to_char("uni\u{FFFD}bc"), None);
assert_eq!(glyph_to_char("uni\u{00e9}00"), None);
}
}
+346 -3875
View File
File diff suppressed because it is too large Load Diff
-499
View File
@@ -130,315 +130,6 @@ pub(crate) fn has_dot_leaders(text: &str) -> bool {
dot_groups >= 2
}
/// Detect a table-of-contents entry: a line ending in a page number preceded by
/// a dot-leader group (e.g. "Measurement Lab worksheet ... 3"). `has_dot_leaders`
/// misses single-group leaders ("..."), but a trailing "<dots> <number>" is a
/// strong TOC signal on its own. Such lines must never be promoted to headings.
pub(crate) fn is_toc_entry_line(text: &str) -> bool {
let trimmed = text.trim_end();
let digits = trimmed
.chars()
.rev()
.take_while(|c| c.is_ascii_digit())
.count();
if digits == 0 || digits > 4 {
return false;
}
let before_number = trimmed[..trimmed.len() - digits].trim_end();
let dots = before_number
.chars()
.rev()
.take_while(|c| *c == '.')
.count();
dots >= 3
}
/// A heading that announces a table of contents ("Contents", "Table of
/// Contents"). Lines after it on the same page are ToC entries — section
/// titles that look exactly like headings but must not be promoted.
pub(crate) fn is_toc_marker_heading(text: &str) -> bool {
let t = text.trim().trim_end_matches(':').trim().to_lowercase();
matches!(t.as_str(), "contents" | "table of contents")
}
/// Lines that resemble headings structurally but are display-math fragments:
/// equations ending in an equation number ("S = kB ln W, (2)") or equation
/// lead-ins ("Rearranging Equation (8) gives:"). Both carry an "(N)" equation
/// reference — but a trailing "(N)" alone is not enough: real headings end
/// with parenthesized numbers too ("Nicaea (325)", appendix numbering), so
/// the suffix form additionally requires math evidence — an "=" in the line
/// or a comma immediately before the number, both present in every display
/// equation and absent from name-plus-number headings. A bare trailing colon
/// is NOT a fragment signal either: real headings frequently end with colons
/// ("Procedure:", "Steps for Using the Microscope:").
/// True when the line opens with a section number ("3.", "2.1.4", "IV)").
///
/// Mirrors the acceptance of `heading::parse_numbering` rather than the
/// stricter `convert::starts_with_section_number`, which deliberately
/// requires two components because it bypasses isolation checks. Here a
/// single "1." counts: numbering is independent evidence of a heading, and
/// `heading.rs` applies its numbered-prefix allowance *after* consulting
/// `is_heading_fragment`, so without this exemption a numbered
/// sentence-case heading would be vetoed before that allowance can run.
fn starts_with_numbering_prefix(t: &str) -> bool {
let Some(first) = t.split_whitespace().next() else {
return false;
};
let has_delimiter = first.ends_with(['.', ')', ':']);
let token = first.trim_end_matches(['.', ')', ':']);
if token.is_empty() {
return false;
}
let parts: Vec<&str> = token.split('.').collect();
let decimal = parts
.iter()
.all(|p| !p.is_empty() && p.len() <= 3 && p.chars().all(|c| c.is_ascii_digit()));
if decimal {
// "1." / "2.1." carry a delimiter; "2.3 Title" is written without
// one, so a multi-component number is accepted bare. A bare single
// number ("3 apples") is not — that is ordinary prose.
return has_delimiter || parts.len() >= 2;
}
// Roman numerals go through the heading parser's own grammar so the two
// agree: uppercase I/V/X/L/C only, at most 8 characters. A looser rule
// here would exempt markers the parser rejects — "iv)" or "d)" from an
// alphabetical list — letting an ordinary list item bypass the veto and
// reach heading promotion.
//
// A delimiter is also required: a bare leading "I" is the pronoun far
// more often than a section number.
has_delimiter && crate::markdown::heading::roman_value(token).is_some()
}
/// True when the line reads as a title rather than a sentence: every
/// content word (ignoring minor words) starts uppercase. Used to spare real
/// headings from the dangling-verb veto — "Bond Yields" is a section title,
/// "the method yields" is a stranded clause, and only the casing tells them
/// apart.
fn looks_title_case(t: &str) -> bool {
const MINOR: &[&str] = &[
"a", "an", "the", "of", "and", "or", "for", "to", "in", "on", "at", "by", "with", "from",
"as", "is", "are", "that", "than", "into",
];
let mut content = 0usize;
let mut capitalized = 0usize;
for w in t.split_whitespace() {
let cleaned: String = w.chars().filter(|c| c.is_alphabetic()).collect();
if cleaned.is_empty() {
continue;
}
if MINOR.contains(&cleaned.to_lowercase().as_str()) {
continue;
}
content += 1;
if cleaned.chars().next().is_some_and(char::is_uppercase) {
capitalized += 1;
}
}
// A single content word ("Yields") is a title by default.
content == 0 || capitalized == content
}
pub(crate) fn is_heading_fragment(text: &str) -> bool {
let t = text.trim_end();
// A lowercase-initial one-or-two-word "heading" is a mid-sentence
// fragment beside display math ("or inversely", "and therefore") —
// real headings that short start uppercase. Measured as spurious
// headings on academic docs (fire-pdf ENG-5029 / opendataloader MHS).
{
let words: Vec<&str> = t.split_whitespace().collect();
if words.len() <= 2 {
if let Some(first_alpha) = t.chars().find(|c| c.is_alphabetic()) {
if first_alpha.is_lowercase() {
return true;
}
}
}
}
fn is_equation_number(s: &str) -> bool {
s.strip_prefix('(')
.and_then(|r| r.strip_suffix(')'))
.is_some_and(|inner| {
!inner.is_empty() && inner.len() <= 3 && inner.chars().all(|c| c.is_ascii_digit())
})
}
// Equation-number suffix with math evidence: "S = kB ln W, (2)"
let mut rev = t.rsplit(' ');
let last = rev.next().unwrap_or("");
if is_equation_number(last) {
// Page-of-total running headers: "LIVSMEDELSVERKET PM 2 (10)"
if let Some(prev_word) = t.rsplit(' ').nth(1) {
if let (Ok(page), Some(total)) = (
prev_word.parse::<u32>(),
last.trim_start_matches('(')
.trim_end_matches(')')
.parse::<u32>()
.ok(),
) {
if page <= total {
return true;
}
}
}
let punct_before = rev
.next()
.is_some_and(|w| w.ends_with(',') || w.ends_with(':'));
let has_math_op = t.chars().any(|c| {
matches!(
c,
'=' | '<'
| '>'
| '≤'
| '≥'
| '≪'
| '≫'
| '≈'
| '≠'
| '±'
| '∑'
| '∫'
| '√'
| '∝'
)
});
if punct_before || has_math_op {
return true;
}
}
// Lead-in: ends with a colon AND references an equation number inline
if t.ends_with(':') && t.split_whitespace().any(is_equation_number) {
return true;
}
// Dangling clause: a stranded sentence lead-in ends on a relational
// verb with no terminal punctuation — "Note that the exact error equals"
// left ahead of its formula when a phantom table dissolved.
//
// Gated on the line reading as prose rather than a title. Case is the
// discriminator the trailing word alone cannot provide: a heading is
// title case ("Bond Yields", "The Method Yields") while a stranded
// lead-in is sentence case ("the method yields"). Without this gate the
// veto eats real headings — "Bond Yields", "Crop Yields" and any wrapped
// title-case heading the preprocessor failed to merge.
if !t.ends_with(['.', '!', '?', ':', ';', ')', ']'])
&& !looks_title_case(t)
&& !starts_with_numbering_prefix(t)
{
if let Some(last) = t.split_whitespace().next_back() {
let word: String = last
.trim_matches(|c: char| !c.is_alphanumeric())
.to_lowercase();
// Relational verbs only, and only those with no common noun
// sense. "yields" was dropped for exactly that reason: "Bond
// Yields" is a real section title. Function words, copulas and
// auxiliaries were measured and rejected outright — a heading
// that wraps across lines ends on those, and suppressing them
// destroyed real IRS Publication 17 headings.
const DANGLING_TAIL: &[&str] =
&["equals", "denotes", "implies", "satisfies", "signifies"];
if DANGLING_TAIL.contains(&word.as_str()) {
return true;
}
}
}
false
}
#[cfg(test)]
mod fragment_heading_tests {
use super::is_heading_fragment;
#[test]
fn dangling_tail_marks_stranded_clause() {
// opendataloader 01030000000144: left behind when a phantom table
// dissolved, ahead of its formula on the next line.
assert!(is_heading_fragment("Note that the exact error equals"));
assert!(is_heading_fragment("The remainder term satisfies"));
assert!(is_heading_fragment("we conclude that the sum equals"));
}
#[test]
fn real_headings_survive() {
assert!(!is_heading_fragment("Introduction"));
assert!(!is_heading_fragment("Error Analysis"));
assert!(!is_heading_fragment("Materials and Methods"));
assert!(!is_heading_fragment("Results"));
assert!(!is_heading_fragment("3.2 Richardson Extrapolation"));
assert!(!is_heading_fragment("Discussion and Conclusions"));
// Terminal punctuation means the clause is complete.
assert!(!is_heading_fragment("What is a Derivative?"));
assert!(!is_heading_fragment("Procedure:"));
assert!(!is_heading_fragment("Note that this is important."));
}
#[test]
fn title_case_headings_ending_in_a_verb_survive() {
// "yields" is also a plural noun; these are real section titles.
assert!(!is_heading_fragment("Bond Yields"));
assert!(!is_heading_fragment("Crop Yields"));
assert!(!is_heading_fragment("Dividend Yields"));
assert!(!is_heading_fragment("Yields"));
// A wrapped title-case heading whose first line ends on a listed
// verb must survive even if the preprocessor failed to merge it.
assert!(!is_heading_fragment("The Theorem Implies"));
assert!(!is_heading_fragment("What This Denotes"));
}
#[test]
fn numbered_sentence_case_headings_survive() {
// heading.rs consults is_heading_fragment BEFORE applying its
// numbered-prefix allowance, so the veto must not pre-empt it.
assert!(!is_heading_fragment("1. What the model implies"));
assert!(!is_heading_fragment("2.3 How the estimator satisfies"));
assert!(!is_heading_fragment("IV) What this denotes"));
// Without numbering the same wording is still a stranded clause.
assert!(is_heading_fragment("What the model implies"));
// A bare leading number or pronoun is prose, not numbering.
assert!(is_heading_fragment("3 apples and what that implies"));
assert!(is_heading_fragment("I think the model implies"));
// Markers heading::parse_numbering rejects must not be exempted
// either, or an ordinary list item bypasses the veto: lowercase
// roman, alphabetical markers, and over-long tokens.
assert!(is_heading_fragment("iv) the estimator satisfies"));
assert!(is_heading_fragment("d) the value implies"));
// Unsupported character (M is outside the parser's I/V/X/L/C set).
assert!(is_heading_fragment("MMMM. the value implies"));
// Over-long token: nine valid characters, so this exercises the
// 8-character bound rather than the character set.
assert!(is_heading_fragment("IIIIIIIII. the value implies"));
// Eight is still within the bound and stays exempt.
assert!(!is_heading_fragment("IIIIIIII. What this implies"));
// Uppercase roman within the parser's grammar is still exempt.
assert!(!is_heading_fragment("IV. What this denotes"));
assert!(!is_heading_fragment("XII) What this implies"));
}
#[test]
fn wrapped_headings_are_not_fragments() {
// A heading that wraps across lines ends on a function word. These
// are real headings from IRS Publication 17 and must survive.
assert!(!is_heading_fragment("Casualty and"));
assert!(!is_heading_fragment("Rule 10. You Must Be at"));
assert!(!is_heading_fragment("Higher Standard Deduction for"));
assert!(!is_heading_fragment("Qualifying Child of"));
assert!(!is_heading_fragment("When Can I Withdraw or"));
// Copulas and auxiliaries also end real wrapped headings.
assert!(!is_heading_fragment("Rule 15. Your AGI Must Be"));
assert!(!is_heading_fragment("What Medical Expenses Are"));
assert!(!is_heading_fragment("Rule 13. You Must Have"));
assert!(!is_heading_fragment("When Can a Roth IRA Be"));
}
#[test]
fn dangling_check_is_case_insensitive() {
// All-caps is not sentence case, so the veto must not fire there.
assert!(!is_heading_fragment("THE REMAINDER EQUALS"));
}
}
/// Compute the Y-gap threshold for paragraph break detection.
///
/// Instead of using a fixed multiple of base_size (which fails for double-spaced
@@ -566,15 +257,6 @@ pub(crate) fn compute_heading_tiers(lines: &[TextLine], base_size: f32) -> Vec<f
for line in lines {
if let Some(first) = line.items.first() {
if first.font_size / base_size >= 1.2 {
// Digit-only lines (page numbers, issue numbers) must not
// define heading tiers: a large bold folio claims tier 0 and
// blocks the bold-size fallback for the document's real
// same-size headings.
let text = line.text();
let t = text.trim();
if !t.is_empty() && t.chars().all(|c| !c.is_alphabetic()) {
continue;
}
heading_sizes.push(first.font_size);
}
}
@@ -592,45 +274,11 @@ pub(crate) fn compute_heading_tiers(lines: &[TextLine], base_size: f32) -> Vec<f
}
}
// Books often set section headings barely above body size (e.g. 11pt
// bold over 10pt text). When nothing clears the 1.2x ratio gate, fall
// back to bold lines modestly larger than body so those documents still
// get an H1 instead of every bold heading defaulting to H2.
if tiers.is_empty() {
let mut bold_sizes: Vec<f32> = lines
.iter()
.filter(|line| {
let text = line.text();
let t = text.trim();
!t.is_empty() && t.chars().any(|c| c.is_alphabetic())
})
.filter_map(|line| line.items.first())
.filter(|it| it.is_bold && it.font_size / base_size >= 1.05)
.map(|it| it.font_size)
.collect();
bold_sizes.sort_by(|a, b| b.total_cmp(a));
for size in bold_sizes {
if !tiers.iter().any(|&t| (t - size).abs() < 0.5) {
tiers.push(size);
}
}
}
// Cap at 4 tiers
tiers.truncate(4);
tiers
}
/// Boldness of a line judged by character mass, so a heading with an
/// unbold section-number prefix ("4. " + bold title) still counts as bold.
pub(crate) fn line_is_mostly_bold(line: &TextLine) -> bool {
let (bold, total) = line.items.iter().fold((0usize, 0usize), |(b, t), it| {
let n = it.text.trim().chars().count();
(b + if it.is_bold { n } else { 0 }, t + n)
});
total > 0 && bold * 2 >= total
}
/// Detect header level from font size using document-specific heading tiers.
/// When tiers are available, maps tier 0→H1, tier 1→H2, etc.
/// Falls back to ratio-based thresholds when no tiers exist.
@@ -638,21 +286,9 @@ pub(crate) fn detect_header_level(
font_size: f32,
base_size: f32,
heading_tiers: &[f32],
is_bold: bool,
) -> Option<usize> {
let ratio = font_size / base_size;
// Tier matches are trusted below the 1.2x gate (down to 1.05x) only for
// bold lines: sub-gate tiers come from the bold fallback, and honoring
// them for non-bold text at the same size would promote captions.
if (1.05..1.2).contains(&ratio) && is_bold && !heading_tiers.is_empty() {
for (i, &tier_size) in heading_tiers.iter().enumerate() {
if (font_size - tier_size).abs() < 0.5 {
return Some(i + 1); // tier 0 → H1, tier 1 → H2, etc.
}
}
}
if ratio < 1.2 {
return None; // Regular text
}
@@ -684,138 +320,3 @@ pub(crate) fn detect_header_level(
Some(4)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn line_of(text: &str, font_size: f32, bold: bool, y: f32) -> crate::types::TextLine {
let item = crate::types::TextItem {
text: text.into(),
x: 72.0,
y,
width: text.len() as f32 * font_size * 0.5,
height: font_size,
font: "Test".into(),
font_size,
page: 1,
is_bold: bold,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: crate::types::ItemType::Text,
mcid: None,
};
crate::types::TextLine {
items: vec![item],
y,
page: 1,
adaptive_threshold: 0.10,
}
}
#[test]
fn digit_only_lines_do_not_define_tiers() {
// A 14pt bold page number must not claim tier 0 — that both demotes
// every real heading a level and blocks the bold-size fallback.
let lines = vec![
line_of("76", 14.0, true, 760.0),
line_of("Replace", 11.0, true, 700.0),
line_of("body text at eleven points", 11.0, false, 680.0),
];
let tiers = compute_heading_tiers(&lines, 11.0);
assert!(tiers.is_empty(), "page number claimed a tier: {tiers:?}");
}
#[test]
fn bold_fallback_tiers_when_nothing_clears_ratio_gate() {
// 10pt body, 11pt bold section headings (book-style): no size clears
// 1.2x, so bold sizes modestly above body form the tiers.
let lines = vec![
line_of("4. Entropy", 11.0, true, 700.0),
line_of("body text about entropy", 10.0, false, 680.0),
line_of("5. The dynamics", 11.0, true, 500.0),
];
let tiers = compute_heading_tiers(&lines, 10.0);
assert_eq!(tiers, vec![11.0]);
assert_eq!(detect_header_level(11.0, 10.0, &tiers, true), Some(1));
// Non-bold text at the fallback size must not become a heading.
assert_eq!(detect_header_level(11.0, 10.0, &tiers, false), None);
// Non-tier body text stays regular.
assert_eq!(detect_header_level(10.0, 10.0, &tiers, true), None);
}
#[test]
fn bold_fallback_skipped_when_real_tiers_exist() {
let lines = vec![
line_of("Chapter One", 18.0, false, 700.0),
line_of("bold label", 11.0, true, 600.0),
line_of("body", 10.0, false, 580.0),
];
let tiers = compute_heading_tiers(&lines, 10.0);
assert_eq!(tiers, vec![18.0]);
// The 11pt bold label does not match any tier and stays non-heading.
assert_eq!(detect_header_level(11.0, 10.0, &tiers, true), None);
}
#[test]
fn toc_entry_with_single_dot_group() {
assert!(is_toc_entry_line("Measurement Lab worksheet ... 3"));
assert!(is_toc_entry_line("Results ........ 12"));
assert!(is_toc_entry_line("Appendix B...42"));
}
#[test]
fn non_toc_lines_pass() {
assert!(!is_toc_entry_line(
"6.2. Expectations for Re-Hiring Employees"
));
assert!(!is_toc_entry_line("What happened in 2020"));
assert!(!is_toc_entry_line("IMPLEMENTATION"));
// Ellipsis without a trailing page number
assert!(!is_toc_entry_line("and so it goes ..."));
// Long numbers are data, not page refs
assert!(!is_toc_entry_line("ISBN ... 97814"));
}
#[test]
fn toc_marker_headings() {
assert!(is_toc_marker_heading("Contents"));
assert!(is_toc_marker_heading("CONTENTS"));
assert!(is_toc_marker_heading("Table of Contents"));
assert!(is_toc_marker_heading("Table of contents:"));
assert!(!is_toc_marker_heading("Contents of the Shipment"));
assert!(!is_toc_marker_heading("Introduction"));
}
#[test]
fn heading_fragments() {
// Equation lead-ins: colon ending + inline equation reference
assert!(is_heading_fragment("or inversely"));
assert!(is_heading_fragment("and therefore"));
assert!(!is_heading_fragment("Introduction"));
assert!(!is_heading_fragment("iPhone Sales Strategy Overview")); // 4 words, exempt
assert!(is_heading_fragment("Rearranging Equation (8) gives:"));
// Display-equation neighbours ending in an equation number
assert!(is_heading_fragment("S = kB ln W, (2)"));
assert!(is_heading_fragment("E = mc2 (12)"));
assert!(is_heading_fragment("x + y = z, (3)"));
// Page-of-total running headers
assert!(is_heading_fragment("LIVSMEDELSVERKET PM 2 (10)"));
// Comparison-operator evidence and colon-before-number
assert!(is_heading_fragment(
"PLL\u{fe} PHH\u{226a} PLH\u{fe} PHL: (12)"
));
// Real headings pass — including name-plus-number and colon-ended ones
assert!(!is_heading_fragment("Nicaea (325)"));
assert!(!is_heading_fragment(
"\u{627}\u{644}\u{645}\u{644}\u{62d}\u{642} \u{631}\u{642}\u{645} (1)"
));
assert!(!is_heading_fragment("4. Entropy"));
assert!(!is_heading_fragment("Procedure:"));
assert!(!is_heading_fragment("Steps for Using the Microscope:"));
assert!(!is_heading_fragment("Changing objectives:"));
assert!(!is_heading_fragment("Sales by Region (2024)"));
assert!(!is_heading_fragment("Results (preliminary)"));
}
}
+4 -12
View File
@@ -131,11 +131,10 @@ pub(crate) fn format_list_item(text: &str) -> String {
if let Some(rest) = trimmed.strip_prefix(*bullet) {
return format!("- {}", rest.trim_start());
}
// Bullet inside a leading style run (e.g. "**● Label:** rest" or
// "<u>● Label</u>"). The run wraps both the marker and the following
// label because both carry the style in the PDF. The marker must move
// outside the wrapper so markdown still sees a list item.
for wrapper in ["**", "*", "<u>"] {
// Bullet inside a leading bold/italic run (e.g. "**● Label:** rest").
// The run wraps both the marker and the following label because both
// use a bold font in the PDF.
for wrapper in ["**", "*"] {
if let Some(after_open) = trimmed.strip_prefix(wrapper) {
if let Some(rest) = after_open.strip_prefix(*bullet) {
return format!("- {}{}", wrapper, rest.trim_start());
@@ -236,13 +235,6 @@ mod tests {
assert_eq!(format_list_item("• Item"), "- Item");
}
#[test]
fn format_list_item_bullet_inside_underline() {
// Fully-underlined bullet line: the marker must move outside the
// <u> wrapper so markdown still renders a list item.
assert_eq!(format_list_item("<u>● Item text</u>"), "- <u>Item text</u>");
}
#[test]
fn format_list_item_bullet_inside_bold() {
// PDF that uses bold font for both the marker and the label produces
+98 -840
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+99 -1765
View File
File diff suppressed because it is too large Load Diff
+69 -522
View File
@@ -2,22 +2,14 @@
use regex::Regex;
use super::{MarkdownOptions, MarkdownProfile};
use crate::text_utils::is_page_number_line;
use super::MarkdownOptions;
/// Clean up markdown output with post-processing
pub(crate) fn clean_markdown(mut text: String, options: &MarkdownOptions) -> String {
if options.profile == MarkdownProfile::Compact {
// Dot-leader collapse saves tokens but changes source text, so it is
// reserved for the explicit compact profile.
text = collapse_dot_leaders(&text);
}
// Collapse dot leaders (e.g. TOC entries: "Introduction...............................1")
text = collapse_dot_leaders(&text);
// Collapse runs of spaces first: double-spaced breaks ("de- fendant")
// must look like single-spaced ones before the hyphenation passes.
collapse_consecutive_spaces(&mut text);
// Fix hyphenation (before other processing)
// Fix hyphenation first (before other processing)
if options.fix_hyphenation {
text = fix_hyphenation(&text);
}
@@ -37,8 +29,6 @@ pub(crate) fn clean_markdown(mut text: String, options: &MarkdownOptions) -> Str
// text item, which combine with gap-based space insertion to produce
// double spaces ("Vice President" instead of "Vice President").
collapse_consecutive_spaces(&mut text);
remove_spaces_before_closing_brackets(&mut text);
remove_spaces_before_sentence_punctuation(&mut text);
// Remove excessive newlines (more than 2 in a row)
while text.contains("\n\n\n") {
@@ -81,46 +71,6 @@ fn collapse_consecutive_spaces(text: &mut String) {
*text = result;
}
/// Remove spaces before closing square brackets.
/// Unit markers and markdown links occasionally pick up a gap-inserted space
/// before `]` (e.g. `[kg/m3 ]`), which is cosmetic padding.
fn remove_spaces_before_closing_brackets(text: &mut String) {
let mut result = String::with_capacity(text.len());
for ch in text.chars() {
if ch == ']' && result.ends_with(' ') {
result.pop();
}
result.push(ch);
}
*text = result;
}
/// Remove a stray space before sentence punctuation ("word ." → "word.").
/// Style-boundary item splits (bold/italic/underline runs) can strand a
/// trailing period or comma in its own fragment, and several assembly paths
/// join fragments with spaces. Only fires when the punctuation ends the
/// token (followed by whitespace or end of text), so decimals ("3 .14" stays
/// untouched — no such input exists, but the guard is cheap) and dot leaders
/// (" ... ") are unaffected.
fn remove_spaces_before_sentence_punctuation(text: &mut String) {
let chars: Vec<char> = text.chars().collect();
let mut result = String::with_capacity(text.len());
for (i, &ch) in chars.iter().enumerate() {
if matches!(ch, '.' | ',' | ';') && result.ends_with(' ') {
let next = chars.get(i + 1);
// `|` counts as a token end so table cells get the same fix.
let token_ends = next.is_none_or(|c| c.is_whitespace() || *c == '|');
// Never touch runs of dots (ellipsis / dot leaders).
let in_dot_run = ch == '.' && next == Some(&'.');
if token_ends && !in_dot_run {
result.pop();
}
}
result.push(ch);
}
*text = result;
}
/// Collapse dot leaders (runs of 4+ dots) into " ... "
/// Common in tables of contents: "Introduction...............................1" -> "Introduction ... 1"
fn collapse_dot_leaders(text: &str) -> String {
@@ -147,216 +97,10 @@ fn fix_hyphenation(text: &str) -> String {
})
.to_string();
dehyphenate_line_breaks(&result)
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(&current, |caps: &regex::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: &regex::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(&current);
}
out
}
/// Remove isolated page-number expressions from Markdown.
/// Remove standalone page numbers (lines that are just 1-4 digit numbers)
fn remove_page_numbers(text: &str) -> String {
let mut result = Vec::new();
let lines: Vec<&str> = text.lines().collect();
@@ -394,6 +138,69 @@ fn remove_page_numbers(text: &str) -> String {
result.join("\n")
}
/// Check if a line looks like a page number
fn is_page_number_line(trimmed: &str) -> bool {
// Empty lines are not page numbers
if trimmed.is_empty() {
return false;
}
// Pattern 1: Just a number (1-4 digits)
if trimmed.len() <= 4 && trimmed.chars().all(|c| c.is_ascii_digit()) {
return true;
}
// Pattern 2: "Page X of Y" or "Page X" or "Page of" (placeholder)
let lower = trimmed.to_lowercase();
if let Some(rest) = lower.strip_prefix("page") {
let rest = rest.trim();
// "Page of" (empty page numbers)
if rest == "of" || rest.starts_with("of ") {
return true;
}
// "Page X" or "Page X of Y"
if rest
.chars()
.next()
.map(|c| c.is_ascii_digit())
.unwrap_or(false)
{
return true;
}
// Just "Page" followed by whitespace and maybe "of"
if rest.is_empty()
|| rest
.split_whitespace()
.all(|w| w == "of" || w.chars().all(|c| c.is_ascii_digit()))
{
return true;
}
}
// Pattern 3: "X of Y" where X and Y are numbers
if let Some(of_idx) = trimmed.find(" of ") {
let before = trimmed[..of_idx].trim();
let after = trimmed[of_idx + 4..].trim();
if before.chars().all(|c| c.is_ascii_digit())
&& after.chars().all(|c| c.is_ascii_digit())
&& !before.is_empty()
&& !after.is_empty()
{
return true;
}
}
// Pattern 4: "- X -" centered page number
if trimmed.len() >= 3 && trimmed.starts_with('-') && trimmed.ends_with('-') {
let inner = trimmed[1..trimmed.len() - 1].trim();
if inner.chars().all(|c| c.is_ascii_digit()) && !inner.is_empty() {
return true;
}
}
false
}
/// Convert URLs to markdown links
fn format_urls(text: &str) -> String {
use once_cell::sync::Lazy;
@@ -506,23 +313,6 @@ fn format_urls(text: &str) -> String {
mod tests {
use super::*;
#[test]
fn fidelity_profile_preserves_dot_leaders() {
let input = "Introduction............................1".to_string();
let result = clean_markdown(input.clone(), &MarkdownOptions::default());
assert_eq!(result, format!("{input}\n"));
}
#[test]
fn compact_profile_collapses_dot_leaders() {
let input = "Introduction............................1".to_string();
let options = MarkdownOptions {
profile: MarkdownProfile::Compact,
..MarkdownOptions::default()
};
assert_eq!(clean_markdown(input, &options), "Introduction ... 1\n");
}
// --- collapse_dot_leaders ---
#[test]
@@ -552,222 +342,6 @@ mod tests {
assert!(result.contains("Chapter 2 ... 20"));
}
// --- remove_spaces_before_closing_brackets ---
#[test]
fn test_remove_spaces_before_closing_brackets() {
let mut input = "Density [kg/m3 ] and [linked text ](https://example.com)".to_string();
remove_spaces_before_closing_brackets(&mut input);
assert_eq!(
input,
"Density [kg/m3] and [linked text](https://example.com)"
);
}
// --- remove_spaces_before_sentence_punctuation ---
#[test]
fn strips_space_before_trailing_period() {
let mut t = "Foreign insurance companies . The provisions".to_string();
remove_spaces_before_sentence_punctuation(&mut t);
assert_eq!(t, "Foreign insurance companies. The provisions");
}
#[test]
fn strips_space_before_period_at_cell_boundary() {
let mut t = "|Applicability date .|This section|".to_string();
remove_spaces_before_sentence_punctuation(&mut t);
assert_eq!(t, "|Applicability date.|This section|");
}
#[test]
fn keeps_dot_leaders_and_ellipses() {
let mut t = "Introduction ... 1".to_string();
remove_spaces_before_sentence_punctuation(&mut t);
assert_eq!(t, "Introduction ... 1");
}
#[test]
fn keeps_mid_token_periods() {
let mut t = "version 3 .14 released".to_string();
remove_spaces_before_sentence_punctuation(&mut t);
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]
@@ -811,14 +385,12 @@ mod tests {
fn test_is_page_number_page_x() {
assert!(is_page_number_line("Page 5"));
assert!(is_page_number_line("page 12"));
assert!(is_page_number_line("Page123"));
}
#[test]
fn test_is_page_number_page_x_of_y() {
assert!(is_page_number_line("Page 3 of 10"));
assert!(is_page_number_line("page 1 of 5"));
assert!(is_page_number_line("Page 3 of 10 Report header"));
}
#[test]
@@ -848,13 +420,6 @@ mod tests {
assert!(!is_page_number_line("Hello World"));
assert!(!is_page_number_line("Chapter 1"));
assert!(!is_page_number_line("Total: 500"));
assert!(!is_page_number_line("PAGE0-PARA2-END-MARKER-0"));
}
#[test]
fn test_is_page_number_labeled_running_header() {
assert!(is_page_number_line("Page 42 Chapter 5"));
assert!(is_page_number_line("Page 42 explains the result"));
}
// --- remove_page_numbers ---
@@ -882,24 +447,6 @@ mod tests {
assert!(result.contains("42"));
}
#[test]
fn test_remove_page_numbers_labeled_header_with_content() {
let input = "Content\n\nPage 42 explains the result\n---\nEnd";
let result = remove_page_numbers(input);
assert!(!result.contains("Page 42 explains the result"));
assert!(result.contains("Content"));
assert!(result.contains("End"));
}
#[test]
fn test_remove_page_numbers_preserves_page_prefixed_content() {
let input = "PAGE0-PARA2-START substantive report text PAGE0-PARA2-END-MARKER-0";
let result = remove_page_numbers(input);
assert_eq!(result, input);
}
#[test]
fn test_remove_page_numbers_multiple_patterns() {
let input = "\n1\n\nContent\n\n2\n\n---\nMore\n\n3\n";
+2 -108
View File
@@ -3,7 +3,7 @@
use std::collections::{HashMap, HashSet};
use crate::structure_tree::StructRole;
use crate::types::{TextItem, TextLine};
use crate::types::TextLine;
use super::analysis::detect_header_level;
@@ -42,12 +42,7 @@ fn effective_heading_level(
// Fall back to font-size heuristic
let font = line.items.first().map(|i| i.font_size).unwrap_or(base_size);
detect_header_level(
font,
base_size,
heading_tiers,
crate::markdown::analysis::line_is_mostly_bold(line),
)
detect_header_level(font, base_size, heading_tiers)
}
/// Merge consecutive heading lines at the same level into a single line.
@@ -92,41 +87,6 @@ pub(crate) fn merge_heading_lines(
false
};
// Bold headings at body font size never reach a tier, so wrapped ones
// split into two output headings ("…of wood pellets and cost" /
// "structure in Japan"). Merge a fully-bold line into the previous
// fully-bold line when it reads as a wrap continuation: starts
// lowercase, tiny Y gap, and the previous line has no terminal
// punctuation. Kept deliberately narrow — bold list labels and bold
// sentences start with markers or capitals and are unaffected.
let should_merge = should_merge
|| if let Some(prev) = result.last() {
let all_bold = |l: &TextLine| {
!l.items.is_empty() && l.items.iter().all(|i: &TextItem| i.is_bold)
};
let prev_text = prev.text();
let prev_trim = prev_text.trim_end();
let curr_text = line.text();
let curr_trim = curr_text.trim();
let y_gap = prev.y - line.y;
// Both lines must be tier-less: a tiered/tagged bold heading
// followed by bold body text must not absorb it.
line_level.is_none()
&& effective_heading_level(prev, base_size, heading_tiers, struct_roles)
.is_none()
&& prev.page == line.page
&& all_bold(prev)
&& all_bold(&line)
&& y_gap > 0.0
&& y_gap < line_font * 1.6
&& curr_trim.chars().next().is_some_and(|c| c.is_lowercase())
&& !prev_trim.ends_with(['.', ':', ';', '!', '?'])
&& prev_trim.split_whitespace().count() + curr_trim.split_whitespace().count()
<= 20
} else {
false
};
if should_merge {
// Append this line's items to the previous line
let prev = result.last_mut().unwrap();
@@ -582,8 +542,6 @@ mod tests {
page: 1,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: ItemType::Text,
mcid,
}
@@ -725,68 +683,4 @@ mod tests {
.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;
TextLine {
items: vec![item],
y,
page,
adaptive_threshold: 0.10,
}
}
#[test]
fn merge_wrapped_bold_heading_lowercase_continuation() {
// Bold-at-body-size heading wrapped across two lines: the second line
// starts lowercase and must merge into the first.
let lines = vec![
make_bold_line(
"3. Perspective of supply and demand balance and cost",
1,
700.0,
),
make_bold_line("structure in Japan", 1, 686.0),
make_line("Body text paragraph follows here.", 12.0, 1, 660.0, None),
];
let result = merge_heading_lines(lines, 12.0, &[], None);
assert_eq!(result.len(), 2, "wrapped bold heading should merge");
assert!(result[0].text().contains("cost structure in Japan"));
}
#[test]
fn no_merge_for_bold_sentences_or_new_headings() {
// Second bold line starts with a capital — a new heading or label,
// not a wrap continuation.
let lines = vec![
make_bold_line("Replace", 1, 700.0),
make_bold_line("Trash", 1, 686.0),
];
let result = merge_heading_lines(lines, 12.0, &[], None);
assert_eq!(result.len(), 2, "distinct bold lines must not merge");
// Previous line ends a sentence — continuation must not merge.
let lines = vec![
make_bold_line("This is a bold sentence.", 1, 700.0),
make_bold_line("another bold line", 1, 686.0),
];
let result = merge_heading_lines(lines, 12.0, &[], None);
assert_eq!(result.len(), 2, "sentence-final bold line must not merge");
}
#[test]
fn tiered_bold_heading_does_not_absorb_bold_body() {
// Previous line is a tier-level bold heading (16pt vs 12pt body);
// a following lowercase bold body line must NOT merge into it.
let mut heading = make_bold_line("Section Title", 1, 700.0);
heading.items[0].font_size = 16.0;
heading.items[0].height = 16.0;
let lines = vec![
heading,
make_bold_line("emphasized body text continues here", 1, 686.0),
];
let result = merge_heading_lines(lines, 12.0, &[16.0], None);
assert_eq!(result.len(), 2, "tiered heading must not absorb bold body");
}
}
-144
View File
@@ -30,9 +30,6 @@ pub struct PyPdfResult {
/// 1-indexed page numbers that need OCR.
#[pyo3(get)]
pub pages_needing_ocr: Vec<u32>,
/// Machine-readable OCR reasons by 1-indexed page.
#[pyo3(get)]
pub ocr_reasons_by_page: Vec<PyPageOcrReasons>,
/// Title from PDF metadata.
#[pyo3(get)]
pub title: Option<String>,
@@ -63,28 +60,6 @@ impl PyPdfResult {
}
}
/// OCR reasons for a single 1-indexed page.
#[pyclass(name = "PageOcrReasons")]
#[derive(Clone)]
pub struct PyPageOcrReasons {
/// 1-indexed page number.
#[pyo3(get)]
pub page: u32,
/// Machine-readable OCR reason identifiers.
#[pyo3(get)]
pub reasons: Vec<String>,
}
#[pymethods]
impl PyPageOcrReasons {
fn __repr__(&self) -> String {
format!(
"PageOcrReasons(page={}, reasons={:?})",
self.page, self.reasons
)
}
}
// ---------------------------------------------------------------------------
// Classification wrapper (lightweight)
// ---------------------------------------------------------------------------
@@ -131,9 +106,6 @@ pub struct PyRegionText {
/// True when the text should not be trusted (empty, GID fonts, garbage, encoding issues).
#[pyo3(get)]
pub needs_ocr: bool,
/// Machine-readable OCR reason when the cause is known.
#[pyo3(get)]
pub ocr_reason: Option<String>,
}
#[pymethods]
@@ -188,9 +160,6 @@ pub struct PyPageMarkdown {
/// encoding issues, garbage text, or empty extraction).
#[pyo3(get)]
pub needs_ocr: bool,
/// Machine-readable OCR reason when the cause is known.
#[pyo3(get)]
pub ocr_reason: Option<String>,
}
#[pymethods]
@@ -221,9 +190,6 @@ pub struct PyPagesExtractionResult {
/// 1-indexed pages that need OCR (scanned/image-based or unreliable text).
#[pyo3(get)]
pub pages_needing_ocr: Vec<u32>,
/// Machine-readable OCR reasons by 1-indexed page.
#[pyo3(get)]
pub ocr_reasons_by_page: Vec<PyPageOcrReasons>,
/// True if any page has tables or columns.
#[pyo3(get)]
pub is_complex: bool,
@@ -266,17 +232,7 @@ pub struct PyTextItem {
#[pyo3(get)]
pub is_italic: bool,
#[pyo3(get)]
pub is_underline: bool,
#[pyo3(get)]
pub is_strikeout: bool,
#[pyo3(get)]
pub item_type: String,
/// Marked Content ID from the content stream's BDC/BMC operator, None
/// when the text is not part of marked content. Join with the
/// (page, mcid) pairs from extract_structure_elements to attach
/// structure-tree roles (headings, paragraphs, ...) in tagged PDFs.
#[pyo3(get)]
pub mcid: Option<i64>,
}
#[pymethods]
@@ -292,32 +248,6 @@ impl PyTextItem {
}
}
/// One structure-tree element reference from a tagged PDF.
#[pyclass(name = "StructureElement")]
#[derive(Clone)]
pub struct PyStructureElement {
/// 1-indexed page number (matches TextItem.page).
#[pyo3(get)]
pub page: u32,
/// Marked Content ID from the page's content stream (matches
/// TextItem.mcid).
#[pyo3(get)]
pub mcid: i64,
/// Standard structure type name ("H1".."H6", "P", "Table", "TD", ...).
#[pyo3(get)]
pub role: String,
}
#[pymethods]
impl PyStructureElement {
fn __repr__(&self) -> String {
format!(
"StructureElement(page={}, mcid={}, role='{}')",
self.page, self.mcid, self.role
)
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -338,7 +268,6 @@ fn to_py_result(r: crate::PdfProcessResult) -> PyPdfResult {
page_count: r.page_count,
processing_time_ms: r.processing_time_ms,
pages_needing_ocr: r.pages_needing_ocr,
ocr_reasons_by_page: to_py_page_ocr_reasons(r.ocr_reasons_by_page),
title: r.title,
confidence: r.confidence,
is_complex_layout: r.layout.is_complex,
@@ -348,16 +277,6 @@ fn to_py_result(r: crate::PdfProcessResult) -> PyPdfResult {
}
}
fn to_py_page_ocr_reasons(reasons: Vec<crate::PageOcrReasons>) -> Vec<PyPageOcrReasons> {
reasons
.into_iter()
.map(|reason| PyPageOcrReasons {
page: reason.page,
reasons: reason.reasons,
})
.collect()
}
fn to_py_err(e: crate::PdfError) -> PyErr {
PyValueError::new_err(e.to_string())
}
@@ -385,21 +304,7 @@ fn convert_text_items(items: Vec<crate::TextItem>) -> Vec<PyTextItem> {
page: item.page,
is_bold: item.is_bold,
is_italic: item.is_italic,
is_underline: item.is_underline,
is_strikeout: item.is_strikeout,
item_type: item_type_str(&item.item_type),
mcid: item.mcid,
})
.collect()
}
fn convert_structure_elements(elements: Vec<crate::StructureElement>) -> Vec<PyStructureElement> {
elements
.into_iter()
.map(|e| PyStructureElement {
page: e.page,
mcid: e.mcid,
role: e.role,
})
.collect()
}
@@ -445,13 +350,11 @@ fn to_py_pages_result(r: crate::PagesExtractionResult) -> PyPagesExtractionResul
page: p.page,
markdown: p.markdown,
needs_ocr: p.needs_ocr,
ocr_reason: p.ocr_reason,
})
.collect(),
pages_with_tables: r.pages_with_tables,
pages_with_columns: r.pages_with_columns,
pages_needing_ocr: r.pages_needing_ocr,
ocr_reasons_by_page: to_py_page_ocr_reasons(r.ocr_reasons_by_page),
is_complex: r.is_complex,
}
}
@@ -467,7 +370,6 @@ fn convert_region_results(results: Vec<crate::PageRegionResult>) -> Vec<PyPageRe
.map(|r| PyRegionText {
text: r.text,
needs_ocr: r.needs_ocr,
ocr_reason: r.ocr_reason,
})
.collect(),
})
@@ -657,56 +559,12 @@ fn extract_pages_markdown_bytes(
Ok(to_py_pages_result(result))
}
/// Extract structure-tree element references from a tagged PDF file.
///
/// Parses the document's structure tree (when present) and returns one
/// entry per marked-content reference, resolved to its 1-indexed page,
/// MCID, and structure type name ("H1".."H6", "P", "Table", ...). Returns
/// an empty list when the PDF is not tagged.
///
/// Join (page, mcid) against the page/mcid attributes from
/// [`extract_text_with_positions`] to attach heading levels and other
/// semantic roles to extracted text.
///
/// Args:
/// path: Path to the PDF file.
/// pages: Optional list of 1-indexed pages (matching TextItem.page).
/// When None (default), the whole document is returned.
///
/// Returns:
/// List of StructureElement sorted by (page, mcid).
#[pyfunction]
#[pyo3(signature = (path, pages=None))]
fn extract_structure_elements(
path: &str,
pages: Option<Vec<u32>>,
) -> PyResult<Vec<PyStructureElement>> {
let elements = crate::extract_structure_elements(path, pages.as_deref()).map_err(to_py_err)?;
Ok(convert_structure_elements(elements))
}
/// Extract structure-tree element references from tagged PDF bytes.
///
/// See [`extract_structure_elements`] for details.
#[pyfunction]
#[pyo3(signature = (data, pages=None))]
fn extract_structure_elements_bytes(
data: &[u8],
pages: Option<Vec<u32>>,
) -> PyResult<Vec<PyStructureElement>> {
let elements =
crate::extract_structure_elements_mem(data, pages.as_deref()).map_err(to_py_err)?;
Ok(convert_structure_elements(elements))
}
/// Python module definition.
#[pymodule]
fn pdf_inspector(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyPdfResult>()?;
m.add_class::<PyPageOcrReasons>()?;
m.add_class::<PyPdfClassification>()?;
m.add_class::<PyTextItem>()?;
m.add_class::<PyStructureElement>()?;
m.add_class::<PyRegionText>()?;
m.add_class::<PyPageRegionTexts>()?;
m.add_class::<PyPageMarkdown>()?;
@@ -721,8 +579,6 @@ fn pdf_inspector(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_function(wrap_pyfunction!(extract_text_bytes, m)?)?;
m.add_function(wrap_pyfunction!(extract_text_with_positions, m)?)?;
m.add_function(wrap_pyfunction!(extract_text_with_positions_bytes, m)?)?;
m.add_function(wrap_pyfunction!(extract_structure_elements, m)?)?;
m.add_function(wrap_pyfunction!(extract_structure_elements_bytes, m)?)?;
m.add_function(wrap_pyfunction!(extract_text_in_regions, m)?)?;
m.add_function(wrap_pyfunction!(extract_text_in_regions_bytes, m)?)?;
m.add_function(wrap_pyfunction!(extract_pages_markdown, m)?)?;
+38 -913
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+48 -2820
View File
File diff suppressed because it is too large Load Diff
+70 -2423
View File
File diff suppressed because it is too large Load Diff
-2
View File
@@ -586,8 +586,6 @@ mod tests {
page,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: ItemType::Text,
mcid,
}
-2
View File
@@ -108,8 +108,6 @@ pub(crate) fn try_split_financial_item(item: &TextItem) -> Option<Vec<TextItem>>
page: item.page,
is_bold: item.is_bold,
is_italic: item.is_italic,
is_underline: item.is_underline,
is_strikeout: item.is_strikeout,
item_type: item.item_type.clone(),
mcid: item.mcid,
});
+7 -329
View File
@@ -123,7 +123,6 @@ fn format_toc_as_list(cells: &[Vec<String>], footnotes: &[String]) -> String {
/// True when the cell looks like a page number. Accepts:
/// - plain digit tokens: "42", "86 86"
/// - canonical roman numerals (front-matter pages): "vii", "ix", "xii"
/// - dashed section-page IDs: "5-21", "A-1", "B--3", "TC-2" (common in
/// technical manuals)
fn is_page_number_cell(cell: &str) -> bool {
@@ -139,9 +138,6 @@ fn is_page_number_cell(cell: &str) -> bool {
if all_digits {
return t.len() <= 4;
}
if super::canonical_roman_value(t).is_some() {
return true;
}
// Section-page form: uppercase letters, digits, dashes; at least
// one digit present.
t.chars()
@@ -164,96 +160,6 @@ fn starts_with_uppercase_word(cell: &str) -> bool {
.is_some_and(|c| c.is_uppercase())
}
fn starts_with_uppercase_alpha(cell: &str) -> bool {
cell.chars()
.find(|c| c.is_alphabetic())
.is_some_and(|c| c.is_uppercase())
}
fn starts_with_lowercase_alpha(cell: &str) -> bool {
cell.chars()
.find(|c| c.is_alphabetic())
.is_some_and(|c| c.is_lowercase())
}
fn starts_with_numbered_label(cell: &str) -> bool {
let trimmed = cell.trim_start();
let digit_count = trimmed.chars().take_while(|c| c.is_ascii_digit()).count();
digit_count > 0
&& digit_count <= 3
&& trimmed
.chars()
.nth(digit_count)
.is_some_and(|c| matches!(c, '.' | ')' | '-' | ':'))
}
fn starts_with_hierarchical_numbered_label(cell: &str) -> bool {
let token = cell
.split_whitespace()
.next()
.unwrap_or("")
.trim_end_matches(['.', ')', ':', '-']);
let levels: Vec<&str> = token.split('.').collect();
(2..=4).contains(&levels.len())
&& levels.iter().all(|level| {
!level.is_empty()
&& level.len() <= 3
&& level.chars().all(|character| character.is_ascii_digit())
})
}
fn alpha_word_count(cell: &str) -> usize {
cell.split_whitespace()
.filter(|word| word.chars().any(|c| c.is_alphabetic()))
.count()
}
fn looks_like_compact_entry_label(cell: &str) -> bool {
let trimmed = cell.trim();
if trimmed.len() < 3 || trimmed.len() > 80 {
return false;
}
if !starts_with_uppercase_alpha(trimmed) && !starts_with_numbered_label(trimmed) {
return false;
}
if trimmed.ends_with(['.', ',', ';', ':']) {
return false;
}
let words = alpha_word_count(trimmed);
(1..=6).contains(&words)
}
fn looks_like_plain_section_label(cell: &str) -> bool {
let trimmed = cell.trim();
if trimmed.len() < 4 || trimmed.len() > 40 {
return false;
}
if trimmed.ends_with(['.', ',', ';', ':']) || trimmed.contains(|ch: char| ch.is_ascii_digit()) {
return false;
}
if trimmed.len() <= 4 && trimmed.chars().all(|ch| !ch.is_lowercase()) {
return false;
}
trimmed
.chars()
.all(|ch| ch.is_alphabetic() || ch.is_whitespace() || matches!(ch, '&' | '/' | '-'))
&& starts_with_uppercase_alpha(trimmed)
&& (1..=4).contains(&alpha_word_count(trimmed))
}
fn ends_like_incomplete_phrase(cell: &str) -> bool {
let lower = cell.trim_end().to_ascii_lowercase();
lower.ends_with(" and")
|| lower.ends_with(" or")
|| lower.ends_with(',')
|| lower.ends_with('-')
|| lower.ends_with('/')
}
/// Clean up table cells: merge continuation rows, extract footnotes, remove empty rows
fn clean_table_cells(cells: &[Vec<String>]) -> (Vec<Vec<String>>, Vec<String>) {
let mut cleaned: Vec<Vec<String>> = Vec::new();
@@ -279,9 +185,6 @@ fn clean_table_cells(cells: &[Vec<String>]) -> (Vec<Vec<String>>, Vec<String>) {
continue;
}
let num_cols = row.len();
let filled_cells = row.iter().filter(|c| !c.trim().is_empty()).count();
// Check if this is a continuation row (first column is empty but others have content).
// A row with only 1 short non-empty cell (besides the first) is more likely a
// section sub-header (e.g. "JAN", "FEB") than overflow text — don't merge it.
@@ -319,77 +222,31 @@ fn clean_table_cells(cells: &[Vec<String>]) -> (Vec<Vec<String>>, Vec<String>) {
.iter()
.filter(|cell| starts_with_uppercase_word(cell))
.count();
let first_non_empty_col = row.iter().position(|c| !c.trim().is_empty());
let first_non_empty_cell = first_non_empty_col
.and_then(|idx| row.get(idx))
.map(|c| c.trim())
.unwrap_or("");
let title_like_later_cells = first_non_empty_col
.map(|idx| {
row.iter()
.skip(idx + 1)
.map(|c| c.trim())
.filter(|c| !c.is_empty() && starts_with_uppercase_alpha(c))
.count()
})
.unwrap_or(0);
let prev_first_cell_empty = cleaned
.last()
.and_then(|r| r.first())
.is_some_and(|c| c.trim().is_empty());
let prev_first_cell = cleaned
.last()
.and_then(|r| r.first())
.map(|c| c.trim())
.unwrap_or("");
let header_filled = cleaned
.first()
.map(|r| r.iter().filter(|c| !c.trim().is_empty()).count())
.unwrap_or(num_cols);
let looks_like_spanning_first_column_row = first_cell.is_empty()
&& row.len() >= 4
&& non_first_cells.len() == row.len().saturating_sub(1)
&& uppercase_leading_cells >= non_first_cells.len().saturating_sub(1);
// Hierarchical tables often use a row-spanned first column: sub-rows
// leave column 0 blank, then start a compact title-like label in
// column 1. Wrapped continuations in the existing fixtures start
// mid-sentence/lowercase ("continued text here", "with 3.5%...") or
// carry lowercase fragments in the later cells, so keep those mergeable.
let looks_like_hierarchical_subrow = first_cell.is_empty()
&& first_non_empty_col == Some(1)
&& looks_like_compact_entry_label(first_non_empty_cell)
&& ((row.len() == 2 && starts_with_hierarchical_numbered_label(first_non_empty_cell))
|| (row.len() >= 3 && non_first_cells.len() >= 2 && title_like_later_cells > 0)
|| (non_first_cells.len() == 1
&& row.len() >= 3
&& prev_first_cell_empty
&& alpha_word_count(first_non_empty_cell) >= 2));
let looks_like_new_first_column_entry = !first_cell.is_empty()
&& (starts_with_numbered_label(first_cell) || starts_with_uppercase_alpha(first_cell))
&& filled_cells >= 2
&& non_first_cells
.iter()
.any(|cell| looks_like_compact_entry_label(cell));
let looks_like_section_label_row = !first_cell.is_empty()
&& filled_cells == 1
&& header_filled >= 3
&& looks_like_plain_section_label(first_cell);
// Classic continuation: first cell empty, content in other cells
let is_classic_continuation = first_cell.is_empty()
&& !non_first_cells.is_empty()
&& !is_short_subheader
&& !looks_like_data_row
&& !looks_like_spanning_first_column_row
&& !looks_like_hierarchical_subrow
&& cleaned.len() > 1;
// Wrapped-cell continuation: row has fewer filled cells than the header
// row, suggesting it's overflow text from the previous row's cells.
// Only trigger when the previous row has significantly more filled cells.
let num_cols = row.len();
let filled_cells = row.iter().filter(|c| !c.trim().is_empty()).count();
let prev_filled = cleaned
.last()
.map(|r| r.iter().filter(|c| !c.trim().is_empty()).count())
.unwrap_or(0);
let header_filled = cleaned
.first()
.map(|r| r.iter().filter(|c| !c.trim().is_empty()).count())
.unwrap_or(num_cols);
// Merge when the row has significantly fewer filled cells than header.
// For wide tables (5+ cols), require ≤50% of header cells.
// For narrow tables (2-4 cols), require fewer than header cells.
@@ -400,18 +257,11 @@ fn clean_table_cells(cells: &[Vec<String>]) -> (Vec<Vec<String>>, Vec<String>) {
} else {
header_filled.saturating_sub(1)
};
let continues_wrapped_first_column_label = !first_cell.is_empty()
&& starts_with_lowercase_alpha(first_cell)
&& ends_like_incomplete_phrase(prev_first_cell);
let is_wrapped_continuation = cleaned.len() > 1
&& filled_cells <= max_filled_for_merge
&& (prev_filled > filled_cells
|| (continues_wrapped_first_column_label && prev_filled >= filled_cells))
&& prev_filled > filled_cells
&& !looks_like_data_row
&& !looks_like_spanning_first_column_row
&& !looks_like_hierarchical_subrow
&& !looks_like_new_first_column_entry
&& !looks_like_section_label_row
&& !is_short_subheader;
let is_continuation = is_classic_continuation || is_wrapped_continuation;
@@ -557,44 +407,6 @@ mod tests {
assert!(cleaned[1][1].contains("continued text here"));
}
#[test]
fn test_clean_table_cells_first_column_section_label_not_merged() {
let cells = vec![
vec![
"Properties".into(),
"Conditions".into(),
"Method".into(),
"Typical values".into(),
"Units".into(),
],
vec![
"Melt Flow Rate".into(),
"230 C/2.16 kg".into(),
"ASTM D1238".into(),
"3.0".into(),
"g/10 min".into(),
],
vec![
"Mechanical".into(),
"".into(),
"".into(),
"".into(),
"".into(),
],
vec![
"Tensile Stress at Yield".into(),
"50 mm/min".into(),
"ASTM D638".into(),
"31".into(),
"MPa".into(),
],
];
let (cleaned, _) = clean_table_cells(&cells);
assert_eq!(cleaned.len(), 4);
assert_eq!(cleaned[2][0], "Mechanical");
}
#[test]
fn test_clean_table_cells_short_subheader_not_merged() {
let cells = vec![
@@ -647,140 +459,6 @@ mod tests {
assert_eq!(cleaned[2][1], "Uncertainty around other copies");
}
#[test]
fn test_clean_table_cells_numbered_hierarchy_rows_not_overmerged() {
let cells = vec![
vec![
"Group".into(),
"Task".into(),
"Detail".into(),
"Benefit".into(),
],
vec![
"1. Group alpha".into(),
"Task setup and".into(),
"Begin setup".into(),
"Faster start".into(),
],
vec![
"".into(),
"management".into(),
"recommended profile".into(),
"with saved defaults".into(),
],
vec![
"2. Group beta and".into(),
"Storage setup".into(),
"Provides upload tools".into(),
"".into(),
],
vec![
"fine-tuning".into(),
"".into(),
"for filtered inputs".into(),
"service".into(),
],
vec![
"".into(),
"Label workspace".into(),
"Creates review sets".into(),
"Lets teams review".into(),
],
vec![
"".into(),
"Model training".into(),
"".into(),
"Supports custom model".into(),
],
];
let (cleaned, _) = clean_table_cells(&cells);
assert_eq!(cleaned.len(), 5);
assert_eq!(cleaned[1][0], "1. Group alpha");
assert_eq!(cleaned[1][1], "Task setup and management");
assert_eq!(cleaned[2][0], "2. Group beta and fine-tuning");
assert_eq!(cleaned[2][1], "Storage setup");
assert_eq!(cleaned[3][1], "Label workspace");
assert_eq!(cleaned[4][1], "Model training");
}
#[test]
fn test_clean_table_cells_two_column_numbered_subrows_not_merged() {
let cells = vec![
vec!["Area".into(), "Competence".into()],
vec![
"1. Embodying sustainability values".into(),
"1.1 Valuing sustainability".into(),
],
vec!["".into(), "1.2 Supporting fairness".into()],
vec!["".into(), "1.3 Promoting nature".into()],
vec![
"2. Embracing complexity".into(),
"2.1 Systems thinking".into(),
],
vec!["".into(), "2.2 Critical thinking".into()],
];
let (cleaned, _) = clean_table_cells(&cells);
assert_eq!(cleaned.len(), 6);
assert_eq!(cleaned[2], vec!["", "1.2 Supporting fairness"]);
assert_eq!(cleaned[3], vec!["", "1.3 Promoting nature"]);
assert_eq!(cleaned[5], vec!["", "2.2 Critical thinking"]);
}
#[test]
fn test_clean_table_cells_two_column_numbered_continuation_merges() {
let cells = vec![
vec!["Area".into(), "Requirement".into()],
vec!["Safety".into(), "The program includes".into()],
vec!["".into(), "1. First requirement for every operator".into()],
];
let (cleaned, _) = clean_table_cells(&cells);
assert_eq!(cleaned.len(), 2);
assert_eq!(
cleaned[1][1],
"The program includes 1. First requirement for every operator"
);
}
#[test]
fn test_clean_table_cells_partial_hierarchical_subrow_not_merged() {
let cells = vec![
vec![
"Group".into(),
"Task".into(),
"Detail".into(),
"Benefit".into(),
],
vec![
"Group A".into(),
"Alpha task".into(),
"Initial detail".into(),
"Initial benefit".into(),
],
vec![
"".into(),
"Beta task".into(),
"Parallel detail".into(),
"".into(),
],
vec![
"".into(),
"second line".into(),
"additional detail".into(),
"".into(),
],
];
let (cleaned, _) = clean_table_cells(&cells);
assert_eq!(cleaned.len(), 3);
assert_eq!(cleaned[1][1], "Alpha task");
assert_eq!(cleaned[2][0], "");
assert_eq!(cleaned[2][1], "Beta task second line");
assert_eq!(cleaned[2][2], "Parallel detail additional detail");
}
#[test]
fn test_clean_table_cells_full_width_continuation_row_still_merges_when_lowercase() {
let cells = vec![
+3 -53
View File
@@ -369,10 +369,6 @@ pub(crate) fn join_cell_items(items: &[&TextItem]) -> String {
let prev_ends_with_hyphen = result.ends_with('-');
let curr_is_hyphen = text == "-";
let curr_starts_with_hyphen = text.starts_with('-');
let prev_ends_with_open_delimiter =
result.ends_with('(') || result.ends_with('[') || result.ends_with('{');
let curr_starts_with_close_delimiter =
text.starts_with(')') || text.starts_with(']') || text.starts_with('}');
// Detect subscript/superscript: smaller font size and/or Y offset
let font_ratio = item.font_size / prev_item.font_size;
@@ -389,8 +385,6 @@ pub(crate) fn join_cell_items(items: &[&TextItem]) -> String {
|| curr_starts_with_hyphen
|| is_sub_super
|| was_sub_super
|| prev_ends_with_open_delimiter
|| curr_starts_with_close_delimiter
{
result.push_str(text);
} else {
@@ -436,8 +430,7 @@ pub(crate) fn recover_header_row(
.iter()
.enumerate()
.filter(|(_, item)| {
!item.is_strikeout
&& item.font_size > small_font_threshold
item.font_size > small_font_threshold
&& item.y > first_row_y
&& item.y <= first_row_y + row_gap_limit
})
@@ -521,8 +514,6 @@ mod tests {
page: 1,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: ItemType::Text,
mcid: None,
}
@@ -604,7 +595,7 @@ mod tests {
let items: Vec<(usize, &TextItem)> = vec![];
assert_eq!(
find_column_boundaries(&items, TableDetectionMode::SmallFont),
Vec::<f32>::new()
vec![]
);
}
@@ -661,7 +652,7 @@ mod tests {
#[test]
fn test_find_row_boundaries_empty() {
let items: Vec<(usize, &TextItem)> = vec![];
assert_eq!(find_row_boundaries(&items), Vec::<f32>::new());
assert_eq!(find_row_boundaries(&items), vec![]);
}
#[test]
@@ -736,18 +727,6 @@ mod tests {
assert_eq!(join_cell_items(&[&a, &b, &c]), "pre-fix");
}
#[test]
fn test_join_cell_items_parenthetical_no_inner_spaces() {
let a = make_item("The first sentence", 100.0, 500.0, 10.0);
let b = make_item("(", 190.0, 500.0, 10.0);
let c = make_item("twice", 195.0, 500.0, 10.0);
let d = make_item(")", 220.0, 500.0, 10.0);
assert_eq!(
join_cell_items(&[&a, &b, &c, &d]),
"The first sentence (twice)"
);
}
#[test]
fn test_join_cell_items_subscript_no_space() {
let a = make_item("H", 100.0, 500.0, 12.0);
@@ -805,31 +784,6 @@ mod tests {
assert_eq!(table.rows.len(), rows_before);
}
#[test]
fn test_recover_header_row_skips_strikeout_candidates() {
let mut old_col1 = make_item("Old Col1", 100.0, 520.0, 12.0);
old_col1.is_strikeout = true;
let mut old_col2 = make_item("Old Col2", 200.0, 520.0, 12.0);
old_col2.is_strikeout = true;
let all_items = vec![
old_col1,
old_col2,
make_item("A", 100.0, 500.0, 8.0),
make_item("B", 200.0, 500.0, 8.0),
];
let mut table = Table {
columns: vec![100.0, 200.0],
rows: vec![500.0, 480.0],
cells: vec![vec!["A".into(), "B".into()], vec!["C".into(), "D".into()]],
item_indices: vec![2, 3],
kind: TableKind::Data,
};
recover_header_row(&mut table, &all_items, 9.0);
assert_eq!(table.rows.len(), 2);
assert_eq!(table.cells[0], vec!["A", "B"]);
}
#[test]
fn test_recover_header_row_too_far_above() {
let all_items = vec![
@@ -912,8 +866,6 @@ mod tests {
font: String::new(),
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: ItemType::Text,
mcid: None,
page: 1,
@@ -950,8 +902,6 @@ mod tests {
font: String::new(),
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: ItemType::Text,
mcid: None,
page: 1,
+3 -1303
View File
File diff suppressed because it is too large Load Diff
-520
View File
@@ -1,520 +0,0 @@
//! Text-quality detection: deciding when an extracted text layer is too broken
//! to serve and a page should fall back to OCR.
//!
//! Extraction can produce plausible-looking bytes that are actually garbage —
//! failed CID→Unicode mappings, broken ToUnicode CMaps, mojibake. These
//! detectors catch that and let callers set `needs_ocr`. They come in two
//! layers, sharing the same primitives:
//!
//! - **Markdown-level** ([`detect_encoding_issues`], [`is_garbage_text`],
//! [`is_cid_garbage`]) run on a page's final markdown string. Used as a
//! backstop on the region-extraction and whole-document paths.
//! - **Item/span-level** ([`analyze_text_quality`],
//! [`region_items_have_decoding_issue`]) run on individual `TextItem`s and
//! accumulate per-page evidence, so localized garbled spans on an otherwise
//! clean page are caught without a single span having to condemn the page.
//!
//! Detection classes, roughly by signal:
//! - **Replacement runs**: U+FFFD clusters ([`has_replacement_text_run`]).
//! - **Private-use / C1-control runs**: CID passthrough landing in PUA or the
//! C1 block ([`has_private_use_text_run`], [`has_cid_control_token`]).
//! - **Dollar-as-space**: `Word$Word$Word` from broken CMaps
//! ([`has_dollar_as_space_pattern`]).
//! - **Non-alphanumeric dominance**: symbol soup ([`is_garbage_text`]).
//! - **Substitution-cipher letter statistics**: pure-ASCII output whose letter
//! distribution is a permutation of natural language ([`CipherGarbleStats`]).
use crate::types::TextItem;
use crate::{add_ocr_reason, OCR_REASON_SUSPECTED_GARBLED_TEXT};
use std::collections::BTreeMap;
/// Detect broken font encodings in extracted markdown text.
///
/// Two heuristics:
/// 1. **U+FFFD**: Any replacement character indicates decode failures.
/// 2. **Dollar-as-space**: Pattern like `Word$Word$Word` where `$` is used as a
/// word separator due to broken ToUnicode CMaps. Triggers when either:
/// - More than 50% of `$` are between letters (clear substitution pattern), OR
/// - More than 20 letter-dollar-letter occurrences (even if some `$` are also
/// used as trailing/leading separators, 20+ is far beyond normal financial text).
pub(crate) fn detect_encoding_issues(markdown: &str) -> bool {
// Heuristic 1: U+FFFD replacement characters
if markdown.contains('\u{FFFD}') {
return true;
}
// Heuristic 2: dollar-as-space pattern
if has_dollar_as_space_pattern(markdown) {
return true;
}
// Heuristic 3: substitution-cipher letter statistics (broken ToUnicode)
let mut stats = CipherGarbleStats::default();
stats.add_text(markdown);
stats.looks_garbled()
}
fn has_dollar_as_space_pattern(markdown: &str) -> bool {
let total_dollars = markdown.matches('$').count();
if total_dollars > 10 {
let bytes = markdown.as_bytes();
let mut letter_dollar_letter = 0usize;
for i in 1..bytes.len().saturating_sub(1) {
if bytes[i] == b'$'
&& bytes[i - 1].is_ascii_alphabetic()
&& bytes[i + 1].is_ascii_alphabetic()
{
letter_dollar_letter += 1;
}
}
if letter_dollar_letter > 20 || letter_dollar_letter * 2 > total_dollars {
return true;
}
}
false
}
/// English letter frequencies (percent, az). Used as a natural-language
/// reference: every Latin-script language in the eval corpus (Swedish,
/// Finnish, Turkish, German, romaji) scores ≥ 0.80 cosine similarity against
/// it, while substitution-cipher text scores ~0.53.
const ENGLISH_LETTER_FREQ: [f64; 26] = [
8.2, 1.5, 2.8, 4.3, 12.7, 2.2, 2.0, 6.1, 7.0, 0.15, 0.8, 4.0, 2.4, 6.7, 7.5, 1.9, 0.1, 6.0,
6.3, 9.1, 2.8, 1.0, 2.4, 0.15, 2.0, 0.07,
];
/// Letter statistics for detecting substitution-cipher garbling: broken
/// ToUnicode CMaps that shift every character by a per-range constant (e.g.
/// `Certificate` extracted as `8VceZWZTReV`). Such text is 100% printable
/// ASCII with word-like token lengths, so it defeats `is_garbage_text` and
/// produces no replacement characters — it needs its own discriminator.
#[derive(Debug, Default)]
struct CipherGarbleStats {
/// Case-folded ASCII letter histogram.
letter_counts: [u32; 26],
ascii_letters: usize,
ascii_vowels: usize,
/// Accented Latin letters (Latin-1 Supplement through Latin Extended-B,
/// plus Latin Extended Additional). Count toward Latin dominance only.
latin_ext_letters: usize,
non_latin_letters: usize,
/// Adjacent ASCII-letter pairs, and how many of them switch from
/// lowercase straight to uppercase mid-word.
letter_bigrams: usize,
case_shift_bigrams: usize,
}
impl CipherGarbleStats {
fn add_text(&mut self, text: &str) {
let mut prev: Option<char> = None;
for ch in text.chars() {
if ch.is_ascii_alphabetic() {
let idx = (ch.to_ascii_lowercase() as u8 - b'a') as usize;
self.letter_counts[idx] += 1;
self.ascii_letters += 1;
if matches!(ch.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u') {
self.ascii_vowels += 1;
}
if let Some(p) = prev {
self.letter_bigrams += 1;
if p.is_ascii_lowercase() && ch.is_ascii_uppercase() {
self.case_shift_bigrams += 1;
}
}
prev = Some(ch);
} else {
if ch.is_alphabetic() {
if matches!(ch as u32, 0xC0..=0x24F | 0x1E00..=0x1EFF) {
self.latin_ext_letters += 1;
} else {
self.non_latin_letters += 1;
}
}
prev = None;
}
}
}
/// Cosine similarity between the observed letter histogram and English
/// letter frequencies. A shifted alphabet permutes the histogram, which
/// destroys the similarity regardless of the shift amount.
fn english_cosine(&self) -> f64 {
if self.ascii_letters == 0 {
return 1.0;
}
let n = self.ascii_letters as f64;
let mut dot = 0.0;
let mut norm_obs = 0.0;
for (count, freq) in self.letter_counts.iter().zip(ENGLISH_LETTER_FREQ) {
let p = *count as f64 / n;
dot += p * freq;
norm_obs += p * p;
}
let norm_en = ENGLISH_LETTER_FREQ
.iter()
.map(|f| f * f)
.sum::<f64>()
.sqrt();
dot / (norm_obs.sqrt() * norm_en)
}
/// Cosine similarity between the observed histogram and English
/// frequencies after sorting BOTH descending — i.e. comparing the *shape*
/// of the frequency profile, ignoring which letter sits where. A
/// substitution cipher is a bijection, so it preserves this shape exactly
/// (att10k 0.97, arbitrary shifts 0.99) regardless of case or offset.
/// Non-linguistic ASCII has a different profile: a small alphabet is far
/// steeper (random DNA 0.74, hex dumps 0.81), so the shape diverges.
fn english_shape_cosine(&self) -> f64 {
if self.ascii_letters == 0 {
return 1.0;
}
let n = self.ascii_letters as f64;
let mut obs: [f64; 26] = std::array::from_fn(|i| self.letter_counts[i] as f64 / n);
obs.sort_unstable_by(|a, b| b.total_cmp(a));
let mut en = ENGLISH_LETTER_FREQ;
en.sort_unstable_by(|a, b| b.total_cmp(a));
let dot: f64 = obs.iter().zip(en).map(|(o, e)| o * e).sum();
let norm_obs = obs.iter().map(|o| o * o).sum::<f64>().sqrt();
let norm_en = en.iter().map(|e| e * e).sum::<f64>().sqrt();
dot / (norm_obs * norm_en)
}
/// Thresholds validated against the 380-document pdf-evals snapshot
/// corpus (0 false positives) and the garbled ParseBench `att10k` page
/// (vowel ratio 0.245, case-shift rate 0.225, cosine 0.532). Closest
/// legitimate document on each axis: vowel ratio 0.264 (circuit
/// schematic), case-shift rate 0.021, cosine 0.801.
fn looks_garbled(&self) -> bool {
// Need a statistically meaningful, Latin-dominant sample.
if self.ascii_letters < 200
|| self.non_latin_letters > self.ascii_letters + self.latin_ext_letters
{
return false;
}
// Real Latin-script text keeps vowels above ~30% of letters even in
// acronym- and part-number-heavy documents; shifted text starves them.
let vowel_ratio = self.ascii_vowels as f64 / self.ascii_letters as f64;
if vowel_ratio > 0.30 {
return false;
}
// Signal 1: lowercase→uppercase transitions inside words. A shifted
// lowercase alphabet straddles the ASCII uppercase block ('i'→'Z',
// 't'→'e'), so garbled words flip case constantly. Real documents
// stay ≤ 0.02 even with camelCase identifiers.
let case_shifts = self.letter_bigrams >= 100
&& self.case_shift_bigrams as f64 >= self.letter_bigrams as f64 * 0.10;
// Signal 2: the histogram is a permutation of natural language — an
// English-like frequency SHAPE (sorted cosine high) but with letters
// in the wrong POSITIONS (unsorted cosine low). This is the signature
// of a substitution cipher and is case-independent, so it catches
// all-lowercase and all-uppercase shifts as well as case-straddling
// ones. Genuinely non-linguistic ASCII that is merely "unlike English"
// fails one of the two halves: DNA/hex dumps have too steep a profile
// (shape cosine < 0.90), while protein sequences, ticker symbols and
// base64 are not sufficiently unlike English in position (unsorted
// cosine ≥ 0.60) — so none of them are routed to OCR.
let permuted_language = self.english_cosine() < 0.60 && self.english_shape_cosine() >= 0.90;
case_shifts || permuted_language
}
}
#[derive(Debug, Default)]
pub(crate) struct TextQualityReport {
pub(crate) pages_needing_ocr: Vec<u32>,
pub(crate) has_encoding_issues: bool,
pub(crate) reasons_by_page: BTreeMap<u32, Vec<String>>,
}
#[derive(Debug, Default)]
struct PageTextQualityEvidence {
chars: usize,
replacement_chars: usize,
replacement_spans: usize,
longest_replacement_run: usize,
cipher_garble: CipherGarbleStats,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TextSpanIssueKind {
Replacement,
Strong,
}
pub(crate) fn analyze_text_quality(items: &[TextItem]) -> TextQualityReport {
let mut reasons_by_page = BTreeMap::new();
let mut evidence_by_page = BTreeMap::<u32, PageTextQualityEvidence>::new();
for item in items {
if !matches!(item.item_type, crate::types::ItemType::Text) {
continue;
}
let evidence = evidence_by_page.entry(item.page).or_default();
evidence.chars += item.text.chars().filter(|ch| !ch.is_whitespace()).count();
evidence.cipher_garble.add_text(&item.text);
match text_span_decoding_issue_kind(&item.text) {
Some(TextSpanIssueKind::Strong) => {
add_ocr_reason(
&mut reasons_by_page,
item.page,
OCR_REASON_SUSPECTED_GARBLED_TEXT,
);
}
Some(TextSpanIssueKind::Replacement) => {
let stats = replacement_text_stats(&item.text);
evidence.replacement_chars += stats.0;
evidence.replacement_spans += 1;
evidence.longest_replacement_run = evidence.longest_replacement_run.max(stats.1);
}
None => {}
}
}
for (page, evidence) in evidence_by_page {
if reasons_by_page.contains_key(&page) {
continue;
}
if page_replacement_evidence_needs_ocr(&evidence) || evidence.cipher_garble.looks_garbled()
{
add_ocr_reason(
&mut reasons_by_page,
page,
OCR_REASON_SUSPECTED_GARBLED_TEXT,
);
}
}
let pages_needing_ocr: Vec<u32> = reasons_by_page.keys().copied().collect();
TextQualityReport {
has_encoding_issues: !pages_needing_ocr.is_empty(),
pages_needing_ocr,
reasons_by_page,
}
}
pub(crate) fn region_items_have_decoding_issue(items: &[TextItem]) -> bool {
items.iter().any(|item| {
matches!(item.item_type, crate::types::ItemType::Text)
&& text_span_has_decoding_issue(&item.text)
})
}
fn text_span_has_decoding_issue(text: &str) -> bool {
text_span_decoding_issue_kind(text).is_some()
}
fn text_span_decoding_issue_kind(text: &str) -> Option<TextSpanIssueKind> {
let text = text.trim();
if text.is_empty() {
return None;
}
if has_dollar_as_space_pattern(text)
|| has_private_use_text_run(text)
|| is_cid_garbage(text)
|| has_cid_control_token(text)
{
return Some(TextSpanIssueKind::Strong);
}
if has_replacement_text_run(text) {
return Some(TextSpanIssueKind::Replacement);
}
None
}
fn replacement_text_stats(text: &str) -> (usize, usize) {
let mut replacement = 0usize;
let mut current_run = 0usize;
let mut longest_run = 0usize;
for ch in text.chars() {
if ch == '\u{FFFD}' {
replacement += 1;
current_run += 1;
longest_run = longest_run.max(current_run);
} else {
current_run = 0;
}
}
(replacement, longest_run)
}
fn page_replacement_evidence_needs_ocr(evidence: &PageTextQualityEvidence) -> bool {
if evidence.replacement_chars == 0 || evidence.chars == 0 {
return false;
}
// If the entire page is only a short broken text layer, even a short
// replacement run is enough evidence. On otherwise text-heavy pages,
// require density so math formulas do not force full-page OCR.
if evidence.chars <= 80 && evidence.longest_replacement_run >= 2 {
return true;
}
let replacement_density_bps = evidence.replacement_chars * 10_000 / evidence.chars;
let enough_bad_text = evidence.replacement_chars >= 12 && replacement_density_bps >= 500;
let repeated_bad_spans = evidence.replacement_spans >= 3 && replacement_density_bps >= 250;
let long_bad_run = evidence.longest_replacement_run >= 8 && replacement_density_bps >= 250;
enough_bad_text || repeated_bad_spans || long_bad_run
}
fn has_replacement_text_run(text: &str) -> bool {
let (replacement, longest_run) = replacement_text_stats(text);
longest_run >= 2 || replacement >= 3
}
fn has_private_use_text_run(text: &str) -> bool {
let mut total = 0usize;
let mut private_use = 0usize;
let mut current_run = 0usize;
let mut longest_run = 0usize;
for ch in text.chars() {
if ch.is_whitespace() {
current_run = 0;
continue;
}
total += 1;
if is_private_use_char(ch) {
private_use += 1;
current_run += 1;
longest_run = longest_run.max(current_run);
} else {
current_run = 0;
}
}
if private_use == 0 {
return false;
}
longest_run >= 3 || (total >= 5 && private_use >= 2 && private_use * 2 >= total)
}
fn has_cid_control_token(text: &str) -> bool {
text.split_whitespace().any(token_has_cid_control)
}
fn token_has_cid_control(token: &str) -> bool {
let mut total = 0usize;
let mut c1_control = 0usize;
for ch in token.chars() {
total += 1;
if ('\u{0080}'..='\u{009F}').contains(&ch) {
c1_control += 1;
}
}
total >= 5 && c1_control >= 2 && c1_control * 20 >= total
}
fn is_private_use_char(ch: char) -> bool {
matches!(
ch as u32,
0xE000..=0xF8FF | 0xF0000..=0xFFFFD | 0x100000..=0x10FFFD
)
}
/// Check if extracted text is predominantly garbage (non-alphanumeric).
///
/// Broken font encodings produce text like "----1-.-.-.___ --.-. .._ I_---."
/// where most characters are punctuation/symbols. Real text in any language
/// has >50% alphanumeric characters.
pub(crate) fn is_garbage_text(markdown: &str) -> bool {
let mut alphanum = 0usize;
let mut non_alphanum = 0usize;
let chars: Vec<char> = markdown.chars().collect();
let mut i = 0usize;
while i < chars.len() {
let ch = chars[i];
let mut run_end = i + 1;
while run_end < chars.len() && chars[run_end] == ch {
run_end += 1;
}
let is_decorative_leader = matches!(ch, '.' | '_' | '·') && run_end - i >= 3;
if !is_decorative_leader {
for &run_ch in &chars[i..run_end] {
if run_ch.is_whitespace() {
continue;
}
// Skip markdown syntax chars that we add (not from the PDF)
if matches!(run_ch, '#' | '*' | '|' | '-' | '\n') {
continue;
}
if run_ch.is_alphanumeric() {
alphanum += 1;
} else {
non_alphanum += 1;
}
}
}
i = run_end;
}
let total = alphanum + non_alphanum;
total >= 50 && alphanum * 2 < total
}
/// Detect garbage from failed CID-to-Unicode mapping on Identity-H fonts.
///
/// When CID values don't correspond to Unicode codepoints, the raw bytes often
/// produce characters in the C1 control range (U+0080U+009F) or Private Use
/// Area, mixed with random Latin Extended characters. Valid text in any
/// language almost never contains C1 controls. We also fall back to the
/// general `is_garbage_text` check for non-alphanumeric-heavy patterns.
pub(crate) fn is_cid_garbage(text: &str) -> bool {
if is_garbage_text(text) {
return true;
}
let mut total = 0usize;
let mut c1_control = 0usize;
let mut high_latin = 0usize;
for ch in text.chars() {
if ch.is_whitespace() {
continue;
}
total += 1;
// C1 control characters (U+0080U+009F) — almost never in real text
if ch == '·' {
continue;
}
if ('\u{0080}'..='\u{009F}').contains(&ch) {
c1_control += 1;
}
// High Latin-1 (U+00A0U+00FF) — legitimate in Western European text
// but when combined with ASCII in CID passthrough, indicates mojibake
// from CID values being misinterpreted as Latin-1 characters.
if ('\u{00A0}'..='\u{00FF}').contains(&ch) {
high_latin += 1;
}
}
if total < 5 {
return false;
}
// If ≥5% of non-whitespace chars are C1 controls, it's garbage
if c1_control >= 2 && c1_control * 20 >= total {
return true;
}
// If ≥40% of non-whitespace chars are high Latin-1 AND the text has few
// ASCII letters, it's likely CID-as-Latin-1 mojibake (Japanese/CJK PDFs
// where CID values 0x80-0xFF become accented Latin characters). Keep a
// minimum length so short math tokens like "2×()×" do not route a clean
// page to OCR.
let ascii_letters = text.chars().filter(|c| c.is_ascii_alphabetic()).count();
total >= 20 && high_latin * 5 >= total * 2 && ascii_letters * 3 < total
}
-97
View File
@@ -7,83 +7,6 @@
use crate::types::TextItem;
use unicode_normalization::UnicodeNormalization;
/// Return whether text is an explicit page-number expression.
///
/// This strict form is suitable before layout, where removing one numeric item
/// from substantive text such as `Page 42 explains the result` would lose data.
pub(crate) fn is_explicit_page_number_expression(text: &str) -> bool {
let trimmed = text.trim();
if trimmed.is_empty() {
return false;
}
let is_number = |value: &str| {
!value.is_empty() && value.chars().all(|character| character.is_ascii_digit())
};
if trimmed.len() <= 4 && is_number(trimmed) {
return true;
}
if trimmed.len() >= 3 && trimmed.starts_with('-') && trimmed.ends_with('-') {
let inner = trimmed[1..trimmed.len() - 1].trim();
if is_number(inner) {
return true;
}
}
let lowercase = trimmed.to_ascii_lowercase();
if let Some(rest) = lowercase.strip_prefix("page") {
let words: Vec<&str> = rest.split_whitespace().collect();
if words.len() >= 3 && is_number(words[0]) && words[1] == "of" && is_number(words[2]) {
return true;
}
if words.len() >= 2 && words[0] == "of" && is_number(words[1]) {
return true;
}
return match words.as_slice() {
[] | ["of"] => true,
[number] => is_number(number),
["of", total] => is_number(total),
[number, "of", total] => is_number(number) && is_number(total),
_ => false,
};
}
let words: Vec<&str> = lowercase.split_whitespace().collect();
match words.as_slice() {
[number, "of", total] => is_number(number) && is_number(total),
_ => false,
}
}
/// Return whether a completed Markdown line looks like a page number or a
/// labeled running header.
///
/// At this stage the complete line and surrounding breaks are available, so a
/// leading `Page N` remains compatible with the existing header cleanup even
/// when the PDF appends a chapter or document title.
pub(crate) fn is_page_number_line(text: &str) -> bool {
if is_explicit_page_number_expression(text) {
return true;
}
let lowercase = text.trim().to_ascii_lowercase();
lowercase.strip_prefix("page").is_some_and(|rest| {
let mut characters = rest.trim_start().chars().peekable();
let mut has_page_number = false;
while characters
.peek()
.is_some_and(|character| character.is_ascii_digit())
{
has_page_number = true;
characters.next();
}
has_page_number && characters.next().is_none_or(char::is_whitespace)
})
}
/// Check if a character is CJK (Chinese, Japanese, Korean).
/// CJK languages don't use spaces between words, so word-boundary
/// heuristics should not apply when CJK characters are involved.
@@ -170,9 +93,6 @@ pub fn is_bold_font(font_name: &str) -> bool {
|| lower.contains("extrabold")
|| lower.contains("ultrabold")
|| lower.contains("medium") && !lower.contains("mediumitalic") // Some fonts use Medium for semi-bold
// URW Type 1 fonts abbreviate Medium as "Medi" (e.g. NimbusRomNo9L-Medi,
// the Times-Bold substitute in LaTeX documents; -MediItal is bold italic).
|| lower.contains("-medi") && !lower.contains("mediumital")
}
/// Detect if a font name indicates italic/oblique style
@@ -842,17 +762,6 @@ mod tests {
use super::*;
use crate::types::ItemType;
#[test]
fn bold_font_urw_medi_abbreviation() {
// URW Type 1 fonts (LaTeX default Times) abbreviate Medium as "Medi"
assert!(is_bold_font("NROFIU+NimbusRomNo9L-Medi"));
assert!(is_bold_font("NimbusRomNo9L-MediItal"));
assert!(!is_bold_font("DSSZWN+NimbusRomNo9L-Regu"));
assert!(!is_bold_font("NimbusRomNo9L-ReguItal"));
// Medium-Italic exclusion still holds
assert!(!is_bold_font("Foo-MediumItalic"));
}
#[test]
fn strip_soft_hyphen() {
assert_eq!(expand_ligatures("con\u{00AD}tent"), "content");
@@ -974,8 +883,6 @@ mod tests {
page: 1,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: ItemType::Text,
mcid: None,
}
@@ -1095,8 +1002,6 @@ mod tests {
page: 1,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: ItemType::Text,
mcid: None,
});
@@ -1173,8 +1078,6 @@ mod tests {
page: 1,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: ItemType::Text,
mcid: None,
}
+59 -538
View File
@@ -4,17 +4,11 @@
use log::{debug, warn};
use lopdf::{Document, Object, ObjectId};
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
#[cfg(not(target_arch = "wasm32"))]
use std::path::{Path, PathBuf};
use crate::glyph_names::glyph_to_char;
#[cfg(target_arch = "wasm32")]
static BUILTIN_CMAPS: include_dir::Dir<'_> =
include_dir::include_dir!("$CARGO_MANIFEST_DIR/external/bcmaps");
/// A parsed ToUnicode CMap mapping CIDs to Unicode strings
#[derive(Debug, Default, Clone)]
pub struct ToUnicodeCMap {
@@ -335,7 +329,7 @@ impl ToUnicodeCMap {
if let (Some(start), Some(end), Some(base)) = (
parse_hex_u16(&start_hex),
parse_hex_u16(&end_hex),
hex_to_unicode_scalar(&base_hex),
parse_hex_u32(&base_hex),
) {
self.ranges.push((start, end, base));
}
@@ -540,14 +534,18 @@ 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_bfranges_for_remap(&self.ranges, &mut cid_to_unicode, MAX_CID_W_EXPANSION);
// 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());
}
}
}
// char_map entries override range entries
for (&cid, unicode) in &self.char_map {
@@ -572,118 +570,37 @@ 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()
}
/// Convert a ToUnicode destination hex string to Unicode.
///
/// PDF ToUnicode destinations are UTF-16BE strings. Supplementary-plane
/// characters are encoded as surrogate pairs, so treating each 4-hex chunk as
/// a scalar drops emoji like D83CDF1F.
/// Parse a hex string to u32
fn parse_hex_u32(hex: &str) -> Option<u32> {
u32::from_str_radix(hex.trim(), 16).ok()
}
/// Convert a hex string to a Unicode string
/// Handles both 2-byte (BMP) and 4-byte (supplementary) codepoints
fn hex_to_unicode_string(hex: &str) -> Option<String> {
let hex: String = hex.chars().filter(|ch| !ch.is_ascii_whitespace()).collect();
if hex.is_empty() || !hex.len().is_multiple_of(2) {
return None;
}
let hex = hex.trim();
let mut result = String::new();
let bytes: Option<Vec<u8>> = (0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok())
.collect();
let bytes = bytes?;
if bytes.len().is_multiple_of(2) {
let units: Vec<u16> = bytes
.chunks_exact(2)
.map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]]))
.collect();
if let Ok(result) = String::from_utf16(&units) {
if !result.is_empty() {
return Some(normalize_tounicode_destination(result));
// Process 4 hex digits at a time
let mut i = 0;
while i + 4 <= hex.len() {
if let Ok(cp) = u32::from_str_radix(&hex[i..i + 4], 16) {
if let Some(c) = char::from_u32(cp) {
result.push(c);
}
}
i += 4;
}
// Be permissive for non-standard one-byte destinations.
if bytes.len() == 1 {
let ch = bytes[0] as char;
if !ch.is_control() || ch == '\t' || ch == '\n' {
return Some(ch.to_string());
}
}
None
}
fn normalize_tounicode_destination(text: String) -> String {
let is_multi_char = text.chars().nth(1).is_some();
// Some malformed producer CMaps put a list of alternative whitespace or
// hyphen codepoints into one destination. Keep ordinary multi-character
// mappings intact unless that malformed signature is present.
if is_multi_char
&& text.chars().all(char::is_whitespace)
&& text.chars().any(|ch| matches!(ch, '\t' | '\n' | '\r'))
{
return if text.contains('\t') {
"\t".to_string()
} else {
" ".to_string()
};
}
if is_multi_char
&& text.contains('\u{00ad}')
&& text.chars().all(|ch| {
matches!(
ch,
'-' | '\u{00ad}' | '\u{2010}' | '\u{2011}' | '\u{2012}' | '\u{2013}' | '\u{2212}'
)
})
{
return "-".to_string();
}
text
}
fn hex_to_unicode_scalar(hex: &str) -> Option<u32> {
let text = hex_to_unicode_string(hex)?;
let mut chars = text.chars();
let ch = chars.next()?;
if chars.next().is_none() {
Some(ch as u32)
} else {
if result.is_empty() {
None
} else {
Some(result)
}
}
@@ -903,25 +820,6 @@ fn try_remap_subset_cmap(
None => return (cmap, None),
};
// Both repair paths below assume CIDs are glyph indices that a subsetter can
// renumber, which is only true for CIDFontType2 (TrueType). For CIDFontType0
// (CFF), CIDs are resolved through the CFF charset, so a valid CMap stays valid
// after subsetting and renumbering it corrupts otherwise-correct text.
// CIDToGIDMap is likewise CIDFontType2-only (PDF 32000-1:2008, 9.7.4.2), so this
// also ignores a CIDToGIDMap that a malformed producer attached to a CFF font.
// /Subtype may be an indirect reference, so resolve it through the document.
// Only bail out when the descendant is *explicitly* something other than
// CIDFontType2: a missing or unresolvable /Subtype keeps the previous
// behaviour rather than silently disabling the repair.
let subtype = cid_font_dict.get(b"Subtype").ok().and_then(|o| match o {
Object::Reference(r) => doc.get_object(*r).ok().and_then(|o| o.as_name().ok()),
other => other.as_name().ok(),
});
if subtype.is_some_and(|name| name != b"CIDFontType2") {
debug!("Subset remap skipped for obj={obj_num}: descendant is not CIDFontType2");
return (cmap, None);
}
// If there's an explicit CIDToGIDMap, build a repaired CMap using it.
if let Some(cid_to_gid) = get_cid_to_gid_map(cid_font_dict, doc) {
if let Some(repaired) = build_cmap_with_cid_to_gid_map(&cmap, &cid_to_gid) {
@@ -1197,7 +1095,9 @@ fn build_gid_to_unicode(face: &ttf_parser::Face<'_>) -> Option<HashMap<u16, char
/// Build a ToUnicodeCMap from pdf.js built-in binary CMaps (bcmaps).
fn build_cmap_from_builtin_cmap(ordering: &str) -> Option<ToUnicodeCMap> {
let name = format!("Adobe-{}-UCS2.bcmap", ordering);
let data = read_builtin_cmap_file(&name)?;
let dir = find_bcmaps_dir()?;
let path = dir.join(name);
let data = std::fs::read(&path).ok()?;
let mut cmap = parse_binary_cmap(&data).ok()?;
if cmap.char_map.is_empty() && cmap.ranges.is_empty() {
return None;
@@ -1205,14 +1105,13 @@ fn build_cmap_from_builtin_cmap(ordering: &str) -> Option<ToUnicodeCMap> {
cmap.code_byte_length = 2;
debug!(
"Built-in CMap {}: char_map={} ranges={}",
name,
path.display(),
cmap.char_map.len(),
cmap.ranges.len()
);
Some(cmap)
}
#[cfg(not(target_arch = "wasm32"))]
fn find_bcmaps_dir() -> Option<PathBuf> {
if let Ok(dir) = std::env::var("PDF_INSPECTOR_BCMAPS_DIR") {
let p = PathBuf::from(dir);
@@ -1229,18 +1128,6 @@ fn find_bcmaps_dir() -> Option<PathBuf> {
None
}
#[cfg(not(target_arch = "wasm32"))]
fn read_builtin_cmap_file(name: &str) -> Option<Cow<'static, [u8]>> {
let path = find_bcmaps_dir()?.join(name);
std::fs::read(path).ok().map(Cow::Owned)
}
#[cfg(target_arch = "wasm32")]
fn read_builtin_cmap_file(name: &str) -> Option<Cow<'static, [u8]>> {
let file = BUILTIN_CMAPS.get_file(name)?;
Some(Cow::Borrowed(file.contents()))
}
fn parse_binary_cmap(data: &[u8]) -> Result<ToUnicodeCMap, String> {
let mut stream = BinaryCMapStream::new(data);
let _header = stream.read_byte().ok_or("unexpected EOF in bcmap header")?;
@@ -1551,7 +1438,9 @@ fn parse_encoding_cmap_object(obj: &Object, doc: &Document) -> Option<EncodingCM
}
fn load_builtin_encoding_cmap(name: &str) -> Option<EncodingCMap> {
let data = read_builtin_cmap_file(&format!("{}.bcmap", name))?;
let dir = find_bcmaps_dir()?;
let path = dir.join(format!("{}.bcmap", name));
let data = std::fs::read(&path).ok()?;
parse_binary_cmap_encoding(&data).ok()
}
@@ -1584,31 +1473,23 @@ 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];
if !parse_cidchar_section(section, &mut map, &mut src_hex_lengths, &mut assigned) {
break;
}
parse_cidchar_section(section, &mut map, &mut src_hex_lengths);
pos = section_start + end;
} else {
break;
}
}
pos = 0;
while assigned < MAX_CID_W_EXPANSION {
let Some(start) = text[pos..].find("begincidrange") else {
break;
};
while let Some(start) = text[pos..].find("begincidrange") {
let section_start = pos + start + "begincidrange".len();
if let Some(end) = text[section_start..].find("endcidrange") {
let section = &text[section_start..section_start + end];
if !parse_cidrange_section(section, &mut map, &mut src_hex_lengths, &mut assigned) {
break;
}
parse_cidrange_section(section, &mut map, &mut src_hex_lengths);
pos = section_start + end;
} else {
break;
@@ -1643,8 +1524,7 @@ 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()) {
@@ -1675,20 +1555,16 @@ fn parse_cidchar_section(
}
}
if let (Some(code), Ok(cid)) = (parse_hex_u16(&src_hex), cid_str.parse::<u16>()) {
if !assign_encoding_cid(map, code, cid, assigned) {
return false;
}
map.insert(code, cid);
}
}
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()) {
@@ -1739,34 +1615,12 @@ fn parse_cidrange_section(
) else {
continue;
};
if start > end {
continue;
}
let mut cid = start_cid;
for code in start..=end {
if !assign_encoding_cid(map, code, cid, assigned) {
return false;
}
map.insert(code, cid);
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> {
@@ -1871,7 +1725,9 @@ fn load_builtin_cmap_by_name(name: &str) -> Option<ToUnicodeCMap> {
if !name.ends_with("UCS2") {
return None;
}
let data = read_builtin_cmap_file(&format!("{}.bcmap", name))?;
let dir = find_bcmaps_dir()?;
let path = dir.join(format!("{}.bcmap", name));
let data = std::fs::read(&path).ok()?;
let mut cmap = parse_binary_cmap(&data).ok()?;
if cmap.char_map.is_empty() && cmap.ranges.is_empty() {
return None;
@@ -1890,13 +1746,6 @@ 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.
///
@@ -1908,23 +1757,20 @@ 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].
// 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();
// 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();
let mut i = 0;
while i < w_arr.len() && seen.len() < MAX_CID_W_EXPANSION {
while i < w_arr.len() {
if let Ok(cid) = w_arr[i].as_i64() {
let start = cid as u16;
cids.push(cid as u16);
// Skip the width data
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 0..widths.len() {
if seen.len() >= MAX_CID_W_EXPANSION {
break;
}
seen.insert(start.wrapping_add(j as u16));
for j in 1..widths.len() {
cids.push((cid as u16).wrapping_add(j as u16));
}
i += 2;
}
@@ -1932,7 +1778,9 @@ 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() {
record_unique_cid_range(start, cid_end as u16, &mut seen);
for c in (cid as u16)..=(cid_end as u16) {
cids.push(c);
}
}
i += 3;
} else {
@@ -1941,7 +1789,6 @@ pub(crate) fn cid_values_look_like_unicode(cid_font_dict: &lopdf::Dictionary) ->
}
}
} else {
seen.insert(start);
i += 1;
}
} else {
@@ -1949,11 +1796,10 @@ pub(crate) fn cid_values_look_like_unicode(cid_font_dict: &lopdf::Dictionary) ->
}
}
if seen.is_empty() {
if cids.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+.
@@ -1962,18 +1808,6 @@ 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
@@ -2686,24 +2520,6 @@ endcmap
assert_eq!(cmap.lookup(0x0025), Some("B".to_string()));
}
#[test]
fn test_hex_to_unicode_non_ascii_no_panic() {
// A destination containing a multi-byte char makes the byte length even
// while a byte offset can land inside a char. Slicing must not panic;
// it should be rejected gracefully.
assert_eq!(hex_to_unicode_string("XéY"), None);
assert_eq!(hex_to_unicode_string("\u{fffd}0"), None);
}
#[test]
fn test_parse_bfchar_non_ascii_destination_no_panic() {
// Crafted /ToUnicode CMap: a non-hex, non-ASCII destination previously
// triggered a char-boundary panic in hex_to_unicode_string.
let cmap_content = "beginbfchar <0041> <XéY> endbfchar";
// Must not panic; the malformed entry is simply skipped.
let _ = ToUnicodeCMap::parse(cmap_content.as_bytes());
}
#[test]
fn test_parse_bfchar_1byte() {
// This is the pattern that caused the CJK bug: codespace is <0000><FFFF>
@@ -2791,97 +2607,6 @@ endbfrange
assert_eq!(cmap.lookup(0x0005), Some("C".to_string()));
}
#[test]
fn test_parse_bfchar_surrogate_pair_emoji() {
let cmap_content = r#"
1 begincodespacerange
<00> <FF>
endcodespacerange
2 beginbfchar
<16> <D83CDF1F>
<9D> <D83CDFAD>
endbfchar
"#;
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
assert_eq!(cmap.code_byte_length, 1);
assert_eq!(cmap.lookup(0x16), Some("🌟".to_string()));
assert_eq!(cmap.lookup(0x9D), Some("🎭".to_string()));
}
#[test]
fn test_parse_bfrange_surrogate_pair_base() {
let cmap_content = r#"
1 begincodespacerange
<00> <FF>
endcodespacerange
1 beginbfrange
<C8> <C9> <D83CDFD8>
endbfrange
"#;
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
assert_eq!(cmap.code_byte_length, 1);
assert_eq!(cmap.lookup(0xC8), Some("🏘".to_string()));
assert_eq!(cmap.lookup(0xC9), Some("🏙".to_string()));
}
#[test]
fn test_parse_bfrange_preserves_single_hyphen_like_base() {
let cmap_content = r#"
1 begincodespacerange
<00> <FF>
endcodespacerange
1 beginbfrange
<21> <22> <2013>
endbfrange
"#;
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
assert_eq!(cmap.lookup(0x21), Some("".to_string()));
assert_eq!(cmap.lookup(0x22), Some("".to_string()));
}
#[test]
fn test_parse_spaced_destination_hex_without_control_noise() {
let cmap_content = r#"
1 begincodespacerange
<00> <FF>
endcodespacerange
3 beginbfchar
<21> < 0009 000d 0020 00a0 >
<22> < 002d 00ad 2010 >
<23> <00a0>
endbfchar
"#;
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
assert_eq!(cmap.lookup(0x21), Some("\t".to_string()));
assert_eq!(cmap.lookup(0x22), Some("-".to_string()));
assert_eq!(cmap.lookup(0x23), Some("\u{00a0}".to_string()));
}
#[test]
fn test_parse_preserves_valid_multi_character_destinations() {
let cmap_content = r#"
1 begincodespacerange
<00> <FF>
endcodespacerange
4 beginbfchar
<21> <002d002d>
<22> <20132013>
<23> <002000a0>
<24> <00660069>
endbfchar
"#;
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
assert_eq!(cmap.lookup(0x21), Some("--".to_string()));
assert_eq!(cmap.lookup(0x22), Some("––".to_string()));
assert_eq!(cmap.lookup(0x23), Some(" \u{00a0}".to_string()));
assert_eq!(cmap.lookup(0x24), Some("fi".to_string()));
}
#[test]
fn test_remap_to_sequential() {
// Simulate a broken CMap where GIDs are from pre-subsetting:
@@ -2947,33 +2672,6 @@ 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#"
@@ -3303,181 +3001,4 @@ endbfrange
"Remap must fire when CMap's CIDs are outside W array coverage"
);
}
#[test]
fn test_try_remap_skipped_for_cid_font_type0() {
// Same W/CMap mismatch as the CIDFontType2 case above, but the descendant is
// CIDFontType0 (CFF). There CIDs are resolved through the CFF charset, so the
// ToUnicode CIDs stay valid after subsetting and must not be renumbered.
// Real-world case: Japanese Adobe-Japan1 PDFs (e.g. National Diet Library
// minutes) where remapping turned correct text into unrelated glyphs.
let cmap_content = r#"
1 begincodespacerange
<0000><FFFF>
endcodespacerange
1 beginbfrange
<0200> <0220> <0410>
endbfrange
"#;
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
let mut doc = Document::new();
// CIDToGIDMap is CIDFontType2-only, but a malformed producer can still emit
// one on a CFF font. Use a real stream (not /Identity, which is treated as
// "no map") so this also fails if the guard is moved back below the
// CIDToGIDMap branch: cid 1 -> gid 0x0200, which the CMap resolves.
let mut cid_to_gid = vec![0u8; 68];
cid_to_gid[2] = 0x02;
cid_to_gid[3] = 0x00;
let cid_to_gid_id =
doc.add_object(lopdf::Stream::new(lopdf::Dictionary::new(), cid_to_gid));
let mut cid_font = lopdf::Dictionary::new();
cid_font.set("Subtype", lopdf::Object::Name(b"CIDFontType0".to_vec()));
cid_font.set("CIDToGIDMap", lopdf::Object::Reference(cid_to_gid_id));
cid_font.set(
"W",
lopdf::Object::Array(vec![
lopdf::Object::Integer(1),
lopdf::Object::Array(vec![lopdf::Object::Integer(500); 34]),
]),
);
let cid_font_id = doc.add_object(cid_font);
let mut font_dict = lopdf::Dictionary::new();
font_dict.set("Encoding", lopdf::Object::Name(b"Identity-H".to_vec()));
font_dict.set(
"DescendantFonts",
lopdf::Object::Array(vec![lopdf::Object::Reference(cid_font_id)]),
);
let (primary, remapped) = try_remap_subset_cmap(cmap, &font_dict, &doc, 789);
assert!(
remapped.is_none(),
"Remap must be skipped for CIDFontType0 (CFF) descendants, including a \
CIDToGIDMap a malformed producer attached to one"
);
// The original CMap must still resolve its own CIDs.
assert_eq!(primary.lookup(0x0200), Some("\u{0410}".to_string()));
}
#[test]
fn test_try_remap_resolves_indirect_subtype() {
// /Subtype may be stored as an indirect reference. A genuine CIDFontType2
// font must still get the repair, so the guard has to dereference it rather
// than treat the unresolved value as "not CIDFontType2".
let cmap_content = r#"
1 begincodespacerange
<0000><FFFF>
endcodespacerange
1 beginbfrange
<0200> <0220> <0410>
endbfrange
"#;
let cmap = ToUnicodeCMap::parse(cmap_content.as_bytes()).unwrap();
let mut doc = Document::new();
let subtype_id = doc.add_object(lopdf::Object::Name(b"CIDFontType2".to_vec()));
let mut cid_font = lopdf::Dictionary::new();
cid_font.set("Subtype", lopdf::Object::Reference(subtype_id));
cid_font.set("CIDToGIDMap", lopdf::Object::Name(b"Identity".to_vec()));
cid_font.set(
"W",
lopdf::Object::Array(vec![
lopdf::Object::Integer(0),
lopdf::Object::Array(vec![lopdf::Object::Integer(500); 34]),
]),
);
let cid_font_id = doc.add_object(cid_font);
let mut font_dict = lopdf::Dictionary::new();
font_dict.set("Encoding", lopdf::Object::Name(b"Identity-H".to_vec()));
font_dict.set(
"DescendantFonts",
lopdf::Object::Array(vec![lopdf::Object::Reference(cid_font_id)]),
);
let (_primary, remapped) = try_remap_subset_cmap(cmap, &font_dict, &doc, 790);
assert!(
remapped.is_some(),
"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));
}
}
+7 -139
View File
@@ -116,14 +116,6 @@ pub struct TextItem {
pub is_bold: bool,
/// Whether the font is italic
pub is_italic: bool,
/// Whether the text is underlined (drawn rule/thin rect under the
/// baseline — PDFs have no underline font flag, so this is detected
/// geometrically after extraction; see `extractor::underline`).
pub is_underline: bool,
/// Whether the text is struck out (drawn rule/thin rect crossing the
/// glyphs at mid x-height). Same geometric detection as underline,
/// different vertical window; see `extractor::underline`.
pub is_strikeout: bool,
/// Type of item (text, image, link)
pub item_type: ItemType,
/// Marked Content ID from the content stream's BDC/BMC operator.
@@ -145,20 +137,12 @@ pub struct TextLine {
impl TextLine {
pub fn text(&self) -> String {
self.text_with_formatting(false, false, false)
self.text_with_formatting(false, false)
}
/// Get text with optional bold/italic/decorative markdown formatting.
///
/// `format_decorations` enables both geometrically detected source
/// decorations: underline (`<u>`) and strikeout (`<s>`).
pub fn text_with_formatting(
&self,
format_bold: bool,
format_italic: bool,
format_decorations: bool,
) -> String {
if !format_bold && !format_italic && !format_decorations {
/// Get text with optional bold/italic markdown formatting
pub fn text_with_formatting(&self, format_bold: bool, format_italic: bool) -> String {
if !format_bold && !format_italic {
return self.text_plain();
}
@@ -167,8 +151,6 @@ impl TextLine {
let mut result = String::new();
let mut current_bold = false;
let mut current_italic = false;
let mut current_underline = false;
let mut current_strikeout = false;
for (i, item) in self.items.iter().enumerate() {
let text = item.text.as_str();
@@ -194,16 +176,9 @@ impl TextLine {
// we push text_trimmed below (which strips it).
let has_leading_space = text.starts_with(' ');
// Check for style changes. Source decorations are exclusive:
// `<u>`/`<s>` content stays free of `**`/`*` markers — consumers
// (and the eval harnesses this feeds) match tag content literally,
// and mixed nesting breaks that. A struck-and-underlined item is
// emitted as struck text because deletion is the stronger semantic
// distinction in redline documents.
let item_strikeout = format_decorations && item.is_strikeout;
let item_underline = format_decorations && item.is_underline && !item_strikeout;
let item_bold = format_bold && item.is_bold && !item_underline && !item_strikeout;
let item_italic = format_italic && item.is_italic && !item_underline && !item_strikeout;
// Check for style changes
let item_bold = format_bold && item.is_bold;
let item_italic = format_italic && item.is_italic;
// Close previous styles if they change
if current_italic && !item_italic {
@@ -214,14 +189,6 @@ impl TextLine {
result.push_str("**");
current_bold = false;
}
if current_underline && !item_underline {
result.push_str("</u>");
current_underline = false;
}
if current_strikeout && !item_strikeout {
result.push_str("</s>");
current_strikeout = false;
}
// Add space: either from spacing logic or preserved from item text
if needs_space || (has_leading_space && !result.is_empty() && !result.ends_with(' ')) {
@@ -229,14 +196,6 @@ impl TextLine {
}
// Open new styles
if item_underline && !current_underline {
result.push_str("<u>");
current_underline = true;
}
if item_strikeout && !current_strikeout {
result.push_str("<s>");
current_strikeout = true;
}
if item_bold && !current_bold {
result.push_str("**");
current_bold = true;
@@ -256,12 +215,6 @@ impl TextLine {
if current_bold {
result.push_str("**");
}
if current_underline {
result.push_str("</u>");
}
if current_strikeout {
result.push_str("</s>");
}
result
}
@@ -327,88 +280,3 @@ impl TextLine {
|| space_already_exists)
}
}
#[cfg(test)]
mod formatting_tests {
use super::{ItemType, TextItem, TextLine};
fn item(text: &str, x: f32, width: f32, strikeout: bool) -> TextItem {
TextItem {
text: text.to_string(),
x,
y: 100.0,
width,
height: 12.0,
font: "F1".to_string(),
font_size: 12.0,
page: 1,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: strikeout,
item_type: ItemType::Text,
mcid: None,
}
}
fn line(items: Vec<TextItem>) -> TextLine {
TextLine {
items,
y: 100.0,
page: 1,
adaptive_threshold: 0.1,
}
}
#[test]
fn formatting_emits_semantic_strikeout() {
let line = line(vec![item("deleted", 10.0, 42.0, true)]);
assert_eq!(
line.text_with_formatting(true, true, true),
"<s>deleted</s>"
);
}
#[test]
fn formatting_closes_strikeout_before_live_text() {
let line = line(vec![
item("keep", 10.0, 24.0, false),
item("remove", 40.0, 42.0, true),
item("keep", 88.0, 24.0, false),
]);
assert_eq!(
line.text_with_formatting(true, true, true),
"keep <s>remove</s> keep"
);
}
#[test]
fn formatting_coalesces_adjacent_struck_items() {
let line = line(vec![
item("deleted", 10.0, 42.0, true),
item("words", 58.0, 30.0, true),
]);
assert_eq!(
line.text_with_formatting(true, true, true),
"<s>deleted words</s>"
);
}
#[test]
fn strikeout_takes_precedence_over_other_styles() {
let mut decorated = item("deleted", 10.0, 42.0, true);
decorated.is_bold = true;
decorated.is_italic = true;
decorated.is_underline = true;
let line = line(vec![decorated]);
assert_eq!(
line.text_with_formatting(true, true, true),
"<s>deleted</s>"
);
assert_eq!(line.text(), "deleted");
}
}
-414
View File
@@ -1,414 +0,0 @@
//! Public contracts between rendering, OCR, layout, 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,
}
/// Resource/quality profile for the OCR engine.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum OcrProfile {
/// Lowest latency and memory footprint.
Edge,
/// OCR-oriented balance of quality and CPU cost.
#[default]
Balanced,
/// Highest quality within the lightweight model family.
Quality,
}
/// Controls whether missing model artifacts may be fetched.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
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,
/// Local quality/resource profile.
pub profile: OcrProfile,
/// Drop recognition spans below this confidence threshold.
pub minimum_confidence: f32,
/// Optional language hints understood by the selected engine.
pub languages: Vec<String>,
/// Optional directory containing an offline model set.
pub model_directory: Option<PathBuf>,
/// Whether a missing pinned artifact may be downloaded.
pub model_downloads: ModelDownloadPolicy,
}
impl Default for OcrOptions {
fn default() -> Self {
Self {
mode: OcrMode::Off,
profile: OcrProfile::Balanced,
minimum_confidence: 0.0,
languages: Vec::new(),
model_directory: None,
model_downloads: ModelDownloadPolicy::IfMissing,
}
}
}
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 local resource/quality profile.
pub fn profile(mut self, profile: OcrProfile) -> Self {
self.profile = profile;
self
}
/// Sets the minimum accepted recognition confidence.
pub fn minimum_confidence(mut self, minimum_confidence: f32) -> Self {
self.minimum_confidence = minimum_confidence;
self
}
/// Replaces the language hints passed to the OCR engine.
pub fn languages(mut self, languages: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.languages = languages.into_iter().map(Into::into).collect();
self
}
/// Uses an explicit model directory, suitable for offline packaging.
pub fn model_directory(mut self, directory: impl Into<PathBuf>) -> Self {
self.model_directory = Some(directory.into());
self
}
/// Sets the missing-model download policy.
pub fn model_downloads(mut self, policy: ModelDownloadPolicy) -> Self {
self.model_downloads = policy;
self
}
}
/// Configuration for an optional learned layout engine.
///
/// Layout inference is disabled by default. Existing deterministic layout,
/// table, and Markdown logic remains the assembly path when this is disabled.
#[derive(Debug, Clone, PartialEq)]
pub struct LayoutOptions {
/// Whether the learned layout extension may run.
pub enabled: bool,
/// Drop layout regions below this confidence threshold.
pub minimum_confidence: f32,
/// Optional directory containing an offline layout model set.
pub model_directory: Option<PathBuf>,
}
impl Default for LayoutOptions {
fn default() -> Self {
Self {
enabled: false,
minimum_confidence: 0.0,
model_directory: None,
}
}
}
impl LayoutOptions {
/// Creates layout options with learned layout disabled.
pub fn new() -> Self {
Self::default()
}
/// Enables or disables learned layout inference.
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
/// Sets the minimum accepted region confidence.
pub fn minimum_confidence(mut self, minimum_confidence: f32) -> Self {
self.minimum_confidence = minimum_confidence;
self
}
/// Uses an explicit layout model directory.
pub fn model_directory(mut self, directory: impl Into<PathBuf>) -> Self {
self.model_directory = Some(directory.into());
self
}
}
/// A point in bitmap space, measured from the top-left in pixels.
#[derive(Debug, Clone, Copy, Default, PartialEq)]
pub struct ImagePoint {
/// 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 01.
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: 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>,
}
/// Normalized semantic class emitted by a learned layout engine.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum LayoutRegionKind {
/// Body or other prose text.
Text,
/// Document heading or title.
Heading,
/// Table region.
Table,
/// Figure/image region.
Figure,
/// Figure or table caption.
Caption,
/// Header/footer/page furniture.
Furniture,
/// Model-specific class retained without changing the common taxonomy.
Other(String),
}
/// One learned layout region in bitmap coordinates.
#[derive(Debug, Clone, PartialEq)]
pub struct LayoutRegion {
/// Normalized semantic class.
pub kind: LayoutRegionKind,
/// Region polygon in the original rendered page's pixel space.
pub polygon: ImageQuad,
/// Model confidence in the inclusive range 01.
pub confidence: f32,
/// Optional model-provided reading-order position.
pub reading_order: Option<u32>,
}
/// Learned layout output for one 1-indexed page.
#[derive(Debug, Clone, PartialEq)]
pub struct LayoutPage {
/// 1-indexed PDF page number.
pub page: u32,
/// Semantic regions.
pub regions: Vec<LayoutRegion>,
/// Exact model identity used for this result.
pub model: ModelIdentity,
/// Layout inference wall time for this page.
pub processing_time_ms: u64,
/// Non-fatal engine warnings.
pub warnings: Vec<String>,
}
/// How final page content was sourced.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
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,
/// Optional learned layout wall time.
pub layout_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: u32,
/// Final page-content source.
pub source: PageContentSource,
/// OCR model, when OCR ran.
pub ocr_model: Option<ModelIdentity>,
/// Learned layout model, when layout inference ran.
pub layout_model: Option<ModelIdentity>,
/// Render resolution used for local vision.
pub render_dpi: Option<f32>,
/// Mean accepted OCR confidence, when available.
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>;
}
/// Optional learned semantic layout extension.
pub trait LayoutEngine: Send + Sync {
/// Engine-specific failure type.
type Error: Error + Send + Sync + 'static;
/// Exact model identity used by this engine instance.
fn model(&self) -> &ModelIdentity;
/// Analyzes rendered pages, optionally using their OCR spans.
fn analyze(
&self,
pages: &[RenderedPage],
ocr: &[OcrPage],
options: &LayoutOptions,
) -> Result<Vec<LayoutPage>, Self::Error>;
}
#[cfg(test)]
mod tests {
use super::*;
#[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"))
);
}
}
-84
View File
@@ -1,84 +0,0 @@
//! HTTPS acquisition for pinned local model artifacts.
use std::fmt;
use std::io::Read;
use std::time::Duration;
use thiserror::Error;
use super::{ModelArtifact, ModelDownloader};
/// Default end-to-end timeout for one model artifact request.
pub const DEFAULT_MODEL_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(5 * 60);
/// Streaming HTTPS downloader used by lazy model resolution.
#[derive(Clone)]
pub struct HttpModelDownloader {
agent: ureq::Agent,
}
impl fmt::Debug for HttpModelDownloader {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("HttpModelDownloader")
.finish_non_exhaustive()
}
}
impl Default for HttpModelDownloader {
fn default() -> Self {
Self::new(DEFAULT_MODEL_DOWNLOAD_TIMEOUT)
}
}
impl HttpModelDownloader {
/// Creates an HTTPS-only downloader with an end-to-end request timeout.
pub fn new(timeout: Duration) -> Self {
let config = ureq::Agent::config_builder()
.https_only(true)
.timeout_global(Some(timeout))
.user_agent(concat!("pdf-inspector/", env!("CARGO_PKG_VERSION")))
.build();
Self {
agent: ureq::Agent::new_with_config(config),
}
}
}
impl ModelDownloader for HttpModelDownloader {
type Error = HttpModelDownloadError;
fn open(&self, artifact: &ModelArtifact) -> Result<Box<dyn Read + Send>, Self::Error> {
let response = self.agent.get(artifact.url).call()?;
if let Some(actual) = response.body().content_length() {
if actual != artifact.size {
return Err(HttpModelDownloadError::ContentLength {
expected: artifact.size,
actual,
});
}
}
// One extra byte lets ModelStore report an exact size mismatch while
// preventing a malicious or broken server from filling the disk.
let limit = artifact.size.saturating_add(1);
Ok(Box::new(response.into_body().into_reader().take(limit)))
}
}
/// Failures before a response stream reaches [`super::ModelStore`].
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum HttpModelDownloadError {
/// DNS, TLS, redirect, HTTP status, or response-stream setup failed.
#[error(transparent)]
Request(#[from] ureq::Error),
/// The server declared a size that disagrees with the pinned manifest.
#[error("server declared {actual} bytes; manifest requires {expected}")]
ContentLength {
/// Pinned artifact size.
expected: u64,
/// Server-declared size.
actual: u64,
},
}
-797
View File
@@ -1,797 +0,0 @@
//! Geometry-aware OCR Markdown assembly and native-text fusion.
use std::collections::{BTreeMap, BTreeSet};
use std::time::Instant;
use thiserror::Error;
use crate::markdown::{to_markdown_from_items_with_rects_and_page_count, MarkdownOptions};
use crate::types::{ItemType, TextItem};
use crate::PageMarkdown;
use super::{
OcrRun, PageContentSource, PageProvenance, RoutedOcrPage, VisionTimings, DEFAULT_RENDER_DPI,
};
/// OCR assembly and hosted-fallback policy.
#[derive(Debug, Clone)]
pub struct OcrFusionOptions {
/// Markdown conversion options applied to positioned OCR spans.
pub markdown: MarkdownOptions,
/// Render resolution recorded in page provenance.
pub render_dpi: f32,
/// Recommend the hosted pipeline below this mean confidence when native
/// extraction already marked the page as requiring OCR.
pub hosted_recommendation_confidence: f32,
}
impl Default for OcrFusionOptions {
fn default() -> Self {
Self {
markdown: MarkdownOptions {
include_page_numbers: false,
strip_headers_footers: false,
..MarkdownOptions::default()
},
render_dpi: DEFAULT_RENDER_DPI,
hosted_recommendation_confidence: 0.5,
}
}
}
impl OcrFusionOptions {
/// Creates OCR fusion options with local defaults.
pub fn new() -> Self {
Self::default()
}
/// Replaces Markdown conversion options.
pub fn markdown(mut self, markdown: MarkdownOptions) -> Self {
self.markdown = markdown;
self
}
/// Records the renderer resolution in provenance.
pub fn render_dpi(mut self, render_dpi: f32) -> Self {
self.render_dpi = render_dpi;
self
}
/// Sets the weak-OCR confidence threshold for recommending hosted parsing.
pub fn hosted_recommendation_confidence(mut self, confidence: f32) -> Self {
self.hosted_recommendation_confidence = confidence;
self
}
/// Validates rendering and hosted-fallback thresholds without doing work.
pub fn validate(&self) -> Result<(), OcrFusionError> {
validate_options(self)
}
}
/// Final Markdown and provenance for one page.
#[derive(Debug, Clone, PartialEq)]
pub struct FusedPageMarkdown {
/// 1-indexed document page number, matching OCR and provenance fields.
pub page: u32,
/// Final page Markdown.
pub markdown: String,
/// Native/OCR source, model, timing, and fallback metadata.
pub provenance: PageProvenance,
}
/// Output of fusing a selective OCR run into native page extraction.
#[derive(Debug, Clone, PartialEq)]
pub struct FusedPages {
/// Pages in the same order as the native input.
pub pages: Vec<FusedPageMarkdown>,
/// Batch rendering wall time from the OCR run.
pub render_time_ms: u64,
/// Batch OCR wall time from the OCR run.
pub ocr_time_ms: u64,
}
/// Converts positioned OCR spans to Markdown through pdf-inspector's existing
/// deterministic geometry, reading-order, table, and Markdown pipeline.
///
/// The page number is 1-indexed. `document_page_count` prevents a selected
/// page from being mistaken for a one-page document during page-number logic.
pub fn ocr_page_to_markdown(
page: &RoutedOcrPage,
document_page_count: u32,
options: &MarkdownOptions,
) -> String {
let (items, _) = ocr_text_items(page);
to_markdown_from_items_with_rects_and_page_count(
items,
options.clone(),
&[],
document_page_count,
)
}
/// Fuses a selective OCR run into per-page native Markdown.
///
/// OCR replaces pages whose native extraction was already rejected. On clean
/// native pages (for example in `Force` mode), normalized duplicate OCR blocks
/// are removed and only genuinely additional blocks are appended. Pages that
/// needed OCR but still have no credible local result recommend the hosted
/// document pipeline instead of silently presenting an empty result as final.
pub fn fuse_ocr_pages(
native_pages: &[PageMarkdown],
ocr_run: &OcrRun,
document_page_count: u32,
options: &OcrFusionOptions,
) -> Result<FusedPages, OcrFusionError> {
validate_options(options)?;
let mut native_numbers = BTreeSet::new();
for page in native_pages {
let page_number = page
.page
.checked_add(1)
.ok_or(OcrFusionError::PageOverflow)?;
if !native_numbers.insert(page_number) {
return Err(OcrFusionError::DuplicateNativePage { page: page_number });
}
}
let mut ocr_by_page = BTreeMap::new();
for page in &ocr_run.pages {
let page_number = page.rendered.page();
if !native_numbers.contains(&page_number) {
return Err(OcrFusionError::UnexpectedOcrPage { page: page_number });
}
if ocr_by_page.insert(page_number, page).is_some() {
return Err(OcrFusionError::DuplicateOcrPage { page: page_number });
}
}
let render_shares = equal_time_shares(ocr_run.render_time_ms, ocr_run.pages.len());
let render_by_page: BTreeMap<u32, u64> = ocr_run
.pages
.iter()
.zip(render_shares)
.map(|(page, share)| (page.rendered.page(), share))
.collect();
let mut pages = Vec::with_capacity(native_pages.len());
for native in native_pages {
let page_number = native.page + 1;
let assembly_started = Instant::now();
let mut warnings = Vec::new();
let (markdown, source, ocr_model, ocr_confidence, ocr_ms, hosted_recommended) =
if let Some(local) = ocr_by_page.get(&page_number) {
let (ocr_items, discarded_spans) = ocr_text_items(local);
if discarded_spans > 0 {
warnings.push(format!(
"discarded {discarded_spans} OCR spans with unusable text or geometry"
));
}
warnings.extend(local.ocr.warnings.iter().cloned());
let ocr_markdown = to_markdown_from_items_with_rects_and_page_count(
ocr_items,
options.markdown.clone(),
&[],
document_page_count,
);
let (markdown, source) = if native.markdown.trim().is_empty() || native.needs_ocr {
(ocr_markdown, PageContentSource::Ocr)
} else {
merge_native_and_ocr(&native.markdown, &ocr_markdown)
};
let weak_ocr = local
.ocr
.mean_confidence
.is_none_or(|confidence| confidence < options.hosted_recommendation_confidence);
let recommend_hosted = native.needs_ocr && (markdown.trim().is_empty() || weak_ocr);
if native.needs_ocr && markdown.trim().is_empty() {
warnings.push("OCR produced no usable text".to_string());
}
(
markdown,
source,
Some(local.ocr.model.clone()),
local.ocr.mean_confidence,
local.ocr.processing_time_ms,
recommend_hosted,
)
} else {
if native.needs_ocr {
warnings.push("page was recommended for OCR but was not processed".to_string());
}
(
native.markdown.clone(),
PageContentSource::Native,
None,
None,
0,
native.needs_ocr,
)
};
pages.push(FusedPageMarkdown {
page: page_number,
markdown,
provenance: PageProvenance {
page: page_number,
source,
ocr_model,
layout_model: None,
render_dpi: ocr_by_page
.contains_key(&page_number)
.then_some(options.render_dpi),
ocr_confidence,
timings: VisionTimings {
render_ms: render_by_page.get(&page_number).copied().unwrap_or(0),
ocr_ms,
layout_ms: 0,
assembly_ms: elapsed_ms(assembly_started),
},
warnings,
hosted_recommended,
},
});
}
Ok(FusedPages {
pages,
render_time_ms: ocr_run.render_time_ms,
ocr_time_ms: ocr_run.ocr_time_ms,
})
}
/// Converts recognized line polygons to ordinary PDF-space text items.
fn ocr_text_items(page: &RoutedOcrPage) -> (Vec<TextItem>, usize) {
let mut discarded = 0usize;
let mut items = Vec::with_capacity(page.ocr.spans.len());
for span in &page.ocr.spans {
if span.text.trim().is_empty() {
discarded += 1;
continue;
}
let Some((left, top, right, bottom)) = image_quad_bounds(
&span.polygon.points,
page.rendered.width(),
page.rendered.height(),
) else {
discarded += 1;
continue;
};
let rect = page.rendered.pixel_rect_to_pdf_rect(
f64::from(left),
f64::from(top),
f64::from(right - left),
f64::from(bottom - top),
);
items.push(TextItem {
text: span.text.trim().to_string(),
x: rect.x,
y: rect.y,
width: rect.width,
height: rect.height,
font: "OCR".to_string(),
font_size: rect.height.max(1.0),
page: page.rendered.page(),
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: ItemType::Text,
mcid: None,
});
}
// OCR engines do not share an ordering contract. Geometry gives the
// deterministic top-to-bottom seed expected by the existing layout
// pipeline, which can still replace it with column-aware reading order.
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, discarded)
}
fn image_quad_bounds(
points: &[super::ImagePoint; 4],
width: u32,
height: u32,
) -> Option<(f32, f32, f32, f32)> {
if points
.iter()
.any(|point| !point.x.is_finite() || !point.y.is_finite())
{
return None;
}
let left = points
.iter()
.map(|point| point.x)
.fold(f32::INFINITY, f32::min)
.clamp(0.0, width as f32);
let right = points
.iter()
.map(|point| point.x)
.fold(f32::NEG_INFINITY, f32::max)
.clamp(0.0, width as f32);
let top = points
.iter()
.map(|point| point.y)
.fold(f32::INFINITY, f32::min)
.clamp(0.0, height as f32);
let bottom = points
.iter()
.map(|point| point.y)
.fold(f32::NEG_INFINITY, f32::max)
.clamp(0.0, height as f32);
(right > left && bottom > top).then_some((left, top, right, bottom))
}
fn merge_native_and_ocr(native: &str, ocr: &str) -> (String, PageContentSource) {
let native_keys = comparison_units(native);
let mut addition_keys = Vec::new();
let mut additions: Vec<String> = Vec::new();
for block in markdown_blocks(ocr) {
let key = ComparisonKey::new(block);
if key.is_empty()
|| native_keys.iter().any(|native| native.same_content(&key))
|| addition_keys
.iter()
.any(|existing: &ComparisonKey| existing.same_content(&key))
{
continue;
}
let addition = if let Some(native) = best_partial_overlap(&key, &native_keys) {
let Some(novel) = novel_fragments(block, native) else {
continue;
};
novel
} else {
block.trim_end().to_string()
};
let addition_key = ComparisonKey::new(&addition);
if addition_key.is_empty()
|| native_keys
.iter()
.any(|native| native.same_content(&addition_key))
|| addition_keys
.iter()
.any(|existing: &ComparisonKey| existing.same_content(&addition_key))
{
continue;
}
addition_keys.push(addition_key);
additions.push(addition);
}
if additions.is_empty() {
(ensure_trailing_newline(native), PageContentSource::Native)
} else {
let mut result = native.trim_end_matches('\n').to_string();
if !result.is_empty() {
result.push_str("\n\n");
}
result.push_str(&additions.join("\n\n"));
result.push('\n');
(result, PageContentSource::Fused)
}
}
fn markdown_blocks(markdown: &str) -> impl Iterator<Item = &str> {
markdown
.split("\n\n")
.map(str::trim_end)
.filter(|block| !block.trim().is_empty())
}
#[derive(Debug)]
struct ComparisonKey {
tokens: Vec<String>,
punctuation: String,
}
impl ComparisonKey {
fn new(text: &str) -> Self {
let mut tokens = Vec::new();
let mut token = String::new();
let mut punctuation = String::new();
for character in text.chars().flat_map(char::to_lowercase) {
if character.is_alphanumeric() {
token.push(character);
} else {
if !token.is_empty() {
tokens.push(std::mem::take(&mut token));
}
if !character.is_whitespace() {
punctuation.push(character);
}
}
}
if !token.is_empty() {
tokens.push(token);
}
Self {
tokens,
punctuation,
}
}
fn is_empty(&self) -> bool {
self.tokens.is_empty() && self.punctuation.is_empty()
}
fn same_content(&self, other: &Self) -> bool {
if self.tokens.is_empty() || other.tokens.is_empty() {
self.tokens.is_empty()
&& other.tokens.is_empty()
&& self.punctuation == other.punctuation
} else {
token_counts(&self.tokens) == token_counts(&other.tokens)
}
}
}
fn comparison_units(markdown: &str) -> Vec<ComparisonKey> {
let mut units: Vec<_> = markdown_blocks(markdown).map(ComparisonKey::new).collect();
units.extend(
markdown
.lines()
.filter(|line| !line.trim().is_empty())
.map(ComparisonKey::new),
);
units
}
fn token_counts(tokens: &[String]) -> BTreeMap<&str, usize> {
let mut counts = BTreeMap::new();
for token in tokens {
*counts.entry(token.as_str()).or_insert(0) += 1;
}
counts
}
fn best_partial_overlap<'a>(
ocr: &ComparisonKey,
native: &'a [ComparisonKey],
) -> Option<&'a ComparisonKey> {
if ocr.tokens.len() < 2 {
return None;
}
native
.iter()
.filter(|key| !key.tokens.is_empty())
.filter_map(|key| {
let overlap = token_overlap(&ocr.tokens, &key.tokens);
let substantial =
overlap >= 2 && overlap * 2 >= ocr.tokens.len() && overlap * 2 >= key.tokens.len();
substantial.then_some((overlap, key))
})
.max_by_key(|(overlap, _)| *overlap)
.map(|(_, key)| key)
}
fn token_overlap(first: &[String], second: &[String]) -> usize {
let first = token_counts(first);
let second = token_counts(second);
first
.iter()
.map(|(token, count)| (*count).min(second.get(token).copied().unwrap_or(0)))
.sum()
}
fn novel_fragments(block: &str, native: &ComparisonKey) -> Option<String> {
let mut available = token_counts(&native.tokens);
let mut novel = Vec::new();
for fragment in block.split_whitespace() {
let key = ComparisonKey::new(fragment);
let mut adds_content = false;
for token in &key.tokens {
match available.get_mut(token.as_str()) {
Some(count) if *count > 0 => *count -= 1,
_ => adds_content = true,
}
}
if adds_content {
novel.push(fragment);
}
}
(!novel.is_empty()).then(|| novel.join(" "))
}
fn ensure_trailing_newline(markdown: &str) -> String {
let mut result = markdown.trim_end_matches('\n').to_string();
result.push('\n');
result
}
fn equal_time_shares(total: u64, count: usize) -> Vec<u64> {
if count == 0 {
return Vec::new();
}
let count = count as u64;
let base = total / count;
let remainder = total % count;
(0..count)
.map(|index| base + u64::from(index < remainder))
.collect()
}
fn validate_options(options: &OcrFusionOptions) -> Result<(), OcrFusionError> {
if !options.render_dpi.is_finite() || options.render_dpi <= 0.0 {
return Err(OcrFusionError::InvalidRenderDpi {
value: options.render_dpi,
});
}
let confidence = options.hosted_recommendation_confidence;
if !confidence.is_finite() || !(0.0..=1.0).contains(&confidence) {
return Err(OcrFusionError::InvalidHostedConfidence { value: confidence });
}
Ok(())
}
fn elapsed_ms(started: Instant) -> u64 {
u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
}
/// Invalid page sets or fusion options.
#[derive(Debug, Error, PartialEq)]
#[non_exhaustive]
pub enum OcrFusionError {
/// A 0-indexed native page could not be converted to 1-indexed form.
#[error("native page number cannot be converted to a 1-indexed page")]
PageOverflow,
/// Native input repeated a page number.
#[error("native page {page} appears more than once")]
DuplicateNativePage {
/// Repeated 1-indexed page number.
page: u32,
},
/// OCR input repeated a page number.
#[error("OCR page {page} appears more than once")]
DuplicateOcrPage {
/// Repeated 1-indexed page number.
page: u32,
},
/// OCR returned a page that was not part of native extraction.
#[error("OCR page {page} is not present in native page extraction")]
UnexpectedOcrPage {
/// Unexpected 1-indexed page number.
page: u32,
},
/// Render resolution is non-finite or non-positive.
#[error("render DPI must be positive and finite, got {value}")]
InvalidRenderDpi {
/// Invalid value.
value: f32,
},
/// Hosted recommendation confidence is outside 01.
#[error("hosted recommendation confidence must be between 0 and 1, got {value}")]
InvalidHostedConfidence {
/// Invalid value.
value: f32,
},
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vision::{
ImagePoint, ImageQuad, ModelIdentity, OcrPage, OcrSpan, PageTransform, RenderPixelFormat,
RenderedPage,
};
fn native(page: u32, markdown: &str, needs_ocr: bool) -> PageMarkdown {
PageMarkdown {
page,
markdown: markdown.to_string(),
needs_ocr,
ocr_reason: needs_ocr.then(|| "scanned".to_string()),
}
}
fn rendered_page(page: u32) -> RenderedPage {
let transform =
PageTransform::from_corners(200, 100, (0.0, 100.0), (200.0, 100.0), (0.0, 0.0))
.unwrap();
RenderedPage::new(
page,
200.0,
100.0,
200,
100,
600,
RenderPixelFormat::Rgb8,
vec![255; 60_000],
transform,
)
.unwrap()
}
fn span(text: &str, top: f32, confidence: f32) -> OcrSpan {
OcrSpan {
text: text.to_string(),
polygon: ImageQuad::new([
ImagePoint::new(10.0, top),
ImagePoint::new(190.0, top),
ImagePoint::new(190.0, top + 10.0),
ImagePoint::new(10.0, top + 10.0),
]),
confidence,
orientation_degrees: None,
}
}
fn routed_page(page: u32, spans: Vec<OcrSpan>, confidence: Option<f32>) -> RoutedOcrPage {
RoutedOcrPage {
rendered: rendered_page(page),
ocr: OcrPage {
page,
spans,
mean_confidence: confidence,
model: ModelIdentity::new("test-ocr", "v1"),
processing_time_ms: 7,
warnings: Vec::new(),
},
}
}
fn run(pages: Vec<RoutedOcrPage>) -> OcrRun {
OcrRun {
pages,
render_time_ms: 5,
ocr_time_ms: 7,
}
}
#[test]
fn scanned_page_uses_geometry_ordered_ocr_and_provenance() {
let native = [native(0, "", true)];
let run = run(vec![routed_page(
1,
vec![
span("Second line", 30.0, 0.9),
span("First line", 10.0, 0.9),
],
Some(0.9),
)]);
let result = fuse_ocr_pages(&native, &run, 1, &OcrFusionOptions::new()).unwrap();
assert!(
result.pages[0].markdown.find("First").unwrap()
< result.pages[0].markdown.find("Second").unwrap()
);
assert_eq!(result.pages[0].provenance.source, PageContentSource::Ocr);
assert_eq!(result.pages[0].page, 1);
assert_eq!(result.pages[0].page, result.pages[0].provenance.page);
assert_eq!(
result.pages[0].provenance.ocr_model.as_ref().unwrap().name,
"test-ocr"
);
assert!(!result.pages[0].provenance.hosted_recommended);
}
#[test]
fn force_mode_deduplicates_native_content() {
let native = [native(0, "Hello, world!\n", false)];
let run = run(vec![routed_page(
1,
vec![span("Hello world", 10.0, 0.9)],
Some(0.9),
)]);
let result = fuse_ocr_pages(&native, &run, 1, &OcrFusionOptions::new()).unwrap();
assert_eq!(result.pages[0].markdown, "Hello, world!\n");
assert_eq!(result.pages[0].provenance.source, PageContentSource::Native);
}
#[test]
fn force_mode_appends_only_additional_ocr_blocks() {
let native = [native(0, "Native title\n", false)];
let run = run(vec![routed_page(
1,
vec![
span("Native title", 10.0, 0.9),
span("Image-only label", 30.0, 0.9),
],
Some(0.9),
)]);
let result = fuse_ocr_pages(&native, &run, 1, &OcrFusionOptions::new()).unwrap();
assert_eq!(result.pages[0].provenance.source, PageContentSource::Fused);
assert_eq!(result.pages[0].markdown.matches("Native title").count(), 1);
assert!(result.pages[0].markdown.contains("Image-only label"));
}
#[test]
fn force_mode_preserves_punctuation_and_short_distinct_blocks() {
let (markdown, source) = merge_native_and_ocr("Figure 1\n", "1\n\n***");
assert_eq!(source, PageContentSource::Fused);
assert_eq!(markdown, "Figure 1\n\n1\n\n***\n");
}
#[test]
fn force_mode_extracts_only_novel_tokens_from_overlapping_blocks() {
let (markdown, source) = merge_native_and_ocr("A C\n", "## A B C");
assert_eq!(source, PageContentSource::Fused);
assert_eq!(markdown, "A C\n\nB\n");
assert_eq!(markdown.matches('A').count(), 1);
assert_eq!(markdown.matches('C').count(), 1);
}
#[test]
fn force_mode_deduplicates_reordered_tokens() {
let native = [native(0, "Alpha Beta Gamma\n", false)];
let run = run(vec![routed_page(
1,
vec![span("Gamma Alpha Beta", 10.0, 0.9)],
Some(0.9),
)]);
let result = fuse_ocr_pages(&native, &run, 1, &OcrFusionOptions::new()).unwrap();
assert_eq!(result.pages[0].provenance.source, PageContentSource::Native);
assert_eq!(result.pages[0].markdown, "Alpha Beta Gamma\n");
}
#[test]
fn fusion_preserves_native_leading_indentation() {
let native = [native(0, " indented code\n", false)];
let duplicate = run(vec![routed_page(
1,
vec![span("indented code", 10.0, 0.9)],
Some(0.9),
)]);
let result = fuse_ocr_pages(&native, &duplicate, 1, &OcrFusionOptions::new()).unwrap();
assert_eq!(result.pages[0].markdown, " indented code\n");
let addition = run(vec![routed_page(
1,
vec![span("image label", 10.0, 0.9)],
Some(0.9),
)]);
let result = fuse_ocr_pages(&native, &addition, 1, &OcrFusionOptions::new()).unwrap();
assert_eq!(
result.pages[0].markdown,
" indented code\n\nimage label\n"
);
}
#[test]
fn missing_or_weak_required_ocr_recommends_hosted() {
let native = [native(0, "", true), native(1, "", true)];
let run = run(vec![routed_page(
2,
vec![span("uncertain", 10.0, 0.3)],
Some(0.3),
)]);
let result = fuse_ocr_pages(&native, &run, 2, &OcrFusionOptions::new()).unwrap();
assert!(result.pages[0].provenance.hosted_recommended);
assert!(result.pages[1].provenance.hosted_recommended);
}
#[test]
fn rejects_ocr_pages_outside_native_selection() {
let error = fuse_ocr_pages(
&[native(0, "text", false)],
&run(vec![routed_page(2, Vec::new(), None)]),
2,
&OcrFusionOptions::new(),
)
.unwrap_err();
assert_eq!(error, OcrFusionError::UnexpectedOcrPage { page: 2 });
}
#[test]
fn time_shares_preserve_batch_total() {
assert_eq!(equal_time_shares(8, 3), vec![3, 3, 2]);
assert_eq!(equal_time_shares(8, 0), Vec::<u64>::new());
}
}
-66
View File
@@ -1,66 +0,0 @@
//! Optional native vision primitives used by OCR pipelines.
//!
//! The existing lopdf extractor remains the default path. Native page
//! rendering is available only with the `render-pdfium` feature. Engine
//! contracts are available with `vision`, while checksum-verified model
//! resolution is a separate `model-cache` feature. The `ocr-oar` feature adds
//! a CPU PP-OCRv6 Small implementation of [`OcrEngine`]. These remain separate
//! so browser WASM, text-only consumers, and renderer-only users take on no
//! model-management or inference dependencies.
#[cfg(all(feature = "vision", not(target_arch = "wasm32")))]
mod contracts;
#[cfg(all(feature = "model-download", not(target_arch = "wasm32")))]
mod download;
#[cfg(all(feature = "vision", not(target_arch = "wasm32")))]
mod fusion;
#[cfg(all(feature = "model-cache", not(target_arch = "wasm32")))]
mod models;
#[cfg(all(feature = "ocr-oar", not(target_arch = "wasm32")))]
mod oar;
#[cfg(all(feature = "ocr", not(target_arch = "wasm32")))]
mod pipeline;
#[cfg(all(feature = "vision", not(target_arch = "wasm32")))]
mod render;
#[cfg(all(feature = "vision", not(target_arch = "wasm32")))]
mod routing;
#[cfg(all(feature = "render-pdfium", not(target_arch = "wasm32")))]
mod pdfium;
#[cfg(all(feature = "vision", not(target_arch = "wasm32")))]
pub use contracts::{
ImagePoint, ImageQuad, LayoutEngine, LayoutOptions, LayoutPage, LayoutRegion, LayoutRegionKind,
ModelDownloadPolicy, ModelIdentity, OcrEngine, OcrMode, OcrOptions, OcrPage, OcrProfile,
OcrSpan, PageContentSource, PageProvenance, PageRenderer, VisionTimings,
};
#[cfg(all(feature = "model-download", not(target_arch = "wasm32")))]
pub use download::{HttpModelDownloadError, HttpModelDownloader, DEFAULT_MODEL_DOWNLOAD_TIMEOUT};
#[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};
-1004
View File
File diff suppressed because it is too large Load Diff
-469
View File
@@ -1,469 +0,0 @@
//! PP-OCRv6 Small implementation backed by OAR and ONNX Runtime.
use std::path::PathBuf;
use std::time::Instant;
use image::RgbImage;
use oar_ocr::oarocr::{OAROCRBuilder, OAROCR};
use oar_ocr::processors::BoundingBox;
use thiserror::Error;
use super::{
ImagePoint, ImageQuad, ModelArtifactKind, ModelIdentity, ModelPaths, OcrEngine, OcrMode,
OcrOptions, OcrPage, OcrSpan, RenderPixelFormat, RenderedPage,
};
/// Environment variable selecting the ONNX Runtime shared library.
pub const ONNX_RUNTIME_LIBRARY_ENV: &str = "ORT_DYLIB_PATH";
/// Failures while constructing or running the OAR OCR backend.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum OarOcrError {
/// A required file is missing from the resolved model set.
#[error("resolved OCR model set is missing {kind:?}")]
MissingModelArtifact {
/// Missing artifact role.
kind: ModelArtifactKind,
},
/// OCR was invoked while the caller explicitly disabled it.
#[error("OCR is disabled; select Auto or Force before invoking the engine")]
OcrDisabled,
/// Confidence thresholds must match the normalized engine output range.
#[error("minimum OCR confidence must be finite and between 0 and 1, got {value}")]
InvalidMinimumConfidence {
/// Invalid threshold.
value: f32,
},
/// Bitmap dimension arithmetic exceeded the host address space.
#[error("rendered page {page} bitmap dimensions overflow the host address space")]
ImageSizeOverflow {
/// 1-indexed page number.
page: u32,
},
/// A validated renderer buffer could not be represented as an RGB image.
#[error("rendered page {page} could not be converted to an RGB image")]
InvalidImageBuffer {
/// 1-indexed page number.
page: u32,
},
/// The external ONNX Runtime shared library could not be loaded.
#[error("failed to load ONNX Runtime from {path}: {source}")]
OnnxRuntimeLoad {
/// Requested shared-library path or platform library name.
path: PathBuf,
/// Dynamic-loader failure.
#[source]
source: ort::LoadDynamicError,
},
/// OAR returned no result for a submitted page.
#[error("OAR returned no result for rendered page {page}")]
MissingPageResult {
/// 1-indexed page number.
page: u32,
},
/// OAR or ONNX Runtime rejected the models or failed during inference.
#[error(transparent)]
Backend(#[from] oar_ocr::core::OCRError),
}
/// CPU PP-OCRv6 Small engine using OAR's detection and recognition pipeline.
///
/// Construction accepts only [`ModelPaths`] that have already passed
/// pdf-inspector's manifest size and SHA-256 verification. OAR's independent
/// model auto-download feature is deliberately not enabled.
#[derive(Debug)]
pub struct OarOcrEngine {
pipeline: OAROCR,
model: ModelIdentity,
}
impl OarOcrEngine {
/// Loads PP-OCRv6 Small from a resolved, verified model set.
pub fn from_models(models: &ModelPaths) -> Result<Self, OarOcrError> {
load_onnx_runtime()?;
let detection = required_model(models, ModelArtifactKind::TextDetection)?;
let recognition = required_model(models, ModelArtifactKind::TextRecognition)?;
let dictionary = required_model(models, ModelArtifactKind::CharacterDictionary)?;
let pipeline = OAROCRBuilder::new(detection, recognition, dictionary).build()?;
let model = ModelIdentity::new(models.manifest_id(), models.revision());
Ok(Self { pipeline, model })
}
fn recognize_page(
&self,
page: &RenderedPage,
options: &OcrOptions,
) -> Result<OcrPage, OarOcrError> {
let started = Instant::now();
let image = rendered_page_to_rgb(page)?;
let result = self
.pipeline
.predict(vec![image])?
.into_iter()
.next()
.ok_or(OarOcrError::MissingPageResult { page: page.page() })?;
let mut spans = Vec::with_capacity(result.text_regions.len());
let mut invalid_geometry = 0usize;
let mut missing_recognition = 0usize;
for region in result.text_regions {
let (Some(text), Some(confidence)) = (region.text, region.confidence) else {
missing_recognition += 1;
continue;
};
if text.trim().is_empty() || !confidence.is_finite() {
missing_recognition += 1;
continue;
}
let confidence = confidence.clamp(0.0, 1.0);
if confidence < options.minimum_confidence {
continue;
}
let polygon = region.dt_poly.as_ref().unwrap_or(&region.bounding_box);
let Some(polygon) = bounding_box_to_quad(polygon, page.width(), page.height()) else {
invalid_geometry += 1;
continue;
};
spans.push(OcrSpan {
text: text.to_string(),
polygon,
confidence,
orientation_degrees: region.orientation_angle,
});
}
let mut warnings = Vec::new();
if !options.languages.is_empty() {
warnings
.push("language hints are not used by the PP-OCRv6 Small OAR backend".to_string());
}
if missing_recognition > 0 {
warnings.push(format!(
"discarded {missing_recognition} regions without usable recognition output"
));
}
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: page.page(),
spans,
mean_confidence,
model: self.model.clone(),
processing_time_ms,
warnings,
})
}
}
fn load_onnx_runtime() -> Result<(), OarOcrError> {
let path = std::env::var_os(ONNX_RUNTIME_LIBRARY_ENV)
.filter(|path| !path.is_empty())
.map(PathBuf::from)
.unwrap_or_else(default_onnx_runtime_library);
drop(
ort::init_from(&path).map_err(|source| OarOcrError::OnnxRuntimeLoad {
path: path.clone(),
source,
})?,
);
Ok(())
}
fn default_onnx_runtime_library() -> PathBuf {
#[cfg(target_os = "windows")]
const NAME: &str = "onnxruntime.dll";
#[cfg(any(target_os = "linux", target_os = "android", target_os = "freebsd"))]
const NAME: &str = "libonnxruntime.so";
#[cfg(any(target_os = "macos", target_os = "ios"))]
const NAME: &str = "libonnxruntime.dylib";
PathBuf::from(NAME)
}
impl OcrEngine for OarOcrEngine {
type Error = OarOcrError;
fn model(&self) -> &ModelIdentity {
&self.model
}
fn recognize(
&self,
pages: &[RenderedPage],
options: &OcrOptions,
) -> Result<Vec<OcrPage>, Self::Error> {
validate_options(options)?;
pages
.iter()
.map(|page| self.recognize_page(page, options))
.collect()
}
}
fn validate_options(options: &OcrOptions) -> Result<(), OarOcrError> {
if options.mode == OcrMode::Off {
return Err(OarOcrError::OcrDisabled);
}
if !options.minimum_confidence.is_finite() || !(0.0..=1.0).contains(&options.minimum_confidence)
{
return Err(OarOcrError::InvalidMinimumConfidence {
value: options.minimum_confidence,
});
}
Ok(())
}
fn required_model(
models: &ModelPaths,
kind: ModelArtifactKind,
) -> Result<&std::path::Path, OarOcrError> {
models
.get(kind)
.ok_or(OarOcrError::MissingModelArtifact { kind })
}
fn rendered_page_to_rgb(page: &RenderedPage) -> Result<RgbImage, OarOcrError> {
let width = usize::try_from(page.width())
.map_err(|_| OarOcrError::ImageSizeOverflow { page: page.page() })?;
let height = usize::try_from(page.height())
.map_err(|_| OarOcrError::ImageSizeOverflow { page: page.page() })?;
let output_len = width
.checked_mul(height)
.and_then(|pixels| pixels.checked_mul(3))
.ok_or(OarOcrError::ImageSizeOverflow { page: page.page() })?;
let input_bpp = page.format().bytes_per_pixel();
let active_input_row = width
.checked_mul(input_bpp)
.ok_or(OarOcrError::ImageSizeOverflow { page: page.page() })?;
let output_row = width
.checked_mul(3)
.ok_or(OarOcrError::ImageSizeOverflow { page: page.page() })?;
let mut rgb = vec![0u8; output_len];
for row in 0..height {
let input_start = row * page.stride();
let input = &page.pixels()[input_start..input_start + active_input_row];
let output_start = row * output_row;
let output = &mut rgb[output_start..output_start + output_row];
match page.format() {
RenderPixelFormat::Rgb8 => output.copy_from_slice(input),
RenderPixelFormat::Rgba8 => {
for (rgba, rgb) in input.chunks_exact(4).zip(output.chunks_exact_mut(3)) {
rgb.copy_from_slice(&rgba[..3]);
}
}
RenderPixelFormat::Gray8 => {
for (&gray, rgb) in input.iter().zip(output.chunks_exact_mut(3)) {
rgb.fill(gray);
}
}
}
}
RgbImage::from_raw(page.width(), page.height(), rgb)
.ok_or(OarOcrError::InvalidImageBuffer { page: page.page() })
}
fn bounding_box_to_quad(bounding_box: &BoundingBox, width: u32, height: u32) -> Option<ImageQuad> {
let points: Vec<ImagePoint> = bounding_box
.points
.iter()
.filter(|point| point.x.is_finite() && point.y.is_finite())
.map(|point| {
ImagePoint::new(
point.x.clamp(0.0, width as f32),
point.y.clamp(0.0, height as f32),
)
})
.collect();
if bounding_box.points.len() == 4 && points.len() == 4 && is_ordered_convex_quad(&points) {
return Some(ImageQuad::new([points[0], points[1], points[2], points[3]]));
}
if points.len() < 3 {
return None;
}
let min_x = points
.iter()
.map(|point| point.x)
.fold(f32::INFINITY, f32::min);
let max_x = points
.iter()
.map(|point| point.x)
.fold(f32::NEG_INFINITY, f32::max);
let min_y = points
.iter()
.map(|point| point.y)
.fold(f32::INFINITY, f32::min);
let max_y = points
.iter()
.map(|point| point.y)
.fold(f32::NEG_INFINITY, f32::max);
if max_x <= min_x || max_y <= min_y {
return None;
}
Some(ImageQuad::new([
ImagePoint::new(min_x, min_y),
ImagePoint::new(max_x, min_y),
ImagePoint::new(max_x, max_y),
ImagePoint::new(min_x, max_y),
]))
}
fn is_ordered_convex_quad(points: &[ImagePoint]) -> bool {
if points.len() != 4 {
return false;
}
let mut orientation = 0.0_f32;
for index in 0..4 {
let first = points[index];
let second = points[(index + 1) % 4];
let third = points[(index + 2) % 4];
let cross = (second.x - first.x) * (third.y - second.y)
- (second.y - first.y) * (third.x - second.x);
if cross.abs() <= f32::EPSILON {
return false;
}
if orientation == 0.0 {
orientation = cross.signum();
} else if cross.signum() != orientation {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use oar_ocr::processors::Point;
use super::*;
use crate::vision::PageTransform;
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());
}
}
-266
View File
@@ -1,266 +0,0 @@
//! PDFium-backed implementation of the renderer-neutral page contract.
use std::path::Path;
use firecrawl_pdfium::{Pdfium, PixelFormat, PixelPoint, RenderConfig};
use thiserror::Error;
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,
},
/// 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,
}
impl PdfiumRenderer {
/// Loads PDFium using `firecrawl-pdfium`'s documented discovery chain.
pub fn load() -> Result<Self, RenderError> {
Ok(Self {
pdfium: Pdfium::load()?,
})
}
/// Loads PDFium from an explicit native library path.
pub fn load_from_path(path: impl AsRef<Path>) -> Result<Self, RenderError> {
Ok(Self {
pdfium: Pdfium::load_from_path(path)?,
})
}
/// 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)
}
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)
}
}
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::*;
#[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 { .. })
));
}
}
-426
View File
@@ -1,426 +0,0 @@
//! One-call native extraction and OCR pipeline.
use std::collections::BTreeSet;
use std::path::Path;
use std::time::Instant;
use thiserror::Error;
use crate::{MarkdownOptions, PageOcrReasons, PdfError};
use super::{
fuse_ocr_pages, route_ocr_pages, run_ocr_pages, FusedPageMarkdown, HttpModelDownloadError,
HttpModelDownloader, ModelAcquireError, ModelStore, ModelStoreError, OarOcrEngine, OarOcrError,
OcrFusionError, OcrFusionOptions, OcrMode, OcrOptions, OcrRoutingError, OcrRun, OcrRunError,
PdfiumRenderer, RenderError, RenderOptions, PP_OCR_V6_SMALL,
};
/// Options for native extraction with optional OCR.
#[derive(Clone)]
pub struct OcrPdfOptions {
/// Page rasterization settings used when OCR is routed.
pub render: RenderOptions,
/// OCR routing, model, and recognition settings.
pub ocr: OcrOptions,
/// Markdown formatting shared by native and OCR assembly.
pub markdown: MarkdownOptions,
/// Optional 1-indexed page selection. `None` processes the full document.
pub page_filter: Option<BTreeSet<u32>>,
/// Password for an encrypted PDF.
pub password: Option<String>,
/// Weak OCR threshold for recommending the hosted pipeline.
pub hosted_recommendation_confidence: f32,
}
impl Default for OcrPdfOptions {
fn default() -> Self {
Self {
render: RenderOptions::default(),
ocr: OcrOptions::default(),
markdown: MarkdownOptions::default(),
page_filter: None,
password: None,
hosted_recommendation_confidence: 0.5,
}
}
}
impl std::fmt::Debug for OcrPdfOptions {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("OcrPdfOptions")
.field("render", &self.render)
.field("ocr", &self.ocr)
.field("markdown", &self.markdown)
.field("page_filter", &self.page_filter)
.field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
.field(
"hosted_recommendation_confidence",
&self.hosted_recommendation_confidence,
)
.finish()
}
}
impl OcrPdfOptions {
/// Creates options with OCR disabled, preserving the native-only path.
pub fn new() -> Self {
Self::default()
}
/// Replaces page rasterization settings.
pub fn render(mut self, render: RenderOptions) -> Self {
self.render = render;
self
}
/// Replaces OCR routing and recognition settings.
pub fn ocr(mut self, ocr: OcrOptions) -> Self {
self.ocr = ocr;
self
}
/// Sets OCR routing without changing the remaining OCR settings.
pub fn mode(mut self, mode: OcrMode) -> Self {
self.ocr.mode = mode;
self
}
/// Replaces Markdown formatting options.
pub fn markdown(mut self, markdown: MarkdownOptions) -> Self {
self.markdown = markdown;
self
}
/// Restricts processing to 1-indexed pages in ascending order.
pub fn pages(mut self, pages: impl IntoIterator<Item = u32>) -> Self {
self.page_filter = Some(pages.into_iter().collect());
self
}
/// Sets the password used to decrypt the PDF.
pub fn password(mut self, password: impl Into<String>) -> Self {
self.password = Some(password.into());
self
}
/// Sets the weak-OCR threshold for recommending hosted document parsing.
pub fn hosted_recommendation_confidence(mut self, confidence: f32) -> Self {
self.hosted_recommendation_confidence = confidence;
self
}
}
/// Complete native/OCR Markdown output for a PDF request.
#[derive(Debug, Clone, PartialEq)]
pub struct OcrPdfResult {
/// Final document Markdown in selected-page order.
pub markdown: String,
/// Final per-page Markdown and provenance, using 1-indexed page numbers.
pub pages: Vec<FusedPageMarkdown>,
/// Total pages in the PDF, independent of page selection.
pub page_count: u32,
/// 1-indexed selected pages recommended for OCR by native extraction.
pub pages_recommended_for_ocr: Vec<u32>,
/// 1-indexed pages actually rendered and recognized.
pub pages_routed_to_ocr: Vec<u32>,
/// 1-indexed pages whose OCR result recommends hosted document parsing.
pub pages_recommending_hosted: Vec<u32>,
/// Original machine-readable OCR reasons for selected pages.
pub ocr_reasons_by_page: Vec<PageOcrReasons>,
/// Selected pages where deterministic table detection found tables.
pub pages_with_tables: Vec<u32>,
/// Selected pages where deterministic layout found multiple columns.
pub pages_with_columns: Vec<u32>,
/// Whether deterministic extraction found tables or columns.
pub is_complex: bool,
/// End-to-end processing time.
pub processing_time_ms: u64,
/// Batch page-rendering time; zero when no OCR work was routed.
pub render_time_ms: u64,
/// Batch OCR time; zero when no OCR work was routed.
pub ocr_time_ms: u64,
}
/// Processes a PDF file through native extraction and selective OCR.
pub fn process_pdf_with_ocr(
path: impl AsRef<Path>,
options: OcrPdfOptions,
) -> Result<OcrPdfResult, OcrPipelineError> {
let bytes = std::fs::read(path).map_err(PdfError::from)?;
process_pdf_with_ocr_mem(&bytes, options)
}
/// Processes PDF bytes through native extraction and selective OCR.
///
/// Native extraction always runs first. `Auto` initializes PDFium, downloads
/// models, and starts OAR only if the detector selected at least one page.
/// `Off` therefore has no renderer, model-cache, network, or inference side
/// effects even though the complete feature is compiled into the application.
pub fn process_pdf_with_ocr_mem(
buffer: &[u8],
options: OcrPdfOptions,
) -> Result<OcrPdfResult, OcrPipelineError> {
OcrFusionOptions::new()
.render_dpi(options.render.dpi)
.hosted_recommendation_confidence(options.hosted_recommendation_confidence)
.validate()?;
let minimum_confidence = options.ocr.minimum_confidence;
if !minimum_confidence.is_finite() || !(0.0..=1.0).contains(&minimum_confidence) {
return Err(OcrPipelineError::InvalidMinimumConfidence {
value: minimum_confidence,
});
}
if options
.page_filter
.as_ref()
.is_some_and(|pages| pages.contains(&0))
{
return Err(OcrPipelineError::InvalidSelectedPage { page: 0 });
}
let started = Instant::now();
let selected_pages: Option<Vec<u32>> = options
.page_filter
.as_ref()
.map(|pages| pages.iter().copied().collect());
let selected_pages_zero_indexed: Option<Vec<u32>> = selected_pages
.as_ref()
.map(|pages| pages.iter().map(|page| page - 1).collect());
let mut page_markdown_options = options.markdown.clone();
page_markdown_options.include_page_numbers = false;
let (native, page_count) = crate::extract_pages_markdown_mem_for_ocr(
buffer,
selected_pages_zero_indexed.as_deref(),
options.password.as_deref(),
&page_markdown_options,
)?;
if let Some(invalid) = selected_pages
.as_ref()
.and_then(|pages| pages.iter().copied().find(|page| *page > page_count))
{
return Err(OcrPipelineError::InvalidSelectedPage { page: invalid });
}
let routed = route_ocr_pages(
options.ocr.mode,
page_count,
&native.pages_needing_ocr,
selected_pages.as_deref(),
)?;
let ocr_run = if routed.is_empty() {
OcrRun {
pages: Vec::new(),
render_time_ms: 0,
ocr_time_ms: 0,
}
} else {
// Resolve the native renderer before any network request so a missing
// PDFium installation cannot trigger a model download it cannot use.
let renderer = PdfiumRenderer::load()?;
let store = ModelStore::from_options(&options.ocr)?;
let models = store.resolve_or_download(
&PP_OCR_V6_SMALL,
options.ocr.model_downloads,
&HttpModelDownloader::default(),
)?;
let engine = OarOcrEngine::from_models(&models)?;
run_ocr_pages(
&renderer,
&engine,
buffer,
&routed,
options.password.as_deref(),
&options.render,
&options.ocr,
)?
};
let fusion_options = OcrFusionOptions::new()
.markdown(page_markdown_options)
.render_dpi(options.render.dpi)
.hosted_recommendation_confidence(options.hosted_recommendation_confidence);
let fused = fuse_ocr_pages(&native.pages, &ocr_run, page_count, &fusion_options)?;
let pages_recommending_hosted = fused
.pages
.iter()
.filter(|page| page.provenance.hosted_recommended)
.map(|page| page.provenance.page)
.collect();
let markdown = assemble_document_markdown(&fused.pages, options.markdown.include_page_numbers);
Ok(OcrPdfResult {
markdown,
pages: fused.pages,
page_count,
pages_recommended_for_ocr: native.pages_needing_ocr,
pages_routed_to_ocr: routed,
pages_recommending_hosted,
ocr_reasons_by_page: native.ocr_reasons_by_page,
pages_with_tables: native.pages_with_tables,
pages_with_columns: native.pages_with_columns,
is_complex: native.is_complex,
processing_time_ms: elapsed_ms(started),
render_time_ms: fused.render_time_ms,
ocr_time_ms: fused.ocr_time_ms,
})
}
fn assemble_document_markdown(pages: &[FusedPageMarkdown], include_page_numbers: bool) -> String {
let mut document = String::new();
for (index, page) in pages.iter().enumerate() {
if index > 0 {
document.push_str("\n\n");
}
if include_page_numbers {
document.push_str(&format!("<!-- Page {} -->\n\n", page.page));
}
document.push_str(page.markdown.trim());
}
if !document.is_empty() {
document.push('\n');
}
document
}
fn elapsed_ms(started: Instant) -> u64 {
u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX)
}
/// Failures from the complete OCR pipeline.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum OcrPipelineError {
/// PDF loading or native extraction failed.
#[error(transparent)]
Pdf(#[from] PdfError),
/// Page routing rejected an invalid request.
#[error(transparent)]
Routing(#[from] OcrRoutingError),
/// The model cache could not be located or initialized.
#[error(transparent)]
ModelStore(#[from] ModelStoreError),
/// A pinned model set could not be resolved or acquired.
#[error(transparent)]
ModelAcquire(#[from] ModelAcquireError<HttpModelDownloadError>),
/// PDFium could not load or rasterize the request.
#[error(transparent)]
Render(#[from] RenderError),
/// The OAR engine could not initialize.
#[error(transparent)]
Oar(#[from] OarOcrError),
/// Selective rendering or OCR execution failed.
#[error(transparent)]
Run(#[from] OcrRunError),
/// OCR/native Markdown fusion failed.
#[error(transparent)]
Fusion(#[from] OcrFusionError),
/// Page zero is invalid because public page selections are 1-indexed.
#[error("selected page {page} is invalid; page numbers are 1-indexed")]
InvalidSelectedPage {
/// Invalid page number.
page: u32,
},
/// OCR span confidence is outside the inclusive 01 range.
#[error("minimum OCR confidence must be between 0 and 1, got {value}")]
InvalidMinimumConfidence {
/// Invalid value.
value: f32,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn off_mode_extracts_native_text_without_runtime_side_effects() {
let bytes = std::fs::read("tests/fixtures/thermo-freon12.pdf").unwrap();
let result = process_pdf_with_ocr_mem(&bytes, OcrPdfOptions::new()).unwrap();
assert_eq!(result.page_count, 3);
assert_eq!(result.pages.len(), 3);
assert!(result.markdown.contains("Thermodynamic Properties"));
assert!(result.pages_recommended_for_ocr.is_empty());
assert!(result.pages_routed_to_ocr.is_empty());
assert!(result.pages_recommending_hosted.is_empty());
assert_eq!(result.render_time_ms, 0);
assert_eq!(result.ocr_time_ms, 0);
}
#[test]
fn auto_mode_does_not_load_pdfium_or_models_for_clean_pdf() {
let bytes = std::fs::read("tests/fixtures/thermo-freon12.pdf").unwrap();
let result =
process_pdf_with_ocr_mem(&bytes, OcrPdfOptions::new().mode(OcrMode::Auto)).unwrap();
assert!(result.pages_routed_to_ocr.is_empty());
assert!(result.markdown.contains("Freon 12"));
}
#[test]
fn selection_and_page_markers_use_public_one_indexed_pages() {
let bytes = std::fs::read("tests/fixtures/thermo-freon12.pdf").unwrap();
let mut markdown = MarkdownOptions::default();
markdown.include_page_numbers = true;
let result =
process_pdf_with_ocr_mem(&bytes, OcrPdfOptions::new().pages([2]).markdown(markdown))
.unwrap();
assert_eq!(result.pages.len(), 1);
assert_eq!(result.pages[0].page, 2);
assert_eq!(result.pages[0].page, result.pages[0].provenance.page);
assert!(result.markdown.starts_with("<!-- Page 2 -->"));
}
#[test]
fn off_mode_marks_unprocessed_scan_for_hosted_fallback() {
let bytes = std::fs::read("tests/fixtures/scan_with_native_header_text.pdf").unwrap();
let result = process_pdf_with_ocr_mem(&bytes, OcrPdfOptions::new()).unwrap();
assert!(result.pages_routed_to_ocr.is_empty());
assert_eq!(result.pages_recommending_hosted, vec![1]);
}
#[test]
fn password_is_redacted_and_used_for_native_extraction() {
let options = OcrPdfOptions::new().password("secret123");
assert!(!format!("{options:?}").contains("secret123"));
let bytes = std::fs::read("tests/fixtures/encrypted-secret123.pdf").unwrap();
let result = process_pdf_with_ocr_mem(&bytes, options).unwrap();
assert!(result.markdown.contains("Procurement"));
}
#[test]
fn rejects_out_of_range_selection_even_with_ocr_off() {
let bytes = std::fs::read("tests/fixtures/thermo-freon12.pdf").unwrap();
let error = process_pdf_with_ocr_mem(&bytes, OcrPdfOptions::new().pages([4])).unwrap_err();
assert!(matches!(
error,
OcrPipelineError::InvalidSelectedPage { page: 4 }
));
}
#[test]
fn invalid_expensive_options_fail_before_pdf_or_runtime_access() {
let mut invalid_dpi = OcrPdfOptions::new();
invalid_dpi.render.dpi = f32::NAN;
assert!(matches!(
process_pdf_with_ocr_mem(b"not a PDF", invalid_dpi),
Err(OcrPipelineError::Fusion(
OcrFusionError::InvalidRenderDpi { .. }
))
));
let invalid_hosted = OcrPdfOptions::new().hosted_recommendation_confidence(1.1);
assert!(matches!(
process_pdf_with_ocr_mem(b"not a PDF", invalid_hosted),
Err(OcrPipelineError::Fusion(
OcrFusionError::InvalidHostedConfidence { .. }
))
));
}
}
-552
View File
@@ -1,552 +0,0 @@
//! Renderer-neutral page bitmap and coordinate types.
use thiserror::Error;
use crate::PdfRect;
/// Default rendering resolution for OCR.
pub const DEFAULT_RENDER_DPI: f32 = 150.0;
/// Default maximum size of one rendered page: 256 MiB.
pub const DEFAULT_MAX_OUTPUT_BYTES: u64 = 256 * 1024 * 1024;
/// Pixel layout returned by [`RenderedPage`].
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
#[non_exhaustive]
pub enum RenderPixelFormat {
/// Three bytes per pixel in red, green, blue order. This is the default
/// because OCR preprocessors conventionally consume RGB images.
#[default]
Rgb8,
/// Four bytes per pixel in red, green, blue, alpha order.
Rgba8,
/// One luminance byte per pixel.
Gray8,
}
impl RenderPixelFormat {
/// Number of bytes used by one pixel.
pub fn bytes_per_pixel(self) -> usize {
match self {
Self::Rgb8 => 3,
Self::Rgba8 => 4,
Self::Gray8 => 1,
}
}
}
/// Configuration for pages rendered as input to a local vision pipeline.
#[derive(Debug, Clone, PartialEq)]
pub struct RenderOptions {
/// Output resolution. Defaults to 150 DPI.
pub dpi: f32,
/// Pixel layout. Defaults to three-channel RGB.
pub pixel_format: RenderPixelFormat,
/// Include PDF annotations in the rendered bitmap.
pub annotations: bool,
/// Include visible static AcroForm field appearances.
pub form_fields: bool,
/// Maximum allocation for each rendered page.
pub max_output_bytes_per_page: u64,
}
impl Default for RenderOptions {
fn default() -> Self {
Self {
dpi: DEFAULT_RENDER_DPI,
pixel_format: RenderPixelFormat::Rgb8,
annotations: true,
form_fields: true,
max_output_bytes_per_page: DEFAULT_MAX_OUTPUT_BYTES,
}
}
}
impl RenderOptions {
/// Creates local-rendering options with OCR-oriented defaults.
pub fn new() -> Self {
Self::default()
}
/// Sets the output resolution in dots per inch.
pub fn dpi(mut self, dpi: f32) -> Self {
self.dpi = dpi;
self
}
/// Sets the output pixel layout.
pub fn pixel_format(mut self, pixel_format: RenderPixelFormat) -> Self {
self.pixel_format = pixel_format;
self
}
/// Toggles annotation rendering.
pub fn annotations(mut self, annotations: bool) -> Self {
self.annotations = annotations;
self
}
/// Toggles visible static form-field rendering.
pub fn form_fields(mut self, form_fields: bool) -> Self {
self.form_fields = form_fields;
self
}
/// Sets the maximum allocation for each rendered page.
pub fn max_output_bytes_per_page(mut self, bytes: u64) -> Self {
self.max_output_bytes_per_page = bytes;
self
}
}
/// A point in PDF page space, measured in points from the bottom-left.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PagePoint {
/// Horizontal position in PDF points.
pub x: f32,
/// Vertical position in PDF points, increasing upward.
pub y: f32,
}
/// Affine transform between top-left pixel space and PDF page space.
///
/// Renderers create this from the page-space images of the bitmap corners.
/// Keeping the coefficients in pdf-inspector makes [`RenderedPage`] neutral
/// to the renderer implementation that produced it.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct PageTransform {
forward: [f64; 6],
inverse: [f64; 6],
pixel_width: u32,
pixel_height: u32,
}
impl PageTransform {
/// Builds a transform from the PDF-space images of device corners
/// `(0, 0)`, `(pixel_width, 0)`, and `(0, pixel_height)`.
pub fn from_corners(
pixel_width: u32,
pixel_height: u32,
origin: (f64, f64),
x_axis: (f64, f64),
y_axis: (f64, f64),
) -> Option<Self> {
if pixel_width == 0 || pixel_height == 0 {
return None;
}
let values = [origin.0, origin.1, x_axis.0, x_axis.1, y_axis.0, y_axis.1];
if values.iter().any(|value| !value.is_finite()) {
return None;
}
let width = f64::from(pixel_width);
let height = f64::from(pixel_height);
let a = (x_axis.0 - origin.0) / width;
let c = (x_axis.1 - origin.1) / width;
let b = (y_axis.0 - origin.0) / height;
let d = (y_axis.1 - origin.1) / height;
let (e, f) = origin;
let forward = [a, b, c, d, e, f];
if forward.iter().any(|coefficient| !coefficient.is_finite()) {
return None;
}
let determinant = a * d - b * c;
if determinant == 0.0 || !determinant.is_finite() {
return None;
}
let inverse_a = d / determinant;
let inverse_b = -b / determinant;
let inverse_c = -c / determinant;
let inverse_d = a / determinant;
let inverse_e = -(inverse_a * e + inverse_b * f);
let inverse_f = -(inverse_c * e + inverse_d * f);
let inverse = [
inverse_a, inverse_b, inverse_c, inverse_d, inverse_e, inverse_f,
];
if inverse.iter().any(|coefficient| !coefficient.is_finite()) {
return None;
}
Some(Self {
forward,
inverse,
pixel_width,
pixel_height,
})
}
/// Width of the bitmap this transform describes.
pub fn pixel_width(&self) -> u32 {
self.pixel_width
}
/// Height of the bitmap this transform describes.
pub fn pixel_height(&self) -> u32 {
self.pixel_height
}
/// Converts a bitmap point to PDF page space.
pub fn pixel_to_page(&self, x: f64, y: f64) -> PagePoint {
let [a, b, c, d, e, f] = self.forward;
PagePoint {
x: (a * x + b * y + e) as f32,
y: (c * x + d * y + f) as f32,
}
}
/// Converts a PDF page-space point to bitmap coordinates.
pub fn page_to_pixel(&self, x: f64, y: f64) -> (f64, f64) {
let [a, b, c, d, e, f] = self.inverse;
(a * x + b * y + e, c * x + d * y + f)
}
}
/// Invalid renderer output rejected by [`RenderedPage::new`].
#[derive(Debug, Error, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum RenderBufferError {
/// Page numbers are 1-indexed.
#[error("rendered page number must be at least 1")]
InvalidPageNumber,
/// Bitmap dimensions must be non-zero.
#[error("rendered bitmap dimensions must be non-zero")]
InvalidDimensions,
/// Page dimensions must be positive finite numbers.
#[error("rendered PDF page dimensions must be positive and finite")]
InvalidPageDimensions,
/// Transform dimensions must match the bitmap dimensions.
#[error("coordinate transform dimensions do not match the rendered bitmap")]
TransformDimensions,
/// Renderer did not provide an invertible finite coordinate transform.
#[error("renderer returned an invalid coordinate transform")]
InvalidTransform,
/// The stride cannot hold one active row of pixels.
#[error("pixel stride {stride} is shorter than the active row size {minimum}")]
InvalidStride {
/// Supplied bytes per row.
stride: usize,
/// Minimum bytes required for one row.
minimum: usize,
},
/// Pixel buffer size is inconsistent with height and stride.
#[error("pixel buffer has {actual} bytes; expected {expected}")]
InvalidBufferLength {
/// Actual byte count.
actual: usize,
/// Required byte count.
expected: usize,
},
/// Dimension arithmetic overflowed the host address space.
#[error("rendered bitmap dimensions overflow the host address space")]
SizeOverflow,
}
/// One rendered page with owned pixels and its pixel-to-PDF transform.
///
/// The value contains no live renderer, page, or document handles. It can be
/// moved to an OCR worker and retained after rendering returns.
#[derive(Debug, Clone)]
pub struct RenderedPage {
page: u32,
page_width: f32,
page_height: f32,
width: u32,
height: u32,
stride: usize,
format: RenderPixelFormat,
pixels: Vec<u8>,
transform: PageTransform,
}
impl RenderedPage {
/// Creates a renderer-neutral owned page after validating its buffer.
#[allow(clippy::too_many_arguments)]
pub fn new(
page: u32,
page_width: f32,
page_height: f32,
width: u32,
height: u32,
stride: usize,
format: RenderPixelFormat,
pixels: Vec<u8>,
transform: PageTransform,
) -> Result<Self, RenderBufferError> {
if page == 0 {
return Err(RenderBufferError::InvalidPageNumber);
}
if width == 0 || height == 0 {
return Err(RenderBufferError::InvalidDimensions);
}
if page_width <= 0.0
|| page_height <= 0.0
|| !page_width.is_finite()
|| !page_height.is_finite()
{
return Err(RenderBufferError::InvalidPageDimensions);
}
if transform.pixel_width() != width || transform.pixel_height() != height {
return Err(RenderBufferError::TransformDimensions);
}
let row_bytes = (width as usize)
.checked_mul(format.bytes_per_pixel())
.ok_or(RenderBufferError::SizeOverflow)?;
if stride < row_bytes {
return Err(RenderBufferError::InvalidStride {
stride,
minimum: row_bytes,
});
}
let expected = stride
.checked_mul(height as usize)
.ok_or(RenderBufferError::SizeOverflow)?;
if pixels.len() != expected {
return Err(RenderBufferError::InvalidBufferLength {
actual: pixels.len(),
expected,
});
}
Ok(Self {
page,
page_width,
page_height,
width,
height,
stride,
format,
pixels,
transform,
})
}
/// 1-indexed page number.
pub fn page(&self) -> u32 {
self.page
}
/// Page width in PDF points after applying the page's rotation.
pub fn page_width(&self) -> f32 {
self.page_width
}
/// Page height in PDF points after applying the page's rotation.
pub fn page_height(&self) -> f32 {
self.page_height
}
/// Bitmap width in pixels.
pub fn width(&self) -> u32 {
self.width
}
/// Bitmap height in pixels.
pub fn height(&self) -> u32 {
self.height
}
/// Number of bytes between adjacent bitmap rows.
pub fn stride(&self) -> usize {
self.stride
}
/// Pixel layout of [`pixels`](Self::pixels).
pub fn format(&self) -> RenderPixelFormat {
self.format
}
/// Owned bitmap bytes, with rows ordered top-to-bottom.
pub fn pixels(&self) -> &[u8] {
&self.pixels
}
/// Consumes the page and returns its pixel buffer.
pub fn into_pixels(self) -> Vec<u8> {
self.pixels
}
/// Coordinate transform associated with the rendered page.
pub fn transform(&self) -> PageTransform {
self.transform
}
/// Converts a bitmap point (top-left origin, y-down) to PDF page space
/// (bottom-left origin, y-up).
pub fn pixel_to_page(&self, x: f64, y: f64) -> PagePoint {
self.transform.pixel_to_page(x, y)
}
/// Converts a bitmap rectangle to the repository's existing PDF-space
/// rectangle type. The returned page number remains 1-indexed.
pub fn pixel_rect_to_pdf_rect(&self, x: f64, y: f64, width: f64, height: f64) -> PdfRect {
let points = [
self.transform.pixel_to_page(x, y),
self.transform.pixel_to_page(x + width, y),
self.transform.pixel_to_page(x, y + height),
self.transform.pixel_to_page(x + width, y + height),
];
let left = points
.iter()
.map(|point| point.x)
.fold(f32::INFINITY, f32::min);
let right = points
.iter()
.map(|point| point.x)
.fold(f32::NEG_INFINITY, f32::max);
let bottom = points
.iter()
.map(|point| point.y)
.fold(f32::INFINITY, f32::min);
let top = points
.iter()
.map(|point| point.y)
.fold(f32::NEG_INFINITY, f32::max);
PdfRect {
x: left,
y: bottom,
width: right - left,
height: top - bottom,
page: self.page,
}
}
/// Converts a PDF-space rectangle to bitmap coordinates
/// `(x, y, width, height)` with a top-left origin.
pub fn pdf_rect_to_pixel(&self, rect: &PdfRect) -> (f64, f64, f64, f64) {
let left = f64::from(rect.x);
let right = f64::from(rect.x + rect.width);
let bottom = f64::from(rect.y);
let top = f64::from(rect.y + rect.height);
let points = [
self.transform.page_to_pixel(left, bottom),
self.transform.page_to_pixel(right, bottom),
self.transform.page_to_pixel(left, top),
self.transform.page_to_pixel(right, top),
];
let min_x = points
.iter()
.map(|point| point.0)
.fold(f64::INFINITY, f64::min);
let max_x = points
.iter()
.map(|point| point.0)
.fold(f64::NEG_INFINITY, f64::max);
let min_y = points
.iter()
.map(|point| point.1)
.fold(f64::INFINITY, f64::min);
let max_y = points
.iter()
.map(|point| point.1)
.fold(f64::NEG_INFINITY, f64::max);
(min_x, min_y, max_x - min_x, max_y - min_y)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn transform() -> PageTransform {
PageTransform::from_corners(400, 200, (0.0, 100.0), (200.0, 100.0), (0.0, 0.0)).unwrap()
}
#[test]
fn transform_maps_both_directions_at_non_identity_scale() {
let transform = transform();
let point = transform.pixel_to_page(100.0, 50.0);
assert!((point.x - 50.0).abs() < 1e-6);
assert!((point.y - 75.0).abs() < 1e-6);
let pixel = transform.page_to_pixel(f64::from(point.x), f64::from(point.y));
assert!((pixel.0 - 100.0).abs() < 1e-6);
assert!((pixel.1 - 50.0).abs() < 1e-6);
}
#[test]
fn rendered_page_accepts_padding_and_validates_length() {
let page = RenderedPage::new(
1,
200.0,
100.0,
400,
200,
1_204,
RenderPixelFormat::Rgb8,
vec![0; 1_204 * 200],
transform(),
)
.unwrap();
assert_eq!(page.stride(), 1_204);
assert!(matches!(
RenderedPage::new(
1,
200.0,
100.0,
400,
200,
1_204,
RenderPixelFormat::Rgb8,
vec![0; 5],
transform(),
),
Err(RenderBufferError::InvalidBufferLength { .. })
));
}
#[test]
fn rotated_transform_round_trips_rectangles() {
let transform =
PageTransform::from_corners(100, 200, (0.0, 0.0), (0.0, 100.0), (200.0, 0.0)).unwrap();
let page = RenderedPage::new(
1,
200.0,
100.0,
100,
200,
300,
RenderPixelFormat::Rgb8,
vec![0; 300 * 200],
transform,
)
.unwrap();
let pdf = page.pixel_rect_to_pdf_rect(10.0, 20.0, 30.0, 40.0);
let pixel = page.pdf_rect_to_pixel(&pdf);
assert!((pixel.0 - 10.0).abs() < 1e-5);
assert!((pixel.1 - 20.0).abs() < 1e-5);
assert!((pixel.2 - 30.0).abs() < 1e-5);
assert!((pixel.3 - 40.0).abs() < 1e-5);
}
#[test]
fn skewed_transform_bounds_all_rectangle_corners() {
let transform =
PageTransform::from_corners(100, 100, (0.0, 100.0), (100.0, 125.0), (25.0, 0.0))
.unwrap();
let page = RenderedPage::new(
1,
125.0,
125.0,
100,
100,
300,
RenderPixelFormat::Rgb8,
vec![0; 30_000],
transform,
)
.unwrap();
let pdf = page.pixel_rect_to_pdf_rect(10.0, 20.0, 30.0, 40.0);
assert!((pdf.x - 15.0).abs() < 1e-5);
assert!((pdf.y - 42.5).abs() < 1e-5);
assert!((pdf.width - 40.0).abs() < 1e-5);
assert!((pdf.height - 47.5).abs() < 1e-5);
let pixels = page.pdf_rect_to_pixel(&pdf);
assert!(pixels.0 <= 10.0 && pixels.1 <= 20.0);
assert!(pixels.0 + pixels.2 >= 40.0 && pixels.1 + pixels.3 >= 60.0);
}
}
-380
View File
@@ -1,380 +0,0 @@
//! Page routing and renderer/OCR orchestration without Markdown fusion.
use std::collections::BTreeSet;
use std::error::Error;
use std::time::Instant;
use thiserror::Error;
use super::{OcrEngine, OcrMode, OcrOptions, OcrPage, PageRenderer, RenderOptions, RenderedPage};
/// A rendered page paired with OCR output in the same bitmap coordinate space.
#[derive(Debug)]
pub struct RoutedOcrPage {
/// Renderer-owned bitmap and pixel↔PDF transform.
pub rendered: RenderedPage,
/// Positioned OCR spans for the bitmap.
pub ocr: OcrPage,
}
/// Output of one selective OCR invocation.
#[derive(Debug)]
pub struct OcrRun {
/// Pages processed in ascending document order.
pub pages: Vec<RoutedOcrPage>,
/// Total page-rendering wall time.
pub render_time_ms: u64,
/// Total engine wall time.
pub ocr_time_ms: u64,
}
/// Selects 1-indexed pages for OCR.
///
/// `recommended_pages` comes from pdf-inspector's existing detector/text
/// quality signals. `selected_pages` is an optional user page filter. Results
/// are validated, deduplicated, and returned in document order.
pub fn route_ocr_pages(
mode: OcrMode,
page_count: u32,
recommended_pages: &[u32],
selected_pages: Option<&[u32]>,
) -> Result<Vec<u32>, OcrRoutingError> {
match mode {
OcrMode::Off => Ok(Vec::new()),
OcrMode::Auto => {
let mut routed = validated_page_set("recommended", recommended_pages, page_count)?;
if let Some(selected) = selected_pages {
let selected = validated_page_set("selected", selected, page_count)?;
routed.retain(|page| selected.contains(page));
}
Ok(routed.into_iter().collect())
}
OcrMode::Force => {
let routed = selected_pages
.map(|pages| validated_page_set("selected", pages, page_count))
.transpose()?
.unwrap_or_else(|| (1..=page_count).collect());
Ok(routed.into_iter().collect())
}
}
}
/// Renders and recognizes already-routed pages while retaining transforms for
/// the following fusion layer.
///
/// An empty page list returns without calling either dependency, which keeps
/// model resolution and inference lazy when Auto routing finds no OCR work.
#[allow(clippy::too_many_arguments)]
pub fn run_ocr_pages<R, O>(
renderer: &R,
engine: &O,
pdf_bytes: &[u8],
pages: &[u32],
password: Option<&str>,
render_options: &RenderOptions,
ocr_options: &OcrOptions,
) -> Result<OcrRun, OcrRunError>
where
R: PageRenderer,
O: OcrEngine,
{
if pages.is_empty() {
return Ok(OcrRun {
pages: Vec::new(),
render_time_ms: 0,
ocr_time_ms: 0,
});
}
if ocr_options.mode == OcrMode::Off {
return Err(OcrRunError::OcrDisabled);
}
let render_started = Instant::now();
let rendered = renderer
.render_pages(pdf_bytes, pages, password, render_options)
.map_err(|source| OcrRunError::Render {
source: Box::new(source),
})?;
let render_time_ms = elapsed_ms(render_started);
validate_page_order("renderer", pages, rendered.iter().map(RenderedPage::page))?;
let ocr_started = Instant::now();
let recognized =
engine
.recognize(&rendered, ocr_options)
.map_err(|source| OcrRunError::Ocr {
source: Box::new(source),
})?;
let ocr_time_ms = elapsed_ms(ocr_started);
validate_page_order("OCR engine", pages, recognized.iter().map(|page| page.page))?;
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: 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);
}
}
Binary file not shown.
-68
View File
@@ -1,68 +0,0 @@
%PDF-1.3
%“Œ‹ž ReportLab Generated PDF document (opensource)
1 0 obj
<<
/F1 2 0 R
>>
endobj
2 0 obj
<<
/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font
>>
endobj
3 0 obj
<<
/Contents 7 0 R /MediaBox [ 0 0 612 792 ] /Parent 6 0 R /Resources <<
/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ]
>> /Rotate 0 /Trans <<
>>
/Type /Page
>>
endobj
4 0 obj
<<
/PageMode /UseNone /Pages 6 0 R /Type /Catalog
>>
endobj
5 0 obj
<<
/Author (anonymous) /CreationDate (D:20260803112923+00'00') /Creator (anonymous) /Keywords () /ModDate (D:20260803112923+00'00') /Producer (ReportLab PDF Library - \(opensource\))
/Subject (unspecified) /Title (untitled) /Trapped /False
>>
endobj
6 0 obj
<<
/Count 1 /Kids [ 3 0 R ] /Type /Pages
>>
endobj
7 0 obj
<<
/Filter [ /ASCII85Decode /FlateDecode ] /Length 202
>>
stream
GarW05mr9@&;9NOME,dW.,B;'jAjYq0S4Z`*D9aMA;]$5J)A/$3lESen1?F)ZJsa4$4&N%-%cs)#qW5EVhhbPiRDrAV>MC%.spto@CU"ZdipR'TtFiMR_%m*Hm$N%qL7a"ckkp9T/s[N2"Og377mP*M^akb2XQZ@'l*qT(9bVtDb5+)S&Q.#%E)<]Ao`TSk2AE'/E\fn~>endstream
endobj
xref
0 8
0000000000 65535 f
0000000061 00000 n
0000000092 00000 n
0000000199 00000 n
0000000392 00000 n
0000000460 00000 n
0000000721 00000 n
0000000780 00000 n
trailer
<<
/ID
[<6d7ea1213c5974c78613d5d2a08423b5><6d7ea1213c5974c78613d5d2a08423b5>]
% ReportLab generated PDF document -- digest (opensource)
/Info 5 0 R
/Root 4 0 R
/Size 8
>>
startxref
9072
%%EOF
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
+26 -1628
View File
File diff suppressed because it is too large Load Diff
-65
View File
@@ -1,65 +0,0 @@
#![cfg(all(feature = "render-pdfium", not(target_arch = "wasm32")))]
use pdf_inspector::vision::{PdfiumRenderer, RenderError, RenderOptions, RenderPixelFormat};
fn load_renderer() -> Option<PdfiumRenderer> {
match PdfiumRenderer::load() {
Ok(renderer) => Some(renderer),
Err(RenderError::Pdfium(firecrawl_pdfium::Error::Load(
firecrawl_pdfium::LoadError::LibraryNotFound { .. },
))) => {
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 { .. })
));
}

Some files were not shown because too many files have changed in this diff Show More