Compare commits
50
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d590b2f194 | ||
|
|
1719d24871 | ||
|
|
f114e79c8b | ||
|
|
544538b99f | ||
|
|
c8ba909407 | ||
|
|
076183e2e4 | ||
|
|
ec6e54afb8 | ||
|
|
89dd20d02c | ||
|
|
3d33ff3dbd | ||
|
|
75e9b09593 | ||
|
|
f4aab3b36f | ||
|
|
f65b25906c | ||
|
|
9947485a92 | ||
|
|
7054d6aa69 | ||
|
|
a67ee03269 | ||
|
|
965dc65f1b | ||
|
|
36dd5fa426 | ||
|
|
fabec0aec3 | ||
|
|
1f28c00a13 | ||
|
|
f4b8c9e854 | ||
|
|
69039f2728 | ||
|
|
3cca6446bd | ||
|
|
493fed498e | ||
|
|
436af97038 | ||
|
|
f731e1191c | ||
|
|
54a1e9ab74 | ||
|
|
fabbb63521 | ||
|
|
585d36e6a6 | ||
|
|
371de80b14 | ||
|
|
ede48099c0 | ||
|
|
12e9a655e3 | ||
|
|
1d134e26aa | ||
|
|
bfd6c3eabb | ||
|
|
04abab951f | ||
|
|
a410d5aa08 | ||
|
|
7747b3a086 | ||
|
|
ae6246ba0c | ||
|
|
3fb545284b | ||
|
|
1c32e4bd69 | ||
|
|
8121ae97ce | ||
|
|
98990cc550 | ||
|
|
a15ec2d68d | ||
|
|
7b7960ee73 | ||
|
|
7188667045 | ||
|
|
6ff104409a | ||
|
|
5e8f1570f6 | ||
|
|
b31e4b1727 | ||
|
|
3c6eb8bf6b | ||
|
|
5b287341a0 | ||
|
|
55d50ad1c4 |
+58
-11
@@ -14,13 +14,18 @@ jobs:
|
||||
name: Test
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
|
||||
- name: Check package version sync
|
||||
run: python3 scripts/version.py --check
|
||||
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
||||
@@ -32,29 +37,34 @@ jobs:
|
||||
name: Format
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # 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@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
components: clippy
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
with:
|
||||
key: clippy
|
||||
|
||||
@@ -68,15 +78,52 @@ jobs:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Cache cargo
|
||||
uses: Swatinem/rust-cache@v2
|
||||
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
with:
|
||||
key: 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
|
||||
|
||||
@@ -24,13 +24,13 @@ jobs:
|
||||
name: github-pages
|
||||
url: ${{ steps.deploy.outputs.page_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Upload site artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
||||
with:
|
||||
path: site
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deploy
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
||||
|
||||
@@ -27,10 +27,13 @@ jobs:
|
||||
published: ${{ steps.check.outputs.published }}
|
||||
version: ${{ steps.check.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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: |
|
||||
@@ -85,17 +88,19 @@ jobs:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
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@v1
|
||||
uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5
|
||||
|
||||
- name: Publish crate
|
||||
run: cargo publish
|
||||
|
||||
@@ -24,10 +24,13 @@ jobs:
|
||||
published: ${{ steps.check.outputs.published }}
|
||||
version: ${{ steps.check.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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: |
|
||||
@@ -98,21 +101,21 @@ jobs:
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Build wheel
|
||||
uses: PyO3/maturin-action@v1
|
||||
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@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: wheels-${{ matrix.target }}
|
||||
path: dist/*.whl
|
||||
@@ -124,16 +127,16 @@ jobs:
|
||||
name: Build sdist
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Build sdist
|
||||
uses: PyO3/maturin-action@v1
|
||||
uses: PyO3/maturin-action@e83996d129638aa358a18fbd1dfb82f0b0fb5d3b # v1.51.0
|
||||
with:
|
||||
command: sdist
|
||||
args: --out dist
|
||||
|
||||
- name: Upload sdist
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: sdist
|
||||
path: dist/*.tar.gz
|
||||
@@ -149,7 +152,7 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
@@ -158,7 +161,7 @@ jobs:
|
||||
run: ls -la dist/
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
uses: pypa/gh-action-pypi-publish@dc37677b2e1c63e2034f94d8a5b11f265b73ba33 # release/v1
|
||||
with:
|
||||
packages-dir: dist
|
||||
# Tolerate already-uploaded files so a manual re-run can complete
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
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
|
||||
@@ -24,10 +24,13 @@ jobs:
|
||||
changed: ${{ steps.check.outputs.changed }}
|
||||
version: ${{ steps.check.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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: |
|
||||
@@ -61,31 +64,61 @@ 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@v4
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
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@v4
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
path: |
|
||||
~/.cargo/registry/index/
|
||||
~/.cargo/registry/cache/
|
||||
~/.cargo/git/db/
|
||||
~/.napi-rs
|
||||
napi/target/
|
||||
key: ${{ runner.os }}-cargo-napi-${{ hashFiles('**/Cargo.lock') }}
|
||||
key: ${{ matrix.target }}-cargo-napi-${{ hashFiles('**/Cargo.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-cargo-napi-
|
||||
${{ matrix.target }}-cargo-napi-
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: napi
|
||||
@@ -93,10 +126,10 @@ jobs:
|
||||
|
||||
- name: Build native addon
|
||||
working-directory: napi
|
||||
run: bunx napi build --platform --release
|
||||
run: bunx napi build --platform --release --target ${{ matrix.target }} ${{ matrix.build-flags }}
|
||||
|
||||
- name: Upload native binary
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: bindings-${{ matrix.target }}
|
||||
path: napi/*.node
|
||||
@@ -104,7 +137,7 @@ jobs:
|
||||
|
||||
- name: Upload generated JS bindings
|
||||
if: matrix.target == 'x86_64-unknown-linux-gnu'
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: js-bindings
|
||||
path: |
|
||||
@@ -112,23 +145,68 @@ 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]
|
||||
needs: [check-version, build, smoke-test]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '24'
|
||||
registry-url: 'https://registry.npmjs.org'
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
path: napi/artifacts
|
||||
|
||||
@@ -154,9 +232,12 @@ jobs:
|
||||
node -e '
|
||||
const [suffix, version] = process.argv.slice(1)
|
||||
const meta = {
|
||||
"linux-x64-gnu": { os: ["linux"], cpu: ["x64"], libc: ["glibc"] },
|
||||
"darwin-arm64": { os: ["darwin"], cpu: ["arm64"] },
|
||||
"win32-x64-msvc": { os: ["win32"], cpu: ["x64"] },
|
||||
"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`)
|
||||
|
||||
+5
-3
@@ -1,10 +1,13 @@
|
||||
# Rust build artifacts
|
||||
/target/
|
||||
/wasm/target/
|
||||
/wasm/pkg/
|
||||
debug/
|
||||
*.pdb
|
||||
|
||||
# Cargo lock (optional for libraries)
|
||||
Cargo.lock
|
||||
!/wasm/Cargo.lock
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
@@ -28,15 +31,14 @@ Thumbs.db
|
||||
napi/index.js
|
||||
napi/index.d.ts
|
||||
|
||||
# Local samples and scripts
|
||||
# Local samples
|
||||
samples/
|
||||
scripts/
|
||||
|
||||
# Test output
|
||||
test_output/
|
||||
.firecrawl/
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
|
||||
|
||||
@@ -61,7 +61,8 @@ 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 179+ snapshot PDFs. Run `cargo build --release` then `bench.py test` in that repo before committing.
|
||||
- **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".
|
||||
|
||||
## Debugging
|
||||
|
||||
|
||||
@@ -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 187+ snapshot PDFs. Run `cargo build --release` then `bench.py test` in that repo before committing.
|
||||
- **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".
|
||||
|
||||
## Debugging
|
||||
|
||||
+19
-13
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "pdf-inspector"
|
||||
version = "0.1.6"
|
||||
version = "1.14.2"
|
||||
edition = "2021"
|
||||
autobins = false
|
||||
authors = ["Firecrawl Team"]
|
||||
@@ -12,13 +12,13 @@ readme = "docs/rust-api.md"
|
||||
# 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",
|
||||
"/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",
|
||||
"/pdf_inspector.pyi",
|
||||
]
|
||||
|
||||
[lib]
|
||||
@@ -29,18 +29,11 @@ crate-type = ["lib", "cdylib"]
|
||||
# Python bindings
|
||||
pyo3 = { version = "0.25", features = ["extension-module", "abi3-py38"], optional = true }
|
||||
|
||||
# PDF parsing
|
||||
lopdf = { version = "0.41.0", 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"
|
||||
@@ -50,6 +43,19 @@ 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"
|
||||
|
||||
# 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"
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
[](https://pypi.org/project/pdf-inspector/)
|
||||
[](LICENSE)
|
||||
|
||||
Fast Rust library for PDF classification and text extraction. Detects whether a PDF is text-based or scanned, extracts text with position awareness, and converts to clean Markdown — all without OCR. Includes bindings for [Python](docs/python.md) and [Node.js](napi/README.md).
|
||||
Fast Rust library for PDF classification and text extraction. Detects whether a PDF is text-based or scanned, extracts text with position awareness, and converts to clean Markdown — all without OCR. Includes bindings for [Python](docs/python.md), [Node.js](napi/README.md), and [browser WebAssembly](wasm/README.md).
|
||||
|
||||
Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in under 200ms, skipping expensive OCR services for the ~54% of PDFs that don't need them.
|
||||
|
||||
@@ -19,6 +19,7 @@ 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
|
||||
@@ -27,17 +28,17 @@ Evaluated on the [opendataloader-bench](https://github.com/opendataloader-projec
|
||||
|
||||
| Engine | Overall | Reading Order (NID) | Tables (TEDS) | Headings (MHS) | Speed (200 docs) |
|
||||
|---|---|---|---|---|---|
|
||||
| pdf-inspector | **0.875** | **0.915** | **0.814** | 0.788 | **2.8s** |
|
||||
| liteparse | 0.870 | 0.908 | 0.693 | **0.811** | 13.9s |
|
||||
| opendataloader | 0.843 | 0.912 | 0.489 | 0.760 | 9.8s |
|
||||
| pymupdf4llm | 0.735 | 0.886 | 0.401 | 0.424 | 15.5s |
|
||||
| markitdown | 0.583 | 0.879 | 0.000 | 0.000 | 6.7s |
|
||||
| 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 |
|
||||
|
||||
Results were refreshed on July 16, 2026, on an Apple M4 Pro. Engine versions were pdf-inspector 0.1.6, LiteParse 2.6.0, OpenDataLoader 2.1.1, PyMuPDF4LLM 0.2.0, and MarkItDown 0.1.4. Speed is the median of three complete corpus runs.
|
||||
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 or model-based document parsing (docling, marker, mineru) score 0.83-0.88 overall but take 2-180 minutes on the same corpus — pdf-inspector reaches the top of that range without either, in 2.8 seconds.
|
||||
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).
|
||||
|
||||
**Best fit:** Native-text PDFs where speed, reading order, and table structure matter. pdf-inspector delivered the highest overall, reading-order, and table scores, along with the fastest complete run in this benchmark. 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.
|
||||
**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.
|
||||
|
||||
@@ -77,6 +78,26 @@ 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):
|
||||
@@ -89,7 +110,7 @@ Or add it manually:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
pdf-inspector = "0.1"
|
||||
pdf-inspector = "1"
|
||||
```
|
||||
|
||||
```rust
|
||||
@@ -188,6 +209,7 @@ 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
|
||||
@@ -216,7 +238,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 |
|
||||
|
||||
+4
-3
@@ -5,14 +5,15 @@
|
||||
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:** Email **help@firecrawl.dev** with:
|
||||
**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:** Use GitHub's private vulnerability reporting under the
|
||||
[Security tab](https://github.com/firecrawl/pdf-inspector/security/advisories/new).
|
||||
**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
|
||||
|
||||
@@ -30,11 +30,14 @@ overwrite one another.
|
||||
|
||||
## Published comparison protocol
|
||||
|
||||
The public benchmark table was refreshed on July 16, 2026, on an Apple M4 Pro
|
||||
using pdf-inspector 0.1.6, LiteParse 2.6.0, OpenDataLoader 2.1.1,
|
||||
PyMuPDF4LLM 0.2.0, and MarkItDown 0.1.4. Every engine processed the same 200
|
||||
PDFs with OCR disabled. Reported speed is the median of three complete corpus
|
||||
runs; quality scores come from the benchmark evaluator over all 200 outputs.
|
||||
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
|
||||
|
||||
|
||||
+48
-12
@@ -1,21 +1,57 @@
|
||||
# Publishing
|
||||
|
||||
The Rust crate is published to [crates.io](https://crates.io/crates/pdf-inspector) with trusted publishing from GitHub Actions. The first release was published manually; future releases publish from `.github/workflows/publish-crate.yml` when a `Cargo.toml` version change lands on `main`.
|
||||
Every pdf-inspector distribution uses one shared semantic version:
|
||||
|
||||
## crates.io Trusted Publisher
|
||||
- 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
|
||||
|
||||
Configure the trusted publisher for the `pdf-inspector` crate with:
|
||||
`Cargo.toml` is the canonical version source. Update every manifest and lockfile
|
||||
with:
|
||||
|
||||
- Repository: `firecrawl/pdf-inspector`
|
||||
- Workflow: `publish-crate.yml`
|
||||
- Environment: `crates-io`
|
||||
```bash
|
||||
python3 scripts/version.py <version>
|
||||
```
|
||||
|
||||
The workflow uses `rust-lang/crates-io-auth-action@v1` to exchange GitHub's OIDC token for a short-lived crates.io token, then passes it to `cargo publish`.
|
||||
Verify that nothing has diverged with:
|
||||
|
||||
## Release Steps
|
||||
```bash
|
||||
python3 scripts/version.py --check
|
||||
```
|
||||
|
||||
1. Update `version` in `Cargo.toml`.
|
||||
2. Merge the version bump to `main`.
|
||||
3. The publish workflow compares the new `Cargo.toml` version with `HEAD~1`, runs `cargo publish --dry-run`, then publishes if that version is not already on crates.io.
|
||||
CI and every publishing workflow run this check before building or publishing.
|
||||
|
||||
If `Cargo.toml` changes without a package version bump, the workflow exits without 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
|
||||
```
|
||||
|
||||
+39
-9
@@ -18,13 +18,13 @@ Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in
|
||||
|
||||
| Engine | Overall | Reading order | Tables (TEDS) | Headings | Speed |
|
||||
|---|---|---|---|---|---|
|
||||
| **pdf-inspector** | **0.875** | **0.915** | **0.814** | 0.788 | **2.8s** |
|
||||
| liteparse | 0.870 | 0.908 | 0.693 | **0.811** | 13.9s |
|
||||
| opendataloader | 0.843 | 0.912 | 0.489 | 0.760 | 9.8s |
|
||||
| pymupdf4llm | 0.735 | 0.886 | 0.401 | 0.424 | 15.5s |
|
||||
| markitdown | 0.583 | 0.879 | 0.000 | 0.000 | 6.7s |
|
||||
| **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 16, 2026, on Apple M4 Pro; speed is the median of three complete corpus runs. Full methodology and versions are in the [repo README](https://github.com/firecrawl/pdf-inspector#benchmark).
|
||||
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
|
||||
|
||||
@@ -80,6 +80,17 @@ 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
|
||||
@@ -100,6 +111,8 @@ result = pdf_inspector.extract_pages_markdown("document.pdf", pages=[0, 2])
|
||||
| `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
|
||||
|
||||
@@ -111,7 +124,8 @@ class PdfResult: # process_pdf / detect_pdf
|
||||
markdown: str | None # extracted Markdown (None for detect_pdf)
|
||||
page_count: int
|
||||
processing_time_ms: int
|
||||
pages_needing_ocr: list[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
|
||||
@@ -119,6 +133,10 @@ class PdfResult: # process_pdf / detect_pdf
|
||||
pages_with_columns: list[int]
|
||||
has_encoding_issues: bool # broken font encodings — consider OCR fallback
|
||||
|
||||
class PageOcrReasons: # per-page OCR diagnostics
|
||||
page: int # 1-indexed
|
||||
reasons: list[str] # machine-readable reason identifiers
|
||||
|
||||
class PdfClassification: # classify_pdf
|
||||
pdf_type: str
|
||||
page_count: int
|
||||
@@ -139,15 +157,27 @@ class TextItem: # extract_text_with_positions
|
||||
is_underline: bool
|
||||
is_strikeout: bool
|
||||
item_type: str
|
||||
mcid: int | None # marked-content ID for tagged PDFs (None otherwise)
|
||||
|
||||
class StructureElement: # extract_structure_elements
|
||||
page: int # 1-indexed (matches TextItem.page)
|
||||
mcid: int
|
||||
role: str # "H1".."H6", "P", "Table", ... (resolved via /RoleMap)
|
||||
|
||||
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] # RegionText: text: str, needs_ocr: bool
|
||||
regions: list[RegionText]
|
||||
|
||||
class PagesExtractionResult: # extract_pages_markdown
|
||||
pages: list[PageMarkdown] # PageMarkdown: page (0-indexed), markdown, needs_ocr
|
||||
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
|
||||
```
|
||||
|
||||
+38
-7
@@ -18,13 +18,13 @@ Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in
|
||||
|
||||
| Engine | Overall | Reading order | Tables (TEDS) | Headings | Speed |
|
||||
|---|---|---|---|---|---|
|
||||
| **pdf-inspector** | **0.875** | **0.915** | **0.814** | 0.788 | **2.8s** |
|
||||
| liteparse | 0.870 | 0.908 | 0.693 | **0.811** | 13.9s |
|
||||
| opendataloader | 0.843 | 0.912 | 0.489 | 0.760 | 9.8s |
|
||||
| pymupdf4llm | 0.735 | 0.886 | 0.401 | 0.424 | 15.5s |
|
||||
| markitdown | 0.583 | 0.879 | 0.000 | 0.000 | 6.7s |
|
||||
| **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 16, 2026, on Apple M4 Pro; speed is the median of three complete corpus runs. Full methodology and versions are in the [repo README](https://github.com/firecrawl/pdf-inspector#benchmark).
|
||||
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
|
||||
|
||||
@@ -138,6 +138,34 @@ 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 |
|
||||
@@ -163,6 +191,8 @@ println!("Complex layout? {}", result.is_complex);
|
||||
| `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`.
|
||||
|
||||
@@ -178,7 +208,8 @@ 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, and page number |
|
||||
| `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"`, …) |
|
||||
| `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 |
|
||||
|
||||
Generated
+26
-4
@@ -499,11 +499,13 @@ 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]]
|
||||
@@ -557,6 +559,25 @@ 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"
|
||||
@@ -672,9 +693,9 @@ checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lopdf"
|
||||
version = "0.41.0"
|
||||
version = "0.42.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67513274c50a2b51e5f75d9e682fcf4ab064a8a9c9ae2c3c59309084882bb24d"
|
||||
checksum = "25aab26d99567469098e64a02f42679f8965c6401263eefa31d8f2dcc37a221c"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"bitflags",
|
||||
@@ -830,9 +851,10 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "pdf-inspector"
|
||||
version = "0.1.6"
|
||||
version = "1.14.2"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"include_dir",
|
||||
"log",
|
||||
"lopdf",
|
||||
"once_cell",
|
||||
@@ -845,7 +867,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pdf-inspector-napi"
|
||||
version = "0.2.2"
|
||||
version = "1.14.2"
|
||||
dependencies = [
|
||||
"napi",
|
||||
"napi-build",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "pdf-inspector-napi"
|
||||
version = "0.2.2"
|
||||
version = "1.14.2"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
|
||||
+29
-10
@@ -18,13 +18,13 @@ Built by [Firecrawl](https://firecrawl.dev) for hybrid OCR pipelines — extract
|
||||
|
||||
| Engine | Overall | Reading order | Tables (TEDS) | Headings | Speed |
|
||||
|---|---|---|---|---|---|
|
||||
| **pdf-inspector** | **0.875** | **0.915** | **0.814** | 0.788 | **2.8s** |
|
||||
| liteparse | 0.870 | 0.908 | 0.693 | **0.811** | 13.9s |
|
||||
| opendataloader | 0.843 | 0.912 | 0.489 | 0.760 | 9.8s |
|
||||
| pymupdf4llm | 0.735 | 0.886 | 0.401 | 0.424 | 15.5s |
|
||||
| markitdown | 0.583 | 0.879 | 0.000 | 0.000 | 6.7s |
|
||||
| **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 16, 2026, on Apple M4 Pro; speed is the median of three complete corpus runs. Full methodology and versions are in the [repo README](https://github.com/firecrawl/pdf-inspector#benchmark).
|
||||
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
|
||||
|
||||
@@ -34,7 +34,7 @@ npm install @firecrawl/pdf-inspector
|
||||
bun add @firecrawl/pdf-inspector
|
||||
```
|
||||
|
||||
Prebuilt binaries for **Linux x64**, **macOS ARM64**, and **Windows x64** — npm installs only the one matching your platform. No Rust toolchain needed.
|
||||
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.
|
||||
|
||||
## API
|
||||
|
||||
@@ -83,6 +83,22 @@ 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
|
||||
@@ -116,9 +132,12 @@ Prebuilt binaries ship as platform-specific packages installed automatically via
|
||||
|
||||
| Platform | Architecture | Package |
|
||||
|----------|-------------|---------|
|
||||
| Linux | x64 (glibc) | `@firecrawl/pdf-inspector-linux-x64-gnu` |
|
||||
| macOS | ARM64 | `@firecrawl/pdf-inspector-darwin-arm64` |
|
||||
| Windows | x64 | `@firecrawl/pdf-inspector-win32-x64-msvc` |
|
||||
| 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` |
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+6
-3
@@ -8,9 +8,12 @@
|
||||
"@napi-rs/cli": "^3.4.1",
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@firecrawl/pdf-inspector-darwin-arm64": "1.11.0",
|
||||
"@firecrawl/pdf-inspector-linux-x64-gnu": "1.11.0",
|
||||
"@firecrawl/pdf-inspector-win32-x64-msvc": "1.11.0",
|
||||
"@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",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
+10
-4
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@firecrawl/pdf-inspector",
|
||||
"version": "1.11.1",
|
||||
"version": "1.14.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",
|
||||
@@ -37,6 +37,9 @@
|
||||
"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"
|
||||
]
|
||||
@@ -49,8 +52,11 @@
|
||||
"@napi-rs/cli": "^3.4.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@firecrawl/pdf-inspector-linux-x64-gnu": "1.11.1",
|
||||
"@firecrawl/pdf-inspector-darwin-arm64": "1.11.1",
|
||||
"@firecrawl/pdf-inspector-win32-x64-msvc": "1.11.1"
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
+236
-41
@@ -89,6 +89,11 @@ pub struct TextItem {
|
||||
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).
|
||||
@@ -153,9 +158,7 @@ fn to_napi_result(r: pdf_inspector::PdfProcessResult) -> PdfResult {
|
||||
}
|
||||
}
|
||||
|
||||
fn to_napi_page_ocr_reasons(
|
||||
reasons: Vec<pdf_inspector::PageOcrReasons>,
|
||||
) -> Vec<PageOcrReasons> {
|
||||
fn to_napi_page_ocr_reasons(reasons: Vec<pdf_inspector::PageOcrReasons>) -> Vec<PageOcrReasons> {
|
||||
reasons
|
||||
.into_iter()
|
||||
.map(|reason| PageOcrReasons {
|
||||
@@ -202,6 +205,31 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -210,15 +238,7 @@ where
|
||||
#[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 || {
|
||||
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))
|
||||
})
|
||||
catch_panic("process_pdf", move || process_pdf_impl(&bytes, pages))
|
||||
}
|
||||
|
||||
/// Fast detection only — no text extraction or markdown.
|
||||
@@ -238,16 +258,7 @@ 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 || {
|
||||
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,
|
||||
})
|
||||
})
|
||||
catch_panic("classify_pdf", move || classify_pdf_impl(&bytes))
|
||||
}
|
||||
|
||||
/// Extract plain text from a PDF Buffer.
|
||||
@@ -300,12 +311,61 @@ pub fn extract_text_with_positions(
|
||||
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,
|
||||
@@ -633,25 +693,32 @@ pub fn extract_pages_markdown(
|
||||
) -> Result<PagesExtractionResult> {
|
||||
let bytes: Vec<u8> = buffer.to_vec();
|
||||
catch_panic("extract_pages_markdown", move || {
|
||||
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,
|
||||
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,
|
||||
})
|
||||
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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -692,3 +759,131 @@ fn to_page_region_texts(results: Vec<pdf_inspector::PageRegionResult>) -> Vec<Pa
|
||||
})
|
||||
.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
@@ -2,16 +2,21 @@ 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...');
|
||||
@@ -79,6 +84,46 @@ 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, [
|
||||
@@ -124,10 +169,75 @@ 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!');
|
||||
|
||||
@@ -10,6 +10,9 @@ 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
|
||||
@@ -17,6 +20,13 @@ 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
|
||||
@@ -41,12 +51,28 @@ class TextItem:
|
||||
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."""
|
||||
@@ -62,6 +88,8 @@ 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."""
|
||||
@@ -73,6 +101,8 @@ 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."""
|
||||
|
||||
@@ -116,6 +146,27 @@ 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]]]],
|
||||
|
||||
+3
-3
@@ -4,9 +4,9 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "pdf-inspector"
|
||||
# Bump this to publish to PyPI — CI publishes automatically when the version
|
||||
# changes on main (same flow as napi/package.json for npm).
|
||||
version = "0.2.5"
|
||||
# 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"
|
||||
description = "Fast PDF inspection, classification, and text extraction with smart scanned vs text-based detection"
|
||||
readme = "docs/python.md"
|
||||
license = { text = "MIT" }
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
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()
|
||||
@@ -0,0 +1,262 @@
|
||||
#!/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())
|
||||
+1100
-355
File diff suppressed because it is too large
Load Diff
@@ -63,6 +63,7 @@ fn json_escape(s: &str) -> String {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
env_logger::init();
|
||||
let args: Vec<String> = env::args().collect();
|
||||
|
||||
|
||||
+33
-5
@@ -2,8 +2,8 @@
|
||||
|
||||
use pdf_inspector::extractor::ItemType;
|
||||
use pdf_inspector::{
|
||||
extract_text_with_positions_pages, process_pdf_with_options, LayoutComplexity, PdfOptions,
|
||||
PdfType, ProcessMode, TextItem,
|
||||
extract_text_with_positions_pages_with_password, process_pdf_with_options, LayoutComplexity,
|
||||
PdfOptions, PdfType, ProcessMode, TextItem,
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
@@ -103,9 +103,18 @@ fn format_items_json(items: &[TextItem]) -> String {
|
||||
)
|
||||
}
|
||||
|
||||
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::format_items_json;
|
||||
use super::{extract_items_json, format_items_json};
|
||||
use pdf_inspector::extractor::ItemType;
|
||||
use pdf_inspector::TextItem;
|
||||
|
||||
@@ -137,6 +146,24 @@ mod tests {
|
||||
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.
|
||||
@@ -190,6 +217,7 @@ fn print_layout_info(layout: &LayoutComplexity) {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
env_logger::init();
|
||||
let args: Vec<String> = env::args().collect();
|
||||
|
||||
@@ -256,8 +284,8 @@ fn main() {
|
||||
});
|
||||
|
||||
if items_json_output {
|
||||
match extract_text_with_positions_pages(pdf_path, page_filter.as_ref()) {
|
||||
Ok(items) => println!("{}", format_items_json(&items)),
|
||||
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);
|
||||
|
||||
+163
-25
@@ -1382,7 +1382,13 @@ fn scan_content_for_text_operators(
|
||||
let is_word_end =
|
||||
|pos: usize| -> bool { pos + 1 >= content.len() || content[pos + 1].is_ascii_whitespace() };
|
||||
|
||||
// Simple state machine to find operators
|
||||
// Simple state machine to find operators.
|
||||
// Each Tj/TJ/Tf lookback stops at the previous text/font operator so a
|
||||
// malformed `] TJ` (no `[`) cannot rescan the entire prefix — that was
|
||||
// quadratic in the number of operators.
|
||||
// `Tj`/`TJ` are only counted when the preceding token closes a string or
|
||||
// array (')', '>', ']'), so `Tj` inside `(Hello Tj World)` cannot pin the floor.
|
||||
let mut operand_floor = 0usize;
|
||||
let mut i = 0;
|
||||
while i < content.len() {
|
||||
let b = content[i];
|
||||
@@ -1392,14 +1398,15 @@ fn scan_content_for_text_operators(
|
||||
let next = content[i + 1];
|
||||
if next == b'j' || next == b'J' {
|
||||
// Verify it's an operator (followed by whitespace or newline)
|
||||
if i + 2 >= content.len()
|
||||
if (i + 2 >= content.len()
|
||||
|| content[i + 2].is_ascii_whitespace()
|
||||
|| content[i + 2] == b'\n'
|
||||
|| content[i + 2] == b'\r'
|
||||
|| content[i + 2] == b'\r')
|
||||
&& preceding_operand_closer(content, i, operand_floor)
|
||||
{
|
||||
text_ops += 1;
|
||||
// Scan backward for text string operand to collect unique chars
|
||||
collect_text_chars_before(content, i, unique_chars);
|
||||
collect_text_chars_before(content, i, unique_chars, operand_floor);
|
||||
operand_floor = i;
|
||||
}
|
||||
} else if next == b'f' {
|
||||
// Tf = set font operator
|
||||
@@ -1415,12 +1422,10 @@ fn scan_content_for_text_operators(
|
||||
|| content[i + 2] == b'<'
|
||||
|| content[i + 2] == b'/'
|
||||
{
|
||||
font_changes += 1;
|
||||
// Extract the font name operand preceding the size + Tf.
|
||||
// Pattern: /FontName <size> Tf
|
||||
// Scan backward past the size number and whitespace to find /Name.
|
||||
if let Some(name) = extract_font_name_before_tf(content, i) {
|
||||
if let Some(name) = extract_font_name_before_tf(content, i, operand_floor) {
|
||||
used_font_names.insert(name);
|
||||
font_changes += 1;
|
||||
operand_floor = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1466,6 +1471,20 @@ fn scan_content_for_text_operators(
|
||||
(text_ops, image_count, path_ops, font_changes)
|
||||
}
|
||||
|
||||
/// True when the token before `op_pos` (skipping whitespace, not crossing
|
||||
/// `floor`) is a string/array closer. Used so `Tj` inside `(Hello Tj World)`
|
||||
/// is not treated as an operator.
|
||||
fn preceding_operand_closer(content: &[u8], op_pos: usize, floor: usize) -> bool {
|
||||
let mut j = op_pos;
|
||||
while j > floor {
|
||||
j -= 1;
|
||||
if !content[j].is_ascii_whitespace() {
|
||||
return matches!(content[j], b')' | b'>' | b']');
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Extract the font name operand from content stream bytes preceding a Tf operator.
|
||||
///
|
||||
/// The Tf operator syntax is: `/FontName size Tf`
|
||||
@@ -1473,25 +1492,27 @@ fn scan_content_for_text_operators(
|
||||
/// whitespace to find the `/Name` token.
|
||||
///
|
||||
/// Returns the font name bytes (without the leading `/`), e.g. `b"F1"` for `/F1`.
|
||||
fn extract_font_name_before_tf(content: &[u8], tf_pos: usize) -> Option<Vec<u8>> {
|
||||
/// `floor` is the start of the previous text/font operator (or 0); lookback
|
||||
/// must not cross it.
|
||||
fn extract_font_name_before_tf(content: &[u8], tf_pos: usize, floor: usize) -> Option<Vec<u8>> {
|
||||
// Scan backward past whitespace before "Tf"
|
||||
let mut j = tf_pos;
|
||||
while j > 0 && content[j - 1].is_ascii_whitespace() {
|
||||
while j > floor && content[j - 1].is_ascii_whitespace() {
|
||||
j -= 1;
|
||||
}
|
||||
// Scan backward past the size number (digits, '.', '-')
|
||||
while j > 0
|
||||
while j > floor
|
||||
&& (content[j - 1].is_ascii_digit() || content[j - 1] == b'.' || content[j - 1] == b'-')
|
||||
{
|
||||
j -= 1;
|
||||
}
|
||||
// Scan backward past whitespace between font name and size
|
||||
while j > 0 && content[j - 1].is_ascii_whitespace() {
|
||||
while j > floor && content[j - 1].is_ascii_whitespace() {
|
||||
j -= 1;
|
||||
}
|
||||
// Now j should point just after the font name. Scan backward to find '/'.
|
||||
let name_end = j;
|
||||
while j > 0 && content[j - 1] != b'/' {
|
||||
while j > floor && content[j - 1] != b'/' {
|
||||
// Font names consist of regular characters (not whitespace, not delimiters)
|
||||
if content[j - 1].is_ascii_whitespace() || content[j - 1] == b'(' || content[j - 1] == b')'
|
||||
{
|
||||
@@ -1499,7 +1520,7 @@ fn extract_font_name_before_tf(content: &[u8], tf_pos: usize) -> Option<Vec<u8>>
|
||||
}
|
||||
j -= 1;
|
||||
}
|
||||
if j == 0 || content[j - 1] != b'/' {
|
||||
if j <= floor || content[j - 1] != b'/' {
|
||||
return None;
|
||||
}
|
||||
// j-1 is the '/', font name is content[j..name_end]
|
||||
@@ -1514,16 +1535,24 @@ fn extract_font_name_before_tf(content: &[u8], tf_pos: usize) -> Option<Vec<u8>>
|
||||
/// and collect unique non-whitespace bytes from it.
|
||||
///
|
||||
/// Handles both literal strings `(...)` and hex strings `<...>`.
|
||||
fn collect_text_chars_before(content: &[u8], op_pos: usize, unique_chars: &mut HashSet<u8>) {
|
||||
/// `floor` is the start of the previous text/font operator (or 0); lookback
|
||||
/// must not cross it, or a missing `[` before `TJ` rescans the whole prefix.
|
||||
fn collect_text_chars_before(
|
||||
content: &[u8],
|
||||
op_pos: usize,
|
||||
unique_chars: &mut HashSet<u8>,
|
||||
floor: usize,
|
||||
) {
|
||||
// Walk backward past whitespace to find the closing delimiter
|
||||
let mut j = op_pos;
|
||||
while j > 0 {
|
||||
while j > floor {
|
||||
j -= 1;
|
||||
if !content[j].is_ascii_whitespace() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if j == 0 {
|
||||
// All whitespace, or we landed on the previous operator token.
|
||||
if j == floor {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1533,7 +1562,7 @@ fn collect_text_chars_before(content: &[u8], op_pos: usize, unique_chars: &mut H
|
||||
// Literal string: scan backward for matching '('
|
||||
let mut depth = 1i32;
|
||||
let mut k = j;
|
||||
while k > 0 && depth > 0 {
|
||||
while k > floor && depth > 0 {
|
||||
k -= 1;
|
||||
match content[k] {
|
||||
b')' if k == 0 || content[k - 1] != b'\\' => depth += 1,
|
||||
@@ -1552,7 +1581,7 @@ fn collect_text_chars_before(content: &[u8], op_pos: usize, unique_chars: &mut H
|
||||
} else if closing == b'>' {
|
||||
// Hex string: scan backward for '<'
|
||||
let mut k = j;
|
||||
while k > 0 {
|
||||
while k > floor {
|
||||
k -= 1;
|
||||
if content[k] == b'<' {
|
||||
break;
|
||||
@@ -1582,7 +1611,7 @@ fn collect_text_chars_before(content: &[u8], op_pos: usize, unique_chars: &mut H
|
||||
} else if closing == b']' {
|
||||
// TJ array: scan backward for '[' and collect from all strings inside
|
||||
let mut k = j;
|
||||
while k > 0 {
|
||||
while k > floor {
|
||||
k -= 1;
|
||||
if content[k] == b'[' {
|
||||
break;
|
||||
@@ -1659,7 +1688,15 @@ 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)
|
||||
fn analyze_page_images(doc: &Document, page_id: ObjectId) -> (bool, u64, bool) {
|
||||
/// 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) {
|
||||
// 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
|
||||
@@ -1741,6 +1778,62 @@ fn analyze_page_images(doc: &Document, page_id: ObjectId) -> (bool, u64, bool) {
|
||||
(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(
|
||||
@@ -1950,6 +2043,51 @@ mod tests {
|
||||
assert_eq!(imgs3, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_content_successive_tj_collects_each_operand() {
|
||||
// Lookback is floored at the previous Tj/TJ/Tf so later operators must
|
||||
// still see their own operands.
|
||||
let content = b"[(Hello)] TJ [(World)] TJ (More) Tj";
|
||||
let mut uchars = HashSet::new();
|
||||
let (ops, _, _, _) =
|
||||
scan_content_for_text_operators(content, &mut uchars, &mut HashSet::new());
|
||||
assert_eq!(ops, 3);
|
||||
for &ch in b"HeloWrdM" {
|
||||
assert!(uchars.contains(&ch), "missing char {}", ch as char);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_content_tj_inside_literal_is_not_an_operator() {
|
||||
// `Tj` followed by space inside a literal must not count as an operator
|
||||
// or pin the lookback floor; the real `Tj` still collects the string.
|
||||
let content = b"BT (Hello Tj World) Tj ET";
|
||||
let mut uchars = HashSet::new();
|
||||
let (ops, _, _, _) =
|
||||
scan_content_for_text_operators(content, &mut uchars, &mut HashSet::new());
|
||||
assert_eq!(ops, 1);
|
||||
for &ch in b"HeloTjWrd" {
|
||||
assert!(uchars.contains(&ch), "missing char {}", ch as char);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_content_malformed_tj_lookback_stays_linear() {
|
||||
// `] TJ` with no `[` used to walk the entire prefix for every operator
|
||||
// (quadratic). 30k repeats is enough that a prefix rescan would dominate
|
||||
// the test runtime; with the floor it is a single linear pass.
|
||||
let n = 30_000usize;
|
||||
let mut content = Vec::with_capacity(n * 5);
|
||||
for _ in 0..n {
|
||||
content.extend_from_slice(b"] TJ\n");
|
||||
}
|
||||
let mut uchars = HashSet::new();
|
||||
let (ops, _, _, _) =
|
||||
scan_content_for_text_operators(&content, &mut uchars, &mut HashSet::new());
|
||||
assert_eq!(ops, n as u32);
|
||||
assert!(uchars.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_image_dominated_detection() {
|
||||
// Do operators are no longer counted as images by scan_content_for_text_operators.
|
||||
@@ -2708,14 +2846,14 @@ mod tests {
|
||||
fn test_extract_font_name_basic() {
|
||||
// Standard pattern: /F1 12 Tf
|
||||
let content = b"/F1 12 Tf";
|
||||
let name = extract_font_name_before_tf(content, 6); // 'T' is at index 6
|
||||
let name = extract_font_name_before_tf(content, 6, 0); // 'T' is at index 6
|
||||
assert_eq!(name, Some(b"F1".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_font_name_long_name() {
|
||||
let content = b"/ArialMT-Bold 9.5 Tf";
|
||||
let name = extract_font_name_before_tf(content, 18);
|
||||
let name = extract_font_name_before_tf(content, 18, 0);
|
||||
assert_eq!(name, Some(b"ArialMT-Bold".to_vec()));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
//! 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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
//! Bounded content-stream decoding.
|
||||
//!
|
||||
//! `lopdf::content::Content::decode` materializes every operator before any
|
||||
//! caller can apply a limit. A compact page of `q Q` pairs can therefore
|
||||
//! allocate hundreds of megabytes and abort. Count operators first (without
|
||||
//! allocating `Operation` objects) and skip decode when the cap is exceeded.
|
||||
|
||||
use crate::PdfError;
|
||||
use lopdf::content::Content;
|
||||
|
||||
/// Maximum content-stream operators decoded for a page or a single Form
|
||||
/// XObject. Matches the previous post-decode skip threshold.
|
||||
pub(crate) const MAX_PAGE_OPERATIONS: usize = 1_000_000;
|
||||
|
||||
/// Decode `data` unless it contains more than `max_operations` operators.
|
||||
///
|
||||
/// Returns `Ok(None)` when the stream exceeds the cap, so callers can skip
|
||||
/// extraction without first allocating the operation vector.
|
||||
pub(crate) fn decode_content_bounded(
|
||||
data: &[u8],
|
||||
max_operations: usize,
|
||||
) -> Result<Option<Content>, PdfError> {
|
||||
if content_exceeds_operation_limit(data, max_operations) {
|
||||
return Ok(None);
|
||||
}
|
||||
Content::decode(data)
|
||||
.map(Some)
|
||||
.map_err(|e| PdfError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
fn content_exceeds_operation_limit(data: &[u8], max_operations: usize) -> bool {
|
||||
count_content_operators(data, max_operations.saturating_add(1)) > max_operations
|
||||
}
|
||||
|
||||
/// Count operators using the same token rules as lopdf's content parser,
|
||||
/// stopping at `limit`. Does not allocate `Operation` / `Object` values.
|
||||
fn count_content_operators(data: &[u8], limit: usize) -> usize {
|
||||
let mut i = 0;
|
||||
let mut count = 0;
|
||||
while i < data.len() && count < limit {
|
||||
skip_content_space(data, &mut i);
|
||||
if i >= data.len() {
|
||||
break;
|
||||
}
|
||||
if data[i] == b'%' {
|
||||
skip_comment(data, &mut i);
|
||||
continue;
|
||||
}
|
||||
match data[i] {
|
||||
b'(' => i = skip_literal_string(data, i),
|
||||
b'<' => {
|
||||
if data.get(i + 1) == Some(&b'<') {
|
||||
i += 2;
|
||||
} else {
|
||||
i = skip_hex_string(data, i);
|
||||
}
|
||||
}
|
||||
b'>' => {
|
||||
i += 1;
|
||||
if data.get(i) == Some(&b'>') {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
b'[' | b']' => i += 1,
|
||||
b'/' => skip_name(data, &mut i),
|
||||
b'+' | b'-' | b'.' => skip_number(data, &mut i),
|
||||
b if b.is_ascii_digit() => skip_number(data, &mut i),
|
||||
b if is_operator_byte(b) => {
|
||||
let start = i;
|
||||
i += 1;
|
||||
while i < data.len() && is_operator_byte(data[i]) {
|
||||
i += 1;
|
||||
}
|
||||
let token = &data[start..i];
|
||||
if token == b"true" || token == b"false" || token == b"null" {
|
||||
continue;
|
||||
}
|
||||
count += 1;
|
||||
if token == b"BI" && (i >= data.len() || is_content_space(data[i])) {
|
||||
i = skip_inline_image_after_bi(data, i);
|
||||
}
|
||||
}
|
||||
_ => i += 1,
|
||||
}
|
||||
}
|
||||
count
|
||||
}
|
||||
|
||||
fn is_content_space(b: u8) -> bool {
|
||||
// PDF whitespace (ISO 32000): NUL, tab, LF, FF, CR, space. Names must
|
||||
// stop on these so a following operator is not absorbed into `/Name`.
|
||||
matches!(b, b'\0' | b'\t' | b'\n' | b'\x0c' | b'\r' | b' ')
|
||||
}
|
||||
|
||||
fn is_operator_byte(b: u8) -> bool {
|
||||
b.is_ascii_alphabetic() || matches!(b, b'*' | b'\'' | b'"')
|
||||
}
|
||||
|
||||
fn is_delimiter(b: u8) -> bool {
|
||||
matches!(
|
||||
b,
|
||||
b'(' | b')' | b'<' | b'>' | b'[' | b']' | b'{' | b'}' | b'/' | b'%'
|
||||
)
|
||||
}
|
||||
|
||||
fn skip_content_space(data: &[u8], i: &mut usize) {
|
||||
while *i < data.len() && is_content_space(data[*i]) {
|
||||
*i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn skip_comment(data: &[u8], i: &mut usize) {
|
||||
while *i < data.len() && data[*i] != b'\n' && data[*i] != b'\r' {
|
||||
*i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn skip_literal_string(data: &[u8], mut i: usize) -> usize {
|
||||
let mut depth = 1i32;
|
||||
i += 1;
|
||||
while i < data.len() && depth > 0 {
|
||||
match data[i] {
|
||||
b'\\' => {
|
||||
i += 1;
|
||||
if i < data.len() {
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
b'(' => {
|
||||
depth += 1;
|
||||
i += 1;
|
||||
}
|
||||
b')' => {
|
||||
depth -= 1;
|
||||
i += 1;
|
||||
}
|
||||
_ => i += 1,
|
||||
}
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
fn skip_hex_string(data: &[u8], mut i: usize) -> usize {
|
||||
i += 1;
|
||||
while i < data.len() && data[i] != b'>' {
|
||||
i += 1;
|
||||
}
|
||||
if i < data.len() {
|
||||
i += 1;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
fn skip_name(data: &[u8], i: &mut usize) {
|
||||
*i += 1;
|
||||
while *i < data.len() && !is_content_space(data[*i]) && !is_delimiter(data[*i]) {
|
||||
*i += 1;
|
||||
}
|
||||
}
|
||||
|
||||
fn skip_number(data: &[u8], i: &mut usize) {
|
||||
if *i < data.len() && matches!(data[*i], b'+' | b'-') {
|
||||
*i += 1;
|
||||
}
|
||||
while *i < data.len() && data[*i].is_ascii_digit() {
|
||||
*i += 1;
|
||||
}
|
||||
if *i < data.len() && data[*i] == b'.' {
|
||||
*i += 1;
|
||||
while *i < data.len() && data[*i].is_ascii_digit() {
|
||||
*i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// After a `BI` operator, skip inline-image data through `EI`.
|
||||
/// Uses the same PDF whitespace set as `is_content_space`. If `EI` is not
|
||||
/// found, leave the cursor in place so later operators are still counted
|
||||
/// (undercounting would let decode allocate the full vector).
|
||||
fn skip_inline_image_after_bi(data: &[u8], mut i: usize) -> usize {
|
||||
skip_content_space(data, &mut i);
|
||||
let rest = &data[i..];
|
||||
if let Some(pos) = rest.windows(4).position(|w| {
|
||||
is_content_space(w[0]) && w[1] == b'E' && w[2] == b'I' && is_content_space(w[3])
|
||||
}) {
|
||||
return i + pos + 3;
|
||||
}
|
||||
i
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn lopdf_op_count(data: &[u8]) -> usize {
|
||||
Content::decode(data)
|
||||
.map(|c| c.operations.len())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// DoS safety: never report fewer operators than lopdf would allocate.
|
||||
/// Overcount is acceptable (skip a page); undercount would re-open decode.
|
||||
fn assert_count_does_not_undercount(data: &[u8]) {
|
||||
let ours = count_content_operators(data, usize::MAX);
|
||||
match Content::decode(data) {
|
||||
Ok(content) => assert!(
|
||||
ours >= content.operations.len(),
|
||||
"undercount: ours={ours} lopdf={} for {:?}",
|
||||
content.operations.len(),
|
||||
String::from_utf8_lossy(data)
|
||||
),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn operator_count_matches_lopdf_for_typical_streams() {
|
||||
let samples: &[&[u8]] = &[
|
||||
b"q 1 0 0 1 0 0 cm BT /F1 12 Tf 72 720 Td (Hello) Tj ET Q",
|
||||
b"q Q q Q",
|
||||
b"BT /F1 12 Tf 12 TL 1 0 0 1 100 512 Tm (first) Tj (struck) ' ET",
|
||||
b"1 0 0 rg 0 0 10 10 re f",
|
||||
b"true false null q",
|
||||
b"% comment\nq Q\n",
|
||||
b"[ (a) 1 (b) ] TJ",
|
||||
b"1 0 0 1 0 0 cm /Im0 Do",
|
||||
];
|
||||
for data in samples {
|
||||
assert_eq!(
|
||||
count_content_operators(data, usize::MAX),
|
||||
lopdf_op_count(data),
|
||||
"count mismatch for {}",
|
||||
String::from_utf8_lossy(data)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strings_and_comments_are_not_operators() {
|
||||
let data = b"(q Q Tj) Tj % q Q\nET";
|
||||
assert_eq!(
|
||||
count_content_operators(data, usize::MAX),
|
||||
lopdf_op_count(data)
|
||||
);
|
||||
assert_eq!(count_content_operators(data, usize::MAX), 2); // Tj, ET
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_image_counts_as_one_operator() {
|
||||
let data = b"BI /W 2 /H 2 /CS /RGB /BPC 8 ID \x00\x01\x02\x03 EI q";
|
||||
assert_eq!(
|
||||
count_content_operators(data, usize::MAX),
|
||||
lopdf_op_count(data)
|
||||
);
|
||||
assert_eq!(count_content_operators(data, usize::MAX), 2); // BI, q
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_image_ei_accepts_pdf_whitespace() {
|
||||
let tab = b"BI /W 1 /H 1 ID \xff\tEI\t q Q";
|
||||
let nul = b"BI /W 1 /H 1 ID \xff\x00EI\x00 q Q";
|
||||
let ff = b"BI /W 1 /H 1 ID \xff\x0cEI\x0c q Q";
|
||||
for data in [tab.as_slice(), nul.as_slice(), ff.as_slice()] {
|
||||
assert_count_does_not_undercount(data);
|
||||
assert!(
|
||||
count_content_operators(data, usize::MAX) >= 3,
|
||||
"BI plus following q Q must remain visible after EI, got {} for {:?}",
|
||||
count_content_operators(data, usize::MAX),
|
||||
String::from_utf8_lossy(data)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn decode_is_skipped_when_operator_cap_is_exceeded() {
|
||||
let mut data = Vec::new();
|
||||
for _ in 0..20 {
|
||||
data.extend_from_slice(b"q Q\n");
|
||||
}
|
||||
assert!(decode_content_bounded(&data, 10).unwrap().is_none());
|
||||
let decoded = decode_content_bounded(&data, 50).unwrap().unwrap();
|
||||
assert_eq!(decoded.operations.len(), 40);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn name_whitespace_does_not_swallow_following_operator() {
|
||||
// NUL / form-feed end a name (PDF whitespace). Absorbing `q` into
|
||||
// `/x` would undercount and let decode allocate the operator vector.
|
||||
let mut nul_sep = Vec::new();
|
||||
let mut ff_sep = Vec::new();
|
||||
for _ in 0..8_000 {
|
||||
nul_sep.extend_from_slice(b"/x\x00q");
|
||||
ff_sep.extend_from_slice(b"/x\x0cq");
|
||||
}
|
||||
assert_count_does_not_undercount(&nul_sep);
|
||||
assert_count_does_not_undercount(&ff_sep);
|
||||
assert!(count_content_operators(&ff_sep, usize::MAX) >= 8_000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edge_streams_do_not_undercount_vs_lopdf() {
|
||||
let samples: &[&[u8]] = &[
|
||||
b".5 0 0 .5 0 0 cm",
|
||||
b"+1 -2 3.0 rg",
|
||||
b"<0041> Tj",
|
||||
b"(unbalanced",
|
||||
b"BI /W 1 /H 1 ID \xff\xff no EI here q Q q Q",
|
||||
b"q\x00Q\x00q\x00Q",
|
||||
b"/F1\x0c12 Tf (Hi) Tj",
|
||||
b"{ 1 2 add } cvx",
|
||||
];
|
||||
for data in samples {
|
||||
assert_count_does_not_undercount(data);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn million_q_pairs_are_rejected_without_decode() {
|
||||
let mut data = Vec::with_capacity((MAX_PAGE_OPERATIONS + 1) * 2);
|
||||
for _ in 0..=MAX_PAGE_OPERATIONS {
|
||||
data.extend_from_slice(b"q\n");
|
||||
}
|
||||
assert!(content_exceeds_operation_limit(&data, MAX_PAGE_OPERATIONS));
|
||||
assert!(decode_content_bounded(&data, MAX_PAGE_OPERATIONS)
|
||||
.unwrap()
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
+133
-30
@@ -14,12 +14,12 @@ use lopdf::{Document, Encoding, Object, ObjectId};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::fonts::{
|
||||
build_font_encodings, build_font_widths, compute_string_width_ts, descriptor_style_flags,
|
||||
extract_text_from_operand, get_font_file2_obj_num, get_operand_bytes, CMapDecisionCache,
|
||||
FontStyleCache,
|
||||
build_font_encodings, build_font_widths, build_type3_scales, compute_string_width_ts,
|
||||
descriptor_style_flags, extract_text_from_operand, get_font_file2_obj_num, get_operand_bytes,
|
||||
CMapDecisionCache, FontStyleCache,
|
||||
};
|
||||
use super::underline::UnderlineLine;
|
||||
use super::xobjects::{extract_form_xobject_text, get_page_xobjects, XObjectType};
|
||||
use super::xobjects::{extract_form_xobject_text, get_page_xobjects, FormWalkBudget, XObjectType};
|
||||
use super::{get_number, image_bbox_from_ctm, multiply_matrices};
|
||||
|
||||
/// Strip PDF comments (% to end of line) from content stream bytes.
|
||||
@@ -41,6 +41,17 @@ fn strip_pdf_comments(data: &[u8]) -> Vec<u8> {
|
||||
while i < data.len() {
|
||||
let b = data[i];
|
||||
match b {
|
||||
// Inside a string literal, a backslash escapes the next byte —
|
||||
// `\(`, `\)`, and `\\` must not touch the nesting depth, or a
|
||||
// later `%` glyph inside a string gets stripped as a comment,
|
||||
// corrupting the stream.
|
||||
b'\\' if in_string > 0 => {
|
||||
result.push(b);
|
||||
if let Some(&next) = data.get(i + 1) {
|
||||
result.push(next);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
b'(' if !in_hex_string => {
|
||||
in_string += 1;
|
||||
result.push(b);
|
||||
@@ -126,8 +137,11 @@ fn rise_adjusted(tm: &[f32; 6], rise: f32) -> [f32; 6] {
|
||||
]
|
||||
}
|
||||
|
||||
/// Returns `(page_extraction, has_gid_fonts)` where `has_gid_fonts` indicates
|
||||
/// the page uses fonts with unresolvable gid-encoded glyphs.
|
||||
/// Returns `(page_extraction, has_gid_fonts, coords_rotated, skipped_invisible)`
|
||||
/// where `has_gid_fonts` indicates the page uses fonts with unresolvable
|
||||
/// gid-encoded glyphs and `skipped_invisible` reports that invisible (Tr 3)
|
||||
/// text was present but suppressed — callers can use it to decide whether an
|
||||
/// `include_invisible` retry could recover anything at all.
|
||||
pub(crate) fn extract_page_text_items(
|
||||
doc: &Document,
|
||||
page_id: ObjectId,
|
||||
@@ -135,9 +149,8 @@ pub(crate) fn extract_page_text_items(
|
||||
font_cmaps: &FontCMaps,
|
||||
include_invisible: bool,
|
||||
style_cache: &mut FontStyleCache,
|
||||
) -> Result<(PageExtraction, bool, bool), PdfError> {
|
||||
use lopdf::content::Content;
|
||||
|
||||
form_budget: &mut FormWalkBudget,
|
||||
) -> Result<(PageExtraction, bool, bool, bool), PdfError> {
|
||||
let mut items = Vec::new();
|
||||
let mut rects: Vec<PdfRect> = Vec::new();
|
||||
let mut clip_rects: Vec<PdfRect> = Vec::new();
|
||||
@@ -162,10 +175,11 @@ pub(crate) fn extract_page_text_items(
|
||||
let fonts = doc.get_page_fonts(page_id).unwrap_or_default();
|
||||
|
||||
// Build font encoding maps from Differences arrays
|
||||
let (font_encodings, has_gid_fonts) = build_font_encodings(doc, &fonts);
|
||||
let (font_encodings, has_gid_fonts) = build_font_encodings(doc, &fonts, font_cmaps);
|
||||
|
||||
// Build font width info for accurate text positioning
|
||||
let font_widths = build_font_widths(doc, &fonts);
|
||||
let type3_scales = build_type3_scales(doc, &fonts);
|
||||
|
||||
// Build maps of font resource names to their base font names and ToUnicode object refs
|
||||
let mut font_base_names: std::collections::HashMap<String, String> =
|
||||
@@ -240,22 +254,27 @@ pub(crate) fn extract_page_text_items(
|
||||
// Content::decode parser, causing it to skip operators like ET and Q.
|
||||
let content_data = strip_pdf_comments(&content_data);
|
||||
|
||||
let content = Content::decode(&content_data).map_err(|e| PdfError::Parse(e.to_string()))?;
|
||||
|
||||
const MAX_OPERATIONS: usize = 1_000_000;
|
||||
if content.operations.len() > MAX_OPERATIONS {
|
||||
log::warn!(
|
||||
"page {}: skipping extraction — {} operations exceeds limit ({})",
|
||||
page_num,
|
||||
content.operations.len(),
|
||||
MAX_OPERATIONS
|
||||
);
|
||||
return Ok(((Vec::new(), Vec::new(), Vec::new()), false, false));
|
||||
}
|
||||
let content = match super::content_decode::decode_content_bounded(
|
||||
&content_data,
|
||||
super::content_decode::MAX_PAGE_OPERATIONS,
|
||||
)? {
|
||||
Some(content) => content,
|
||||
None => {
|
||||
log::warn!(
|
||||
"page {}: skipping extraction — content stream exceeds {} operations",
|
||||
page_num,
|
||||
super::content_decode::MAX_PAGE_OPERATIONS
|
||||
);
|
||||
return Ok(((Vec::new(), Vec::new(), Vec::new()), false, false, false));
|
||||
}
|
||||
};
|
||||
|
||||
// Graphics state tracking
|
||||
let mut ctm = [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0]; // Current Transformation Matrix
|
||||
let mut text_rendering_mode: i32 = 0; // 0=fill, 1=stroke, 2=fill+stroke, 3=invisible
|
||||
// Invisible (Tr 3) text was present but suppressed — reported to callers
|
||||
// so an include_invisible retry is attempted only when it can recover.
|
||||
let mut skipped_invisible = false;
|
||||
let mut line_width: f32 = 1.0;
|
||||
#[derive(Clone)]
|
||||
struct SavedGraphicsState {
|
||||
@@ -482,6 +501,14 @@ pub(crate) fn extract_page_text_items(
|
||||
// For Mixed/template PDFs, include_invisible=true extracts
|
||||
// the OCR text layer that sits behind scanned images.
|
||||
if text_rendering_mode == 3 && !include_invisible {
|
||||
if op
|
||||
.operands
|
||||
.first()
|
||||
.and_then(get_operand_bytes)
|
||||
.is_some_and(|raw| !raw.is_empty())
|
||||
{
|
||||
skipped_invisible = true;
|
||||
}
|
||||
if let Some(w_ts) = w_ts_opt {
|
||||
text_matrix[4] += w_ts * text_matrix[0];
|
||||
text_matrix[5] += w_ts * text_matrix[1];
|
||||
@@ -502,7 +529,8 @@ pub(crate) fn extract_page_text_items(
|
||||
) {
|
||||
let combined =
|
||||
multiply_matrices(&rise_adjusted(&text_matrix, text_rise), &ctm);
|
||||
let rendered_size = effective_font_size(current_font_size, &combined);
|
||||
let rendered_size = effective_font_size(current_font_size, &combined)
|
||||
* type3_scales.get(¤t_font).copied().unwrap_or(1.0);
|
||||
let (x, y) = (combined[4], combined[5]);
|
||||
if combined[0].abs() >= combined[1].abs() {
|
||||
rotation_votes.horizontal += 1;
|
||||
@@ -552,6 +580,16 @@ pub(crate) fn extract_page_text_items(
|
||||
if in_text_block && !op.operands.is_empty() {
|
||||
if let Ok(array) = op.operands[0].as_array() {
|
||||
let font_info = font_widths.get(¤t_font);
|
||||
// Numeric-only TJ arrays (pure kerning) show no
|
||||
// text — they must not trigger the invisible retry.
|
||||
if text_rendering_mode == 3
|
||||
&& !include_invisible
|
||||
&& array
|
||||
.iter()
|
||||
.any(|el| get_operand_bytes(el).is_some_and(|raw| !raw.is_empty()))
|
||||
{
|
||||
skipped_invisible = true;
|
||||
}
|
||||
let is_invisible = (text_rendering_mode == 3 && !include_invisible)
|
||||
|| suppress_glyph_extraction;
|
||||
// Capture first-glyph position for ActualText
|
||||
@@ -673,7 +711,8 @@ pub(crate) fn extract_page_text_items(
|
||||
} else {
|
||||
rotation_votes.rotated += 1;
|
||||
}
|
||||
let rendered_size = effective_font_size(current_font_size, &combined);
|
||||
let rendered_size = effective_font_size(current_font_size, &combined)
|
||||
* type3_scales.get(¤t_font).copied().unwrap_or(1.0);
|
||||
let base_font = font_base_names
|
||||
.get(¤t_font)
|
||||
.map(|s| s.as_str())
|
||||
@@ -756,6 +795,16 @@ pub(crate) fn extract_page_text_items(
|
||||
)
|
||||
})
|
||||
});
|
||||
if text_rendering_mode == 3
|
||||
&& !include_invisible
|
||||
&& op
|
||||
.operands
|
||||
.first()
|
||||
.and_then(get_operand_bytes)
|
||||
.is_some_and(|raw| !raw.is_empty())
|
||||
{
|
||||
skipped_invisible = true;
|
||||
}
|
||||
if !((text_rendering_mode == 3 && !include_invisible)
|
||||
|| suppress_glyph_extraction
|
||||
|| op.operands.is_empty())
|
||||
@@ -780,7 +829,8 @@ pub(crate) fn extract_page_text_items(
|
||||
} else {
|
||||
rotation_votes.rotated += 1;
|
||||
}
|
||||
let rendered_size = effective_font_size(current_font_size, &combined);
|
||||
let rendered_size = effective_font_size(current_font_size, &combined)
|
||||
* type3_scales.get(¤t_font).copied().unwrap_or(1.0);
|
||||
let (x, y) = (combined[4], combined[5]);
|
||||
let width = w_ts_opt
|
||||
.map(|w_ts| {
|
||||
@@ -867,6 +917,7 @@ pub(crate) fn extract_page_text_items(
|
||||
&ctm,
|
||||
&mut cmap_decisions,
|
||||
style_cache,
|
||||
form_budget,
|
||||
);
|
||||
items.extend(form_items);
|
||||
}
|
||||
@@ -932,7 +983,8 @@ pub(crate) fn extract_page_text_items(
|
||||
} else {
|
||||
rotation_votes.rotated += 1;
|
||||
}
|
||||
let rendered_size = effective_font_size(current_font_size, &combined);
|
||||
let rendered_size = effective_font_size(current_font_size, &combined)
|
||||
* type3_scales.get(¤t_font).copied().unwrap_or(1.0);
|
||||
let (x, y) = (combined[4], combined[5]);
|
||||
// Width in device space from text matrix delta
|
||||
let delta_ts = text_matrix[4] - start_tm[4];
|
||||
@@ -1235,6 +1287,12 @@ pub(crate) fn extract_page_text_items(
|
||||
}
|
||||
}
|
||||
|
||||
if form_budget.was_truncated() {
|
||||
log::warn!(
|
||||
"page {page_num}: Form XObject expansion truncated (invocation or operation budget reached); nested form text may be incomplete"
|
||||
);
|
||||
}
|
||||
|
||||
// Underline detection reads only painted ink: `re` rects confirmed by
|
||||
// a paint operator plus filled-subpath rects — never clip-only rects,
|
||||
// which draw nothing.
|
||||
@@ -1284,7 +1342,12 @@ pub(crate) fn extract_page_text_items(
|
||||
|
||||
let items = super::merge_text_items(items);
|
||||
let items = super::merge_subscript_items(items);
|
||||
Ok(((items, rects, lines), has_gid_fonts, coords_rotated))
|
||||
Ok((
|
||||
(items, rects, lines),
|
||||
has_gid_fonts,
|
||||
coords_rotated,
|
||||
skipped_invisible,
|
||||
))
|
||||
}
|
||||
|
||||
/// Counts of text operators with horizontal vs rotated combined matrices.
|
||||
@@ -1482,13 +1545,14 @@ mod tests {
|
||||
|
||||
let (doc, page_id) = simple_doc_with_content(content);
|
||||
let font_cmaps = FontCMaps::from_doc(&doc);
|
||||
let ((items, _, _), _, _) = extract_page_text_items(
|
||||
let ((items, _, _), _, _, _) = extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
1,
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut FontStyleCache::new(),
|
||||
&mut FormWalkBudget::new(),
|
||||
)
|
||||
.unwrap();
|
||||
items
|
||||
@@ -1718,9 +1782,10 @@ BT /F1 12 Tf 0 1 -1 0 240 100 Tm (WORLD) Tj ET
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut FontStyleCache::new(),
|
||||
&mut FormWalkBudget::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let ((items, rects, lines), _has_gid, _coords_rotated) = result;
|
||||
let ((items, rects, lines), _has_gid, _coords_rotated, _skipped_invisible) = result;
|
||||
assert!(items.is_empty());
|
||||
assert!(rects.is_empty());
|
||||
assert!(lines.is_empty());
|
||||
@@ -1801,13 +1866,14 @@ BT 30 700 Tm <41> Tj ET";
|
||||
doc.trailer.set("Root", Object::Reference(catalog_id));
|
||||
|
||||
let font_cmaps = FontCMaps::from_doc(&doc);
|
||||
let ((items, _, _), _, _) = extract_page_text_items(
|
||||
let ((items, _, _), _, _, _) = extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
1,
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut FontStyleCache::new(),
|
||||
&mut FormWalkBudget::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let text = items
|
||||
@@ -1849,4 +1915,41 @@ BT 30 700 Tm <41> Tj ET";
|
||||
"ET should be preserved after comment stripping"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_pdf_comments_escaped_parens() {
|
||||
// An escaped `\)` must not close the string: the `%` after it is
|
||||
// still string content, not a comment (subset fonts routinely map
|
||||
// glyphs to `%` and to escaped parens in the same TJ array).
|
||||
let input = b"[ (a\\)b) 1 (%) 1 (c) ] TJ\n";
|
||||
let output = strip_pdf_comments(input);
|
||||
assert_eq!(output, input.to_vec());
|
||||
|
||||
// Same for an escaped `\(` — must not open a phantom string that
|
||||
// shields a real comment.
|
||||
let input = b"(x\\(y) Tj % real comment\nET\n";
|
||||
let output = strip_pdf_comments(input);
|
||||
assert_eq!(output, b"(x\\(y) Tj \nET\n");
|
||||
|
||||
// Escaped backslash before a real close-paren: `\\` ends the escape,
|
||||
// the `)` does close the string, and the comment is stripped.
|
||||
let input = b"(x\\\\) Tj % comment\nET\n";
|
||||
let output = strip_pdf_comments(input);
|
||||
assert_eq!(output, b"(x\\\\) Tj \nET\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_content_stream_skips_extraction() {
|
||||
let mut content =
|
||||
Vec::with_capacity((super::super::content_decode::MAX_PAGE_OPERATIONS + 1) * 2);
|
||||
for _ in 0..=super::super::content_decode::MAX_PAGE_OPERATIONS {
|
||||
content.extend_from_slice(b"q\n");
|
||||
}
|
||||
content.extend_from_slice(b"BT /F1 12 Tf 72 720 Td (Hello) Tj ET\n");
|
||||
let items = extract_simple_items(&content);
|
||||
assert!(
|
||||
items.is_empty(),
|
||||
"pages over the operator cap must not be decoded"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+495
-29
@@ -138,6 +138,93 @@ pub(crate) fn build_font_widths(
|
||||
widths
|
||||
}
|
||||
|
||||
/// Visual-size scale factors for Type3 fonts, keyed by resource name.
|
||||
///
|
||||
/// A Type3 font's glyph space maps to text space through FontMatrix, so the
|
||||
/// visual height of its glyphs is `nominal_size × |matrix_y| × FontBBox
|
||||
/// height`. For a well-behaved font (matrix 0.001, bbox ≈ 1000 units) that
|
||||
/// factor is ≈ 1.0 and the nominal size is already right. TeX PK bitmap
|
||||
/// fonts (dvips → Distiller) instead use FontMatrix [1 0 0 -1 0 0] with
|
||||
/// nominal sizes like 0.12, which makes every downstream font-size heuristic
|
||||
/// (drop caps, sub/superscripts, small-font tables, line heights) see
|
||||
/// nonsense. Fonts without a usable FontBBox are omitted (treated as 1.0).
|
||||
pub(crate) fn build_type3_scales(
|
||||
doc: &Document,
|
||||
fonts: &std::collections::BTreeMap<Vec<u8>, &lopdf::Dictionary>,
|
||||
) -> HashMap<String, f32> {
|
||||
let mut scales = HashMap::new();
|
||||
for (font_name, font_dict) in fonts {
|
||||
let is_type3 = font_dict
|
||||
.get(b"Subtype")
|
||||
.ok()
|
||||
.and_then(|o| o.as_name().ok())
|
||||
.is_some_and(|n| n == b"Type3");
|
||||
if !is_type3 {
|
||||
continue;
|
||||
}
|
||||
// Array elements may themselves be indirect references per PDF
|
||||
// syntax — resolve before reading the numeric value.
|
||||
let num = |o: &Object| {
|
||||
let resolved = match o {
|
||||
Object::Reference(r) => match doc.get_object(*r) {
|
||||
Ok(inner) => inner,
|
||||
Err(_) => return 0.0,
|
||||
},
|
||||
other => other,
|
||||
};
|
||||
match resolved {
|
||||
Object::Integer(i) => *i as f32,
|
||||
Object::Real(r) => *r,
|
||||
_ => 0.0,
|
||||
}
|
||||
};
|
||||
let Some(matrix) = font_dict
|
||||
.get(b"FontMatrix")
|
||||
.ok()
|
||||
.and_then(|o| resolve_array(doc, o))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(bbox) = font_dict
|
||||
.get(b"FontBBox")
|
||||
.ok()
|
||||
.and_then(|o| resolve_array(doc, o))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if matrix.len() < 4 || bbox.len() < 4 {
|
||||
continue;
|
||||
}
|
||||
let scale_y = (num(&matrix[2]).powi(2) + num(&matrix[3]).powi(2)).sqrt();
|
||||
let bbox_h = (num(&bbox[3]) - num(&bbox[1])).abs();
|
||||
let scale = bbox_h * scale_y;
|
||||
|
||||
// `scale` is the glyph box measured in text-space units. For a
|
||||
// self-consistent font it lands near 1.0 — the FontMatrix is the
|
||||
// reciprocal of the glyph-space em by construction — so the Tf
|
||||
// operand is already the rendered size and must be left alone.
|
||||
// A modest deviation is normal and must NOT trigger rescaling:
|
||||
// FontBBox is the glyph bounding box, not the em box, so it is
|
||||
// routinely somewhat smaller (descender..ascender ≈ 0.7) or larger
|
||||
// (tall accents > 1.0).
|
||||
//
|
||||
// Only a wildly inconsistent font gets renormalized. dvips/PK
|
||||
// bitmap fonts declare [1 0 0 -1 0 0] with glyphs spanning
|
||||
// hundreds of units, giving scale ≈ 159 against a nominal size of
|
||||
// 0.12pt — there the declared size carries no information. The
|
||||
// band is deliberately wide so that only that class qualifies,
|
||||
// while any matrix scale (including non-standard ones like 0.005
|
||||
// with a full-em bbox, scale = 5.0) is judged on the product
|
||||
// rather than on the matrix alone.
|
||||
const CONSISTENT_LO: f32 = 0.25;
|
||||
const CONSISTENT_HI: f32 = 4.0;
|
||||
if scale.is_finite() && scale > 0.0 && !(CONSISTENT_LO..=CONSISTENT_HI).contains(&scale) {
|
||||
scales.insert(String::from_utf8_lossy(font_name).to_string(), scale);
|
||||
}
|
||||
}
|
||||
scales
|
||||
}
|
||||
|
||||
/// Parse font widths from a font dictionary, dispatching by Subtype
|
||||
pub(crate) fn parse_font_widths(
|
||||
doc: &Document,
|
||||
@@ -149,11 +236,71 @@ pub(crate) fn parse_font_widths(
|
||||
|
||||
match subtype_name {
|
||||
b"Type0" => parse_type0_widths(doc, font_dict),
|
||||
b"Type1" | b"TrueType" | b"MMType1" | b"Type3" => parse_simple_font_widths(doc, font_dict),
|
||||
b"Type1" | b"TrueType" | b"MMType1" => parse_simple_font_widths(doc, font_dict)
|
||||
.or_else(|| base14_fallback_widths(doc, font_dict)),
|
||||
b"Type3" => parse_simple_font_widths(doc, font_dict),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fallback metrics for non-embedded base-14 fonts whose dictionary omits
|
||||
/// `/FirstChar`/`/Widths` (legal per the PDF spec — the reader must supply
|
||||
/// standard-font metrics). Without this, every glyph advances 0 and all
|
||||
/// downstream gap-based logic (space synthesis, script detection, table
|
||||
/// columns) collapses — common in 1990s dvips/Distiller PDFs.
|
||||
///
|
||||
/// Widths are resolved per code through the font's Differences encoding when
|
||||
/// present, falling back to the same single-byte decode the text extractor
|
||||
/// uses (cp1252-style smart punctuation for 0x80..=0x9F, Latin-1 elsewhere) —
|
||||
/// so the width of a code always matches the char we extract for it.
|
||||
fn base14_fallback_widths(doc: &Document, font_dict: &lopdf::Dictionary) -> Option<FontWidthInfo> {
|
||||
let base_font = font_dict
|
||||
.get(b"BaseFont")
|
||||
.ok()
|
||||
.and_then(|o| o.as_name().ok())
|
||||
.map(|n| String::from_utf8_lossy(n).to_string())?;
|
||||
if !crate::extractor::base14::is_base14_font(&base_font) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let enc_map = parse_font_encoding(doc, font_dict)
|
||||
.map(|r| r.map)
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut widths = HashMap::new();
|
||||
for code in 0u16..=255 {
|
||||
// Resolution order: Differences override, then the font's BUILT-IN
|
||||
// encoding (Symbol/ZapfDingbats glyphs live at positions unrelated
|
||||
// to cp1252 — the renderer draws α for Symbol 0x61 no matter how
|
||||
// the text decoder transliterates it, so the advance must be α's),
|
||||
// then the cp1252-style fallback used by the text decoder.
|
||||
let ch = enc_map
|
||||
.get(&(code as u8))
|
||||
.copied()
|
||||
.or_else(|| crate::extractor::base14::builtin_encoding_char(&base_font, code as u8))
|
||||
.unwrap_or_else(|| decode_single_byte_fallback_char(code as u8, true));
|
||||
if let Some(w) = crate::extractor::base14::base14_char_width(&base_font, ch) {
|
||||
widths.insert(code, w);
|
||||
}
|
||||
}
|
||||
let space_width = widths.get(&32).copied().unwrap_or(250);
|
||||
|
||||
debug!(
|
||||
" base14 fallback widths for {} ({} codes mapped)",
|
||||
base_font,
|
||||
widths.len()
|
||||
);
|
||||
|
||||
Some(FontWidthInfo {
|
||||
widths,
|
||||
default_width: 500,
|
||||
space_width,
|
||||
is_cid: false,
|
||||
units_scale: 0.001,
|
||||
wmode: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse widths for simple fonts (Type1, TrueType, MMType1, Type3)
|
||||
/// Reads FirstChar, LastChar, and Widths array.
|
||||
/// For Type3 fonts, reads FontMatrix to determine the correct units_scale.
|
||||
@@ -335,7 +482,11 @@ pub(crate) fn parse_cid_w_array(
|
||||
widths: &mut HashMap<u16, u16>,
|
||||
) {
|
||||
let mut i = 0;
|
||||
let mut assigned = 0usize;
|
||||
while i < w_array.len() {
|
||||
if assigned >= crate::tounicode::MAX_CID_W_EXPANSION {
|
||||
return;
|
||||
}
|
||||
let start_cid = match &w_array[i] {
|
||||
Object::Integer(n) => *n as u16,
|
||||
Object::Real(n) => *n as u16,
|
||||
@@ -354,12 +505,14 @@ pub(crate) fn parse_cid_w_array(
|
||||
Object::Array(arr) => {
|
||||
// [c [w1 w2 ...]] — consecutive widths starting at c
|
||||
for (j, w_obj) in arr.iter().enumerate() {
|
||||
let w = match w_obj {
|
||||
Object::Integer(n) => *n as u16,
|
||||
Object::Real(n) => *n as u16,
|
||||
_ => continue,
|
||||
};
|
||||
widths.insert(start_cid + j as u16, w);
|
||||
if !assign_cid_width(
|
||||
widths,
|
||||
start_cid.wrapping_add(j as u16),
|
||||
w_obj,
|
||||
&mut assigned,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
@@ -367,12 +520,14 @@ pub(crate) fn parse_cid_w_array(
|
||||
// Could be a reference to an array
|
||||
if let Ok(Object::Array(arr)) = doc.get_object(*r) {
|
||||
for (j, w_obj) in arr.iter().enumerate() {
|
||||
let w = match w_obj {
|
||||
Object::Integer(n) => *n as u16,
|
||||
Object::Real(n) => *n as u16,
|
||||
_ => continue,
|
||||
};
|
||||
widths.insert(start_cid + j as u16, w);
|
||||
if !assign_cid_width(
|
||||
widths,
|
||||
start_cid.wrapping_add(j as u16),
|
||||
w_obj,
|
||||
&mut assigned,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
} else {
|
||||
@@ -395,8 +550,8 @@ pub(crate) fn parse_cid_w_array(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for cid in start_cid..=end {
|
||||
widths.insert(cid, w);
|
||||
if !assign_cid_width_range(widths, start_cid, end, w, &mut assigned) {
|
||||
return;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
@@ -414,8 +569,8 @@ pub(crate) fn parse_cid_w_array(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for cid in start_cid..=end {
|
||||
widths.insert(cid, w);
|
||||
if !assign_cid_width_range(widths, start_cid, end, w, &mut assigned) {
|
||||
return;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
@@ -426,6 +581,45 @@ pub(crate) fn parse_cid_w_array(
|
||||
}
|
||||
}
|
||||
|
||||
fn assign_cid_width(
|
||||
widths: &mut HashMap<u16, u16>,
|
||||
cid: u16,
|
||||
w_obj: &Object,
|
||||
assigned: &mut usize,
|
||||
) -> bool {
|
||||
let w = match w_obj {
|
||||
Object::Integer(n) => *n as u16,
|
||||
Object::Real(n) => *n as u16,
|
||||
_ => return true,
|
||||
};
|
||||
if *assigned >= crate::tounicode::MAX_CID_W_EXPANSION {
|
||||
return false;
|
||||
}
|
||||
widths.insert(cid, w);
|
||||
*assigned += 1;
|
||||
true
|
||||
}
|
||||
|
||||
fn assign_cid_width_range(
|
||||
widths: &mut HashMap<u16, u16>,
|
||||
start: u16,
|
||||
end: u16,
|
||||
w: u16,
|
||||
assigned: &mut usize,
|
||||
) -> bool {
|
||||
if start > end {
|
||||
return true;
|
||||
}
|
||||
for cid in start..=end {
|
||||
if *assigned >= crate::tounicode::MAX_CID_W_EXPANSION {
|
||||
return false;
|
||||
}
|
||||
widths.insert(cid, w);
|
||||
*assigned += 1;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Compute the width of a string in text space units,
|
||||
/// given raw bytes and font width info.
|
||||
/// Returns width in text space units (font_units * units_scale * font_size).
|
||||
@@ -497,9 +691,13 @@ pub(crate) fn get_operand_bytes(obj: &Object) -> Option<&[u8]> {
|
||||
/// Build encoding maps for all fonts on a page.
|
||||
/// Returns `(encodings, has_gid_fonts)` where `has_gid_fonts` is true when
|
||||
/// any font uses raw glyph ID names (gidNNNNN) that can't be decoded.
|
||||
/// Gid names whose codes the font's own ToUnicode CMap maps are decodable
|
||||
/// and do not set the flag (LibreOffice subsets write /gidNNNN Differences
|
||||
/// names alongside a complete ToUnicode CMap).
|
||||
pub(crate) fn build_font_encodings(
|
||||
doc: &Document,
|
||||
fonts: &std::collections::BTreeMap<Vec<u8>, &lopdf::Dictionary>,
|
||||
cmaps: &FontCMaps,
|
||||
) -> (PageFontEncodings, bool) {
|
||||
let mut encodings = PageFontEncodings::new();
|
||||
let mut has_gid_fonts = false;
|
||||
@@ -508,7 +706,9 @@ pub(crate) fn build_font_encodings(
|
||||
let resource_name = String::from_utf8_lossy(font_name).to_string();
|
||||
|
||||
if let Some(result) = parse_font_encoding(doc, font_dict) {
|
||||
if result.gid_glyph_count > 0 {
|
||||
if !result.gid_codes.is_empty()
|
||||
&& !tounicode_maps_codes(font_dict, cmaps, &result.gid_codes)
|
||||
{
|
||||
has_gid_fonts = true;
|
||||
}
|
||||
if !result.map.is_empty() {
|
||||
@@ -520,6 +720,34 @@ pub(crate) fn build_font_encodings(
|
||||
(encodings, has_gid_fonts)
|
||||
}
|
||||
|
||||
/// True when the font's ToUnicode CMap maps the gid-named character codes,
|
||||
/// so the Differences entries still decode through the CMap.
|
||||
fn tounicode_maps_codes(font_dict: &lopdf::Dictionary, cmaps: &FontCMaps, codes: &[u8]) -> bool {
|
||||
let Some(obj_ref) = font_dict
|
||||
.get(b"ToUnicode")
|
||||
.ok()
|
||||
.and_then(|o| o.as_reference().ok())
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let Some(entry) = cmaps.get_by_obj(obj_ref.0) else {
|
||||
return false;
|
||||
};
|
||||
// At least one gid code usably mapped means the CMap addresses these
|
||||
// codes; remaining unmapped codes are subset leftovers (e.g. the
|
||||
// component glyphs of an emoji ZWJ sequence mapped whole on its first
|
||||
// code). A mapping is usable only when extraction would accept it —
|
||||
// empty or U+FFFD results are rejected there as invalid. Fonts whose
|
||||
// CMap ignores the gid codes entirely stay flagged, and the downstream
|
||||
// garbage/encoding checks still catch partial damage.
|
||||
codes.iter().any(|&code| {
|
||||
entry
|
||||
.primary
|
||||
.lookup(code as u16)
|
||||
.is_some_and(|s| !s.is_empty() && !s.contains('\u{FFFD}'))
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse font encoding from a font dictionary
|
||||
pub(crate) fn parse_font_encoding(
|
||||
doc: &Document,
|
||||
@@ -558,11 +786,10 @@ pub(crate) fn parse_font_encoding(
|
||||
/// Result of parsing an encoding dictionary's Differences array.
|
||||
pub(crate) struct EncodingResult {
|
||||
pub map: FontEncodingMap,
|
||||
/// Number of glyph names matching the `gidNNNNN` pattern (raw glyph IDs).
|
||||
/// These indicate a font with unresolvable encoding — the glyph IDs
|
||||
/// reference the original font's glyph table, but without the original
|
||||
/// font's cmap there is no way to map them to Unicode.
|
||||
pub gid_glyph_count: u32,
|
||||
/// Character codes whose glyph names match the `gidNNNNN` pattern (raw
|
||||
/// glyph IDs). These reference the original font's glyph table and are
|
||||
/// only decodable when the font's ToUnicode CMap maps the code.
|
||||
pub gid_codes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Parse an encoding dictionary with Differences array
|
||||
@@ -588,7 +815,7 @@ pub(crate) fn parse_encoding_dictionary(
|
||||
let mut encoding_map = FontEncodingMap::new();
|
||||
let mut current_code: u8 = 0;
|
||||
let mut ligature_count = 0u32;
|
||||
let mut gid_glyph_count = 0u32;
|
||||
let mut gid_codes: Vec<u8> = Vec::new();
|
||||
|
||||
for item in diff_array {
|
||||
match item {
|
||||
@@ -614,7 +841,7 @@ pub(crate) fn parse_encoding_dictionary(
|
||||
&& glyph_name.len() >= 4
|
||||
&& glyph_name[3..].chars().all(|c| c.is_ascii_digit())
|
||||
{
|
||||
gid_glyph_count += 1;
|
||||
gid_codes.push(current_code);
|
||||
}
|
||||
if let Some(ch) = mapped_char {
|
||||
encoding_map.insert(current_code, ch);
|
||||
@@ -638,16 +865,16 @@ pub(crate) fn parse_encoding_dictionary(
|
||||
);
|
||||
}
|
||||
|
||||
if gid_glyph_count > 0 {
|
||||
if !gid_codes.is_empty() {
|
||||
debug!(
|
||||
" Differences: {} gid-encoded glyphs (unresolvable without original font)",
|
||||
gid_glyph_count
|
||||
" Differences: {} gid-encoded glyphs (decodable only via ToUnicode)",
|
||||
gid_codes.len()
|
||||
);
|
||||
}
|
||||
|
||||
Some(EncodingResult {
|
||||
map: encoding_map,
|
||||
gid_glyph_count,
|
||||
gid_codes,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1437,6 +1664,92 @@ fn score_text(text: &str) -> i32 {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn type3_scale_resolves_indirect_matrix_and_bbox_numbers() {
|
||||
use lopdf::{dictionary, Document, Object};
|
||||
// FontMatrix/FontBBox elements may be indirect references per PDF
|
||||
// syntax; the scale must use their resolved values, not zero.
|
||||
let mut doc = Document::with_version("1.4");
|
||||
let matrix_d = doc.add_object(Object::Real(-1.0));
|
||||
let bbox_top = doc.add_object(Object::Integer(3));
|
||||
let font_dict = dictionary! {
|
||||
"Type" => "Font",
|
||||
"Subtype" => "Type3",
|
||||
"FontMatrix" => vec![
|
||||
Object::Integer(1),
|
||||
Object::Integer(0),
|
||||
Object::Integer(0),
|
||||
Object::Reference(matrix_d),
|
||||
Object::Integer(0),
|
||||
Object::Integer(0),
|
||||
],
|
||||
"FontBBox" => vec![
|
||||
Object::Integer(1),
|
||||
Object::Integer(-156),
|
||||
Object::Integer(37),
|
||||
Object::Reference(bbox_top),
|
||||
],
|
||||
};
|
||||
let mut fonts = std::collections::BTreeMap::new();
|
||||
fonts.insert(b"T2".to_vec(), &font_dict);
|
||||
let scales = super::build_type3_scales(&doc, &fonts);
|
||||
let scale = scales.get("T2").copied().unwrap_or(1.0);
|
||||
// bbox height 159 x |matrix_y| 1.0
|
||||
assert!(
|
||||
(scale - 159.0).abs() < 0.5,
|
||||
"scale should use resolved indirect values, got {scale}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Build a one-font Type3 document and return its computed scale, if any.
|
||||
#[cfg(test)]
|
||||
fn type3_scale_for(matrix_y: f32, bbox_lo: i64, bbox_hi: i64) -> Option<f32> {
|
||||
use lopdf::{dictionary, Document, Object};
|
||||
let doc = Document::with_version("1.4");
|
||||
let font_dict = dictionary! {
|
||||
"Type" => "Font",
|
||||
"Subtype" => "Type3",
|
||||
"FontMatrix" => vec![
|
||||
Object::Real(matrix_y), Object::Integer(0), Object::Integer(0),
|
||||
Object::Real(matrix_y), Object::Integer(0), Object::Integer(0),
|
||||
],
|
||||
"FontBBox" => vec![
|
||||
Object::Integer(0), Object::Integer(bbox_lo),
|
||||
Object::Integer(600), Object::Integer(bbox_hi),
|
||||
],
|
||||
};
|
||||
let mut fonts = std::collections::BTreeMap::new();
|
||||
fonts.insert(b"T9".to_vec(), &font_dict);
|
||||
super::build_type3_scales(&doc, &fonts).get("T9").copied()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type3_scale_skips_self_consistent_fonts() {
|
||||
// Conventional 1/1000 matrix with a descender..ascender bbox of 700
|
||||
// units: scale 0.7. The Tf operand is already the rendered size, so
|
||||
// renormalizing would report every size at 0.7x.
|
||||
assert_eq!(type3_scale_for(0.001, -200, 500), None);
|
||||
// Tall-accent bbox slightly over the em (1100 units, scale 1.1).
|
||||
assert_eq!(type3_scale_for(0.001, -100, 1000), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type3_scale_applies_to_inconsistent_fonts_at_any_matrix_scale() {
|
||||
// Non-standard but valid matrix (0.005) with a full-em bbox:
|
||||
// scale 5.0, so the declared size is off by 5x and must be fixed.
|
||||
let s = type3_scale_for(0.005, 0, 1000).expect("0.005 matrix should rescale");
|
||||
assert!((s - 5.0).abs() < 0.01, "got {s}");
|
||||
// dvips/PK bitmap pattern: unit matrix, glyphs spanning ~159 units.
|
||||
let s = type3_scale_for(1.0, -156, 3).expect("PK pattern should rescale");
|
||||
assert!((s - 159.0).abs() < 0.5, "got {s}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn type3_scale_ignores_degenerate_bbox() {
|
||||
// [0 0 0 0] is legal and carries no size information.
|
||||
assert_eq!(type3_scale_for(0.001, 0, 0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn texcm_math_symbols_remap() {
|
||||
assert_eq!(
|
||||
@@ -1938,4 +2251,157 @@ mod tests {
|
||||
false
|
||||
));
|
||||
}
|
||||
|
||||
fn gid_font_doc(bfchar: Option<&str>) -> (Document, lopdf::ObjectId) {
|
||||
use lopdf::Stream;
|
||||
let mut doc = Document::with_version("1.4");
|
||||
let cmap = format!(
|
||||
"/CIDInit /ProcSet findresource begin
|
||||
12 dict begin
|
||||
begincmap
|
||||
1 begincodespacerange
|
||||
<00> <FF>
|
||||
endcodespacerange
|
||||
1 beginbfchar
|
||||
{}
|
||||
endbfchar
|
||||
endcmap
|
||||
CMapName currentdict /CMap defineresource pop
|
||||
end
|
||||
end",
|
||||
bfchar.unwrap_or_default()
|
||||
);
|
||||
let tounicode_id = doc.add_object(Object::Stream(Stream::new(
|
||||
dictionary! {},
|
||||
cmap.into_bytes(),
|
||||
)));
|
||||
let enc_id = doc.add_object(dictionary! {
|
||||
"Type" => "Encoding",
|
||||
"Differences" => vec![
|
||||
1.into(),
|
||||
Object::Name(b"gid1283".to_vec()),
|
||||
Object::Name(b"gid1464".to_vec()),
|
||||
],
|
||||
});
|
||||
let mut font = dictionary! {
|
||||
"Type" => "Font",
|
||||
"Subtype" => "TrueType",
|
||||
"BaseFont" => "ABCDEF+OpenSymbol",
|
||||
"Encoding" => Object::Reference(enc_id),
|
||||
};
|
||||
if bfchar.is_some() {
|
||||
font.set("ToUnicode", Object::Reference(tounicode_id));
|
||||
}
|
||||
let font_id = doc.add_object(font);
|
||||
let page_id = doc.add_object(dictionary! {
|
||||
"Type" => "Page",
|
||||
"Resources" => dictionary! {
|
||||
"Font" => dictionary! { "F1" => Object::Reference(font_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 gid_flagged(bfchar: Option<&str>) -> bool {
|
||||
let (doc, page_id) = gid_font_doc(bfchar);
|
||||
let cmaps = FontCMaps::from_doc(&doc);
|
||||
let fonts = doc.get_page_fonts(page_id).unwrap();
|
||||
let (_, has_gid_fonts) = build_font_encodings(&doc, &fonts, &cmaps);
|
||||
has_gid_fonts
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gid_differences_with_covering_tounicode_are_not_flagged() {
|
||||
// LibreOffice subsets write /gidNNNN Differences names alongside a
|
||||
// ToUnicode CMap that decodes those codes; the page must not be
|
||||
// flagged as unresolvable (which would suppress the whole document's
|
||||
// markdown when every page carries such a font).
|
||||
assert!(!gid_flagged(Some("<01> <2022>\n<02> <25E6>")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gid_differences_with_partial_tounicode_are_not_flagged() {
|
||||
// An emoji ZWJ sequence maps whole on its first code; the remaining
|
||||
// component-glyph codes are subset leftovers, not damage.
|
||||
assert!(!gid_flagged(Some(
|
||||
"<01> <D83DDC68200DD83DDC69200DD83DDC67>"
|
||||
)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gid_differences_without_tounicode_are_flagged() {
|
||||
assert!(
|
||||
gid_flagged(None),
|
||||
"gid glyphs without ToUnicode are unresolvable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gid_differences_with_disjoint_tounicode_are_flagged() {
|
||||
// A ToUnicode that never addresses the gid codes leaves them
|
||||
// unresolvable.
|
||||
assert!(gid_flagged(Some("<10> <0041>")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gid_differences_with_replacement_char_tounicode_are_flagged() {
|
||||
// A mapping to U+FFFD is not usable — extraction rejects it as an
|
||||
// invalid CMap result — so it must not clear the gid flag.
|
||||
assert!(gid_flagged(Some("<01> <FFFD>\n<02> <FFFD>")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cid_w_array_range_and_consecutive() {
|
||||
use super::parse_cid_w_array;
|
||||
use lopdf::{Document, Object};
|
||||
use std::collections::HashMap;
|
||||
|
||||
let doc = Document::new();
|
||||
let mut widths = HashMap::new();
|
||||
let w = vec![
|
||||
Object::Integer(10),
|
||||
Object::Integer(12),
|
||||
Object::Integer(500),
|
||||
Object::Integer(20),
|
||||
Object::Array(vec![Object::Integer(100), Object::Integer(200)]),
|
||||
];
|
||||
parse_cid_w_array(&doc, &w, &mut widths);
|
||||
assert_eq!(widths.get(&10), Some(&500));
|
||||
assert_eq!(widths.get(&11), Some(&500));
|
||||
assert_eq!(widths.get(&12), Some(&500));
|
||||
assert_eq!(widths.get(&20), Some(&100));
|
||||
assert_eq!(widths.get(&21), Some(&200));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_cid_w_array_repeated_full_ranges_stay_bounded() {
|
||||
use super::parse_cid_w_array;
|
||||
use crate::tounicode::MAX_CID_W_EXPANSION;
|
||||
use lopdf::{Document, Object};
|
||||
use std::collections::HashMap;
|
||||
|
||||
let doc = Document::new();
|
||||
let mut widths = HashMap::new();
|
||||
let mut w = Vec::new();
|
||||
for _ in 0..5_000 {
|
||||
w.push(Object::Integer(0));
|
||||
w.push(Object::Integer(65535));
|
||||
w.push(Object::Integer(500));
|
||||
}
|
||||
parse_cid_w_array(&doc, &w, &mut widths);
|
||||
assert!(widths.len() <= MAX_CID_W_EXPANSION);
|
||||
assert_eq!(widths.get(&0), Some(&500));
|
||||
assert_eq!(widths.get(&65535), Some(&500));
|
||||
}
|
||||
}
|
||||
|
||||
+1137
-34
File diff suppressed because it is too large
Load Diff
+401
-7
@@ -2,11 +2,51 @@
|
||||
|
||||
use crate::types::{ItemType, TextItem};
|
||||
use lopdf::{Document, Object, ObjectId};
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
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();
|
||||
|
||||
@@ -146,32 +186,104 @@ 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.clone(),
|
||||
Some(arr) => arr,
|
||||
None => return items,
|
||||
},
|
||||
Err(_) => return items,
|
||||
};
|
||||
if fields.is_empty() {
|
||||
return items;
|
||||
}
|
||||
let annotation_pages = annotation_page_map(doc, page_map);
|
||||
|
||||
for field_obj in &fields {
|
||||
// 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;
|
||||
if let Ok(field_ref) = field_obj.as_reference() {
|
||||
walk_form_fields(doc, field_ref, None, "", page_map, &mut items);
|
||||
walk_form_fields(
|
||||
doc,
|
||||
field_ref,
|
||||
None,
|
||||
"",
|
||||
page_map,
|
||||
&annotation_pages,
|
||||
&mut items,
|
||||
&mut budget,
|
||||
0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -202,11 +314,31 @@ 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) {
|
||||
let kids = kids.clone();
|
||||
for kid in &kids {
|
||||
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;
|
||||
if let Ok(kid_ref) = kid.as_reference() {
|
||||
walk_form_fields(doc, kid_ref, ft, &full_name, page_map, items);
|
||||
walk_form_fields(
|
||||
doc,
|
||||
kid_ref,
|
||||
ft,
|
||||
&full_name,
|
||||
page_map,
|
||||
annotation_pages,
|
||||
items,
|
||||
budget,
|
||||
depth + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
return;
|
||||
@@ -299,6 +431,7 @@ 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() {
|
||||
@@ -324,3 +457,264 @@ pub(crate) fn walk_form_fields(
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
+1102
-25
File diff suppressed because it is too large
Load Diff
+385
-17
@@ -44,6 +44,16 @@ const MIN_TABULAR_RULE_ITEMS: usize = 3;
|
||||
const MIN_TABULAR_RULE_GAPS: usize = 2;
|
||||
const TABULAR_RULE_GAP_EM: f32 = 2.0;
|
||||
|
||||
/// Strikeout decorations are text-sized. Diagram connectors, signature
|
||||
/// lines, and chart rules often cross glyphs too, but extend well beyond the
|
||||
/// text they happen to intersect.
|
||||
const STRIKE_OWNER_PAD_EM: f32 = 0.75;
|
||||
const STRIKE_OWNER_MIN_PAD: f32 = 4.0;
|
||||
const STRIKE_ROW_Y_TOLERANCE_EM: f32 = 0.15;
|
||||
const STRIKE_ROW_Y_TOLERANCE_MIN: f32 = 5.0;
|
||||
const GRAPHIC_CONNECTION_EPS: f32 = 2.0;
|
||||
const GRAPHIC_CONNECTOR_MAX_THICKNESS: f32 = 4.0;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct UnderlineLine {
|
||||
pub(crate) x1: f32,
|
||||
@@ -387,6 +397,206 @@ fn rule_strikes_item(rule: &Rule, item: &TextItem) -> bool {
|
||||
overlap >= min_overlap
|
||||
}
|
||||
|
||||
fn is_bare_list_marker(text: &str) -> bool {
|
||||
matches!(
|
||||
text.trim(),
|
||||
"•" | "◦" | "▪" | "▫" | "‣" | "⁃" | "●" | "○" | "■" | "□" | "-" | "*"
|
||||
)
|
||||
}
|
||||
|
||||
fn same_strike_row(left: &TextItem, right: &TextItem) -> bool {
|
||||
let font_size = left.font_size.max(right.font_size);
|
||||
let tolerance = (font_size * STRIKE_ROW_Y_TOLERANCE_EM).max(STRIKE_ROW_Y_TOLERANCE_MIN);
|
||||
(left.y - right.y).abs() <= tolerance
|
||||
}
|
||||
|
||||
fn is_inline_script(rule: &Rule, candidate: &TextItem, parent: &TextItem) -> bool {
|
||||
if !is_underline_candidate(candidate)
|
||||
|| is_bare_list_marker(&candidate.text)
|
||||
|| candidate.font_size <= 0.0
|
||||
|| candidate.font_size >= parent.font_size * 0.75
|
||||
|| candidate.text.len() > 4
|
||||
|| !candidate.text.chars().all(|c| c.is_ascii_digit())
|
||||
|| (candidate.y - parent.y).abs() > 5.0
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
let parent_ends_with_letter = parent.text.chars().last().is_some_and(char::is_alphabetic);
|
||||
if !parent_ends_with_letter {
|
||||
return false;
|
||||
}
|
||||
|
||||
let parent_right = parent.x + parent.width;
|
||||
let gap = candidate.x - parent_right;
|
||||
if gap >= parent.font_size * 0.2 || gap <= -parent.font_size * 0.3 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let overlap = rule.x2.min(candidate.x + candidate.width) - rule.x1.max(candidate.x);
|
||||
overlap >= candidate.width * MIN_X_OVERLAP
|
||||
}
|
||||
|
||||
/// Return the items owned by a snug mid-glyph rule.
|
||||
///
|
||||
/// Real strikeout decorations track the width of the deleted text, including
|
||||
/// runs split by font/style changes and adjacent numeric super/subscripts.
|
||||
/// Non-text graphics can cross the same vertical window, but arrow shafts,
|
||||
/// signature lines, fraction bars, and chart rules extend materially beyond
|
||||
/// the intersected glyphs. Requiring the rule to stay within a small em-sized
|
||||
/// pad of a contiguous matched row separates those cases without relying on
|
||||
/// document-specific fonts or coordinates.
|
||||
///
|
||||
/// Ownership is computed once per rule. This keeps the strikeout pass at the
|
||||
/// same rule-by-item scale as underline detection instead of rescanning the
|
||||
/// whole page for every matching item.
|
||||
fn snug_strike_owner_indices(rule: &Rule, items: &[TextItem]) -> Vec<usize> {
|
||||
let mut struck_indices: Vec<usize> = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, candidate)| {
|
||||
(is_underline_candidate(candidate)
|
||||
&& !is_bare_list_marker(&candidate.text)
|
||||
&& rule_strikes_item(rule, candidate))
|
||||
.then_some(index)
|
||||
})
|
||||
.collect();
|
||||
if struck_indices.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
struck_indices.sort_by(|&left, &right| items[left].y.total_cmp(&items[right].y));
|
||||
|
||||
let mut rows: Vec<Vec<usize>> = Vec::new();
|
||||
for index in struck_indices {
|
||||
if let Some(row) = rows
|
||||
.last_mut()
|
||||
.filter(|row| same_strike_row(&items[row[0]], &items[index]))
|
||||
{
|
||||
row.push(index);
|
||||
} else {
|
||||
rows.push(vec![index]);
|
||||
}
|
||||
}
|
||||
|
||||
let mut owned_indices = Vec::new();
|
||||
for mut row in rows {
|
||||
row.sort_by(|&left, &right| items[left].x.total_cmp(&items[right].x));
|
||||
|
||||
// Underline detection runs before the extractor's script-merging
|
||||
// pass. Include the same tightly adjacent numeric script shape here
|
||||
// when the rule spans it, so the owner width and semantic mark both
|
||||
// survive that later merge.
|
||||
let scripts: Vec<usize> = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, candidate)| {
|
||||
let parent_pos =
|
||||
row.partition_point(|&row_index| items[row_index].x <= candidate.x);
|
||||
let parent_index = parent_pos.checked_sub(1).map(|pos| row[pos])?;
|
||||
is_inline_script(rule, candidate, &items[parent_index]).then_some(index)
|
||||
})
|
||||
.collect();
|
||||
row.extend(scripts);
|
||||
row.sort_by(|&left, &right| items[left].x.total_cmp(&items[right].x));
|
||||
row.dedup();
|
||||
|
||||
let x1 = row
|
||||
.iter()
|
||||
.map(|&index| items[index].x)
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
let x2 = row
|
||||
.iter()
|
||||
.map(|&index| items[index].x + items[index].width)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
let max_font_size = row
|
||||
.iter()
|
||||
.map(|&index| items[index].font_size)
|
||||
.fold(0.0, f32::max);
|
||||
let pad = (max_font_size * STRIKE_OWNER_PAD_EM).max(STRIKE_OWNER_MIN_PAD);
|
||||
|
||||
if rule.x1 < x1 - pad || rule.x2 > x2 + pad {
|
||||
continue;
|
||||
}
|
||||
|
||||
let contiguous = row.windows(2).all(|pair| {
|
||||
let gap = items[pair[1]].x - (items[pair[0]].x + items[pair[0]].width);
|
||||
gap <= (max_font_size * 2.0).max(12.0)
|
||||
});
|
||||
if contiguous {
|
||||
owned_indices.extend(row);
|
||||
}
|
||||
}
|
||||
|
||||
owned_indices.sort_unstable();
|
||||
owned_indices.dedup();
|
||||
owned_indices
|
||||
}
|
||||
|
||||
/// Diagram and table rules participate in larger path geometry. A vertical
|
||||
/// or diagonal segment meeting the candidate rule is strong evidence that
|
||||
/// the horizontal segment is a connector, border, arrow, or symbol rather
|
||||
/// than an isolated text decoration.
|
||||
fn has_connected_nonhorizontal_segment(rule: &Rule, lines: &[UnderlineLine], page: u32) -> bool {
|
||||
lines.iter().any(|line| {
|
||||
if line.page != page {
|
||||
return false;
|
||||
}
|
||||
|
||||
let dx = line.x2 - line.x1;
|
||||
let dy = line.y2 - line.y1;
|
||||
if dy.abs() <= MAX_RULE_THICKNESS {
|
||||
return false;
|
||||
}
|
||||
|
||||
let y_min = line.y1.min(line.y2) - GRAPHIC_CONNECTION_EPS;
|
||||
let y_max = line.y1.max(line.y2) + GRAPHIC_CONNECTION_EPS;
|
||||
if rule.y < y_min || rule.y > y_max {
|
||||
return false;
|
||||
}
|
||||
|
||||
let t = (rule.y - line.y1) / dy;
|
||||
if !(-0.05..=1.05).contains(&t) {
|
||||
return false;
|
||||
}
|
||||
let intersection_x = line.x1 + t * dx;
|
||||
intersection_x >= rule.x1 - GRAPHIC_CONNECTION_EPS
|
||||
&& intersection_x <= rule.x2 + GRAPHIC_CONNECTION_EPS
|
||||
})
|
||||
}
|
||||
|
||||
/// Filled diagrams often build connectors from intersecting thin rectangles
|
||||
/// instead of stroked path segments. Treat only narrow, vertically elongated
|
||||
/// rectangles as connector geometry; broad fills can legitimately sit behind
|
||||
/// struck text and must not veto its decoration.
|
||||
fn has_connected_nonhorizontal_rect(rule: &Rule, rects: &[PdfRect], page: u32) -> bool {
|
||||
rects.iter().any(|rect| {
|
||||
if rect.page != page {
|
||||
return false;
|
||||
}
|
||||
|
||||
let (x1, x2) = if rect.width >= 0.0 {
|
||||
(rect.x, rect.x + rect.width)
|
||||
} else {
|
||||
(rect.x + rect.width, rect.x)
|
||||
};
|
||||
let (y1, y2) = if rect.height >= 0.0 {
|
||||
(rect.y, rect.y + rect.height)
|
||||
} else {
|
||||
(rect.y + rect.height, rect.y)
|
||||
};
|
||||
let width = x2 - x1;
|
||||
let height = y2 - y1;
|
||||
|
||||
width > 0.0
|
||||
&& width <= GRAPHIC_CONNECTOR_MAX_THICKNESS
|
||||
&& height > width * 2.0
|
||||
&& rule.y >= y1 - GRAPHIC_CONNECTION_EPS
|
||||
&& rule.y <= y2 + GRAPHIC_CONNECTION_EPS
|
||||
&& x2 >= rule.x1 - GRAPHIC_CONNECTION_EPS
|
||||
&& x1 <= rule.x2 + GRAPHIC_CONNECTION_EPS
|
||||
})
|
||||
}
|
||||
|
||||
/// Mark `is_underline` on text items that have a horizontal rule just
|
||||
/// below their baseline, and `is_strikeout` on items whose glyphs a rule
|
||||
/// crosses at mid x-height. `items`, `rects`, and `lines` are a single
|
||||
@@ -440,27 +650,43 @@ pub(crate) fn mark_underlined_items(
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
|
||||
for item in items.iter_mut() {
|
||||
let mut strikeout_items = vec![false; items.len()];
|
||||
for (rule_idx, rule) in rules.iter().enumerate() {
|
||||
if tabular_rules.contains(&rule_idx)
|
||||
|| has_connected_nonhorizontal_segment(rule, lines, page)
|
||||
|| has_connected_nonhorizontal_rect(rule, rects, page)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
for item_idx in snug_strike_owner_indices(rule, items) {
|
||||
strikeout_items[item_idx] = true;
|
||||
}
|
||||
}
|
||||
|
||||
let underlined_items: HashSet<usize> = items
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, item)| {
|
||||
is_underline_candidate(item)
|
||||
&& rules.iter().enumerate().any(|(rule_idx, rule)| {
|
||||
!tabular_rules.contains(&rule_idx)
|
||||
&& !fraction_rules.contains(&rule_idx)
|
||||
&& rule_matches_item(rule, item)
|
||||
})
|
||||
})
|
||||
.map(|(item_idx, _)| item_idx)
|
||||
.collect();
|
||||
|
||||
for (item_idx, item) in items.iter_mut().enumerate() {
|
||||
if !is_underline_candidate(item) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (rule_idx, rule) in rules.iter().enumerate() {
|
||||
if tabular_rules.contains(&rule_idx) {
|
||||
continue;
|
||||
}
|
||||
// The fraction guard only gates UNDERLINE marking — a rule that
|
||||
// reads as a fraction bar from below can still legitimately
|
||||
// strike through a line above it.
|
||||
if !fraction_rules.contains(&rule_idx) && rule_matches_item(rule, item) {
|
||||
item.is_underline = true;
|
||||
}
|
||||
if rule_strikes_item(rule, item) {
|
||||
item.is_strikeout = true;
|
||||
}
|
||||
if item.is_underline && item.is_strikeout {
|
||||
break;
|
||||
}
|
||||
if strikeout_items[item_idx] {
|
||||
item.is_strikeout = true;
|
||||
}
|
||||
if underlined_items.contains(&item_idx) {
|
||||
item.is_underline = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -580,6 +806,148 @@ mod tests {
|
||||
assert!(!items[0].is_underline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_connector_crossing_text_is_not_a_strikeout() {
|
||||
let mut items = vec![item("diagram label", 160.0, 500.0, 60.0, 10.0)];
|
||||
let lines = vec![hline(100.0, 280.0, 503.0)];
|
||||
|
||||
mark_underlined_items(&mut items, &[], &lines, 1);
|
||||
|
||||
assert!(!items[0].is_strikeout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_rule_ending_inside_short_label_is_not_a_strikeout() {
|
||||
let mut items = vec![item("T 18", 300.0, 500.0, 20.0, 10.0)];
|
||||
let lines = vec![hline(100.0, 315.0, 503.0)];
|
||||
|
||||
mark_underlined_items(&mut items, &[], &lines, 1);
|
||||
|
||||
assert!(!items[0].is_strikeout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connected_diagram_segment_is_not_a_strikeout() {
|
||||
let mut items = vec![item("V8", 100.0, 500.0, 12.0, 10.0)];
|
||||
let lines = vec![
|
||||
hline(99.0, 113.0, 503.0),
|
||||
UnderlineLine {
|
||||
x1: 106.0,
|
||||
y1: 496.0,
|
||||
x2: 109.0,
|
||||
y2: 510.0,
|
||||
stroke_width: 1.0,
|
||||
page: 1,
|
||||
},
|
||||
];
|
||||
|
||||
mark_underlined_items(&mut items, &[], &lines, 1);
|
||||
|
||||
assert!(!items[0].is_strikeout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connected_filled_rect_is_not_a_strikeout() {
|
||||
let mut items = vec![item("V8", 100.0, 500.0, 12.0, 10.0)];
|
||||
let rects = vec![
|
||||
thin_rect(99.0, 502.6, 14.0),
|
||||
PdfRect {
|
||||
x: 106.0,
|
||||
y: 496.0,
|
||||
width: 2.0,
|
||||
height: 14.0,
|
||||
page: 1,
|
||||
},
|
||||
];
|
||||
|
||||
mark_underlined_items(&mut items, &rects, &[], 1);
|
||||
|
||||
assert!(!items[0].is_strikeout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broad_fill_behind_text_does_not_block_strikeout() {
|
||||
let mut items = vec![item("deleted", 100.0, 500.0, 40.0, 10.0)];
|
||||
let rects = vec![
|
||||
thin_rect(99.0, 502.6, 42.0),
|
||||
PdfRect {
|
||||
x: 90.0,
|
||||
y: 490.0,
|
||||
width: 100.0,
|
||||
height: 20.0,
|
||||
page: 1,
|
||||
},
|
||||
];
|
||||
|
||||
mark_underlined_items(&mut items, &rects, &[], 1);
|
||||
|
||||
assert!(items[0].is_strikeout);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_bullet_is_not_a_strikeout() {
|
||||
for marker in ["•", "-", "*"] {
|
||||
let mut items = vec![item(marker, 100.0, 500.0, 6.0, 10.0)];
|
||||
let lines = vec![hline(99.0, 107.0, 503.0)];
|
||||
|
||||
mark_underlined_items(&mut items, &[], &lines, 1);
|
||||
|
||||
assert!(!items[0].is_strikeout, "marker {marker:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snug_rule_marks_adjacent_split_runs_as_strikeout() {
|
||||
let mut items = vec![
|
||||
item("deleted", 100.0, 500.0, 40.0, 10.0),
|
||||
item("text", 142.0, 500.0, 25.0, 10.0),
|
||||
];
|
||||
let lines = vec![hline(99.0, 168.0, 503.0)];
|
||||
|
||||
mark_underlined_items(&mut items, &[], &lines, 1);
|
||||
|
||||
assert!(items.iter().all(|item| item.is_strikeout));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snug_rule_groups_split_runs_with_baseline_drift() {
|
||||
let mut items = vec![
|
||||
item("deleted", 100.0, 500.0, 40.0, 10.0),
|
||||
item("text", 142.0, 498.0, 25.0, 10.0),
|
||||
];
|
||||
let lines = vec![hline(99.0, 168.0, 503.0)];
|
||||
|
||||
mark_underlined_items(&mut items, &[], &lines, 1);
|
||||
|
||||
assert!(items.iter().all(|item| item.is_strikeout));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snug_rule_keeps_inline_superscript_in_strike_owner() {
|
||||
let mut items = vec![
|
||||
item("deleted", 100.0, 500.0, 40.0, 10.0),
|
||||
item("2", 140.5, 503.0, 4.0, 6.0),
|
||||
];
|
||||
let lines = vec![hline(99.0, 145.0, 503.0)];
|
||||
|
||||
mark_underlined_items(&mut items, &[], &lines, 1);
|
||||
|
||||
assert!(items.iter().all(|item| item.is_strikeout));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snug_rule_keeps_inline_subscript_in_strike_owner() {
|
||||
let mut items = vec![
|
||||
item("deleted", 100.0, 500.0, 40.0, 10.0),
|
||||
item("2", 140.5, 497.0, 4.0, 6.0),
|
||||
];
|
||||
let lines = vec![hline(99.0, 145.0, 503.0)];
|
||||
|
||||
mark_underlined_items(&mut items, &[], &lines, 1);
|
||||
|
||||
assert!(items.iter().all(|item| item.is_strikeout));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn baseline_rule_marks_underline_not_strikeout() {
|
||||
let mut items = vec![item("underlined", 100.0, 500.0, 60.0, 10.0)];
|
||||
|
||||
+609
-26
@@ -8,13 +8,84 @@ use lopdf::{Document, Encoding, Object, ObjectId};
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::fonts::{
|
||||
build_font_encodings, build_font_widths, compute_string_width_ts, extract_text_from_operand,
|
||||
get_font_file2_obj_num, get_operand_bytes, CMapDecisionCache, FontStyleCache,
|
||||
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,
|
||||
};
|
||||
use super::{get_number, image_bbox_from_ctm, multiply_matrices};
|
||||
|
||||
const MAX_FORM_XOBJECT_DEPTH: u8 = 5;
|
||||
|
||||
/// Upper bound on Form XObject invocations during a single page extraction.
|
||||
/// Depth alone is not enough: an acyclic DAG where each form invokes the next
|
||||
/// N times expands to N^depth work before the depth cap is reached.
|
||||
const MAX_FORM_XOBJECT_INVOCATIONS: usize = 10_000;
|
||||
|
||||
/// Upper bound on content-stream operations walked across all Form XObject
|
||||
/// expansions for a page. Nested forms are decoded independently of the
|
||||
/// page-level operation cap, so this keeps total form work in the same
|
||||
/// ballpark as that page cap.
|
||||
const MAX_FORM_XOBJECT_OPERATIONS: usize = 1_000_000;
|
||||
|
||||
/// Shared budget for Form XObject expansion on a page. Bounds both nested DAG
|
||||
/// expansion and repeated sibling `/Do` invocations of the same form.
|
||||
pub(crate) struct FormWalkBudget {
|
||||
invocations: usize,
|
||||
operations: usize,
|
||||
max_invocations: usize,
|
||||
max_operations: usize,
|
||||
truncated: bool,
|
||||
}
|
||||
|
||||
impl FormWalkBudget {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self::with_limits(MAX_FORM_XOBJECT_INVOCATIONS, MAX_FORM_XOBJECT_OPERATIONS)
|
||||
}
|
||||
|
||||
fn with_limits(max_invocations: usize, max_operations: usize) -> Self {
|
||||
Self {
|
||||
invocations: 0,
|
||||
operations: 0,
|
||||
max_invocations,
|
||||
max_operations,
|
||||
truncated: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn exhausted(&mut self) -> bool {
|
||||
if self.invocations >= self.max_invocations || self.operations >= self.max_operations {
|
||||
self.truncated = true;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn charge_invocation(&mut self) -> bool {
|
||||
if self.exhausted() {
|
||||
return false;
|
||||
}
|
||||
self.invocations += 1;
|
||||
true
|
||||
}
|
||||
|
||||
/// Charge one walked content-stream operator. Independent of the
|
||||
/// invocation cap so a form that was already admitted can finish its
|
||||
/// stream (up to the operation cap).
|
||||
fn charge_operation(&mut self) -> bool {
|
||||
if self.operations >= self.max_operations {
|
||||
self.truncated = true;
|
||||
return false;
|
||||
}
|
||||
self.operations += 1;
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) fn was_truncated(&self) -> bool {
|
||||
self.truncated
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) enum XObjectType {
|
||||
Image,
|
||||
Form(ObjectId),
|
||||
@@ -108,6 +179,7 @@ fn collect_xobjects_from_dict(
|
||||
}
|
||||
|
||||
/// Extract text items from a Form XObject
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn extract_form_xobject_text(
|
||||
doc: &Document,
|
||||
form_id: ObjectId,
|
||||
@@ -116,6 +188,7 @@ pub(crate) fn extract_form_xobject_text(
|
||||
parent_ctm: &[f32; 6],
|
||||
cmap_decisions: &mut CMapDecisionCache,
|
||||
style_cache: &mut FontStyleCache,
|
||||
budget: &mut FormWalkBudget,
|
||||
) -> Vec<TextItem> {
|
||||
extract_form_xobject_text_inner(
|
||||
doc,
|
||||
@@ -126,6 +199,7 @@ pub(crate) fn extract_form_xobject_text(
|
||||
cmap_decisions,
|
||||
style_cache,
|
||||
0,
|
||||
budget,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -139,11 +213,14 @@ fn extract_form_xobject_text_inner(
|
||||
cmap_decisions: &mut CMapDecisionCache,
|
||||
style_cache: &mut FontStyleCache,
|
||||
depth: u8,
|
||||
budget: &mut FormWalkBudget,
|
||||
) -> Vec<TextItem> {
|
||||
use lopdf::content::Content;
|
||||
|
||||
let mut items = Vec::new();
|
||||
|
||||
if !budget.charge_invocation() {
|
||||
return items;
|
||||
}
|
||||
|
||||
// Get the Form XObject stream
|
||||
let Ok(Object::Stream(stream)) = doc.get_object(form_id) else {
|
||||
return items;
|
||||
@@ -155,17 +232,22 @@ fn extract_form_xobject_text_inner(
|
||||
Err(_) => stream.content.clone(),
|
||||
};
|
||||
|
||||
// Decode the content stream
|
||||
let Ok(content) = Content::decode(&content_data) else {
|
||||
// Decode the content stream. Cap before lopdf materializes the operator
|
||||
// vector — the walk budget cannot help if decode itself allocates first.
|
||||
let Ok(Some(content)) = super::content_decode::decode_content_bounded(
|
||||
&content_data,
|
||||
super::content_decode::MAX_PAGE_OPERATIONS,
|
||||
) else {
|
||||
return items;
|
||||
};
|
||||
|
||||
// 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);
|
||||
let (font_encodings, _has_gid_fonts) = build_font_encodings(doc, &form_fonts, font_cmaps);
|
||||
|
||||
// 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();
|
||||
@@ -244,19 +326,55 @@ fn extract_form_xobject_text_inner(
|
||||
let mut current_font = String::new();
|
||||
let mut current_font_size: f32 = 12.0;
|
||||
let mut text_matrix = [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0];
|
||||
// Text line matrix (TLM) — Td/TD/T* move relative to the start of the
|
||||
// current line, not to the position left by the last show operator.
|
||||
let mut line_matrix = [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0];
|
||||
let mut text_leading: f32 = 0.0; // TL parameter (text-space units)
|
||||
let mut char_spacing: f32 = 0.0; // Tc parameter
|
||||
let mut word_spacing: f32 = 0.0; // Tw parameter
|
||||
let mut in_text_block = false;
|
||||
let mut fill_is_white = false;
|
||||
let mut ctm = base_ctm;
|
||||
let mut ctm_stack: Vec<[f32; 6]> = Vec::new();
|
||||
|
||||
// Text state (Tc/Tw/TL/Tf) and the fill colour are part of the graphics
|
||||
// state and must be saved/restored by q/Q alongside the CTM.
|
||||
#[derive(Clone)]
|
||||
struct GraphicsState {
|
||||
ctm: [f32; 6],
|
||||
char_spacing: f32,
|
||||
word_spacing: f32,
|
||||
text_leading: f32,
|
||||
current_font: String,
|
||||
current_font_size: f32,
|
||||
fill_is_white: bool,
|
||||
}
|
||||
let mut ctm_stack: Vec<GraphicsState> = Vec::new();
|
||||
|
||||
for op in &content.operations {
|
||||
if !budget.charge_operation() {
|
||||
break;
|
||||
}
|
||||
match op.operator.as_str() {
|
||||
"q" => {
|
||||
ctm_stack.push(ctm);
|
||||
ctm_stack.push(GraphicsState {
|
||||
ctm,
|
||||
char_spacing,
|
||||
word_spacing,
|
||||
text_leading,
|
||||
current_font: current_font.clone(),
|
||||
current_font_size,
|
||||
fill_is_white,
|
||||
});
|
||||
}
|
||||
"Q" => {
|
||||
if let Some(saved) = ctm_stack.pop() {
|
||||
ctm = saved;
|
||||
ctm = saved.ctm;
|
||||
char_spacing = saved.char_spacing;
|
||||
word_spacing = saved.word_spacing;
|
||||
text_leading = saved.text_leading;
|
||||
current_font = saved.current_font;
|
||||
current_font_size = saved.current_font_size;
|
||||
fill_is_white = saved.fill_is_white;
|
||||
}
|
||||
}
|
||||
"cm" => {
|
||||
@@ -274,7 +392,7 @@ fn extract_form_xobject_text_inner(
|
||||
let xobj_name = String::from_utf8_lossy(name).to_string();
|
||||
match form_xobjects.get(&xobj_name) {
|
||||
Some(XObjectType::Form(nested_id)) => {
|
||||
if depth < MAX_FORM_XOBJECT_DEPTH {
|
||||
if depth < MAX_FORM_XOBJECT_DEPTH && !budget.exhausted() {
|
||||
let nested_items = extract_form_xobject_text_inner(
|
||||
doc,
|
||||
*nested_id,
|
||||
@@ -284,6 +402,7 @@ fn extract_form_xobject_text_inner(
|
||||
cmap_decisions,
|
||||
style_cache,
|
||||
depth + 1,
|
||||
budget,
|
||||
);
|
||||
items.extend(nested_items);
|
||||
}
|
||||
@@ -319,6 +438,7 @@ fn extract_form_xobject_text_inner(
|
||||
"BT" => {
|
||||
in_text_block = true;
|
||||
text_matrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
|
||||
line_matrix = text_matrix;
|
||||
}
|
||||
"ET" => {
|
||||
in_text_block = false;
|
||||
@@ -331,12 +451,33 @@ fn extract_form_xobject_text_inner(
|
||||
current_font_size = get_number(&op.operands[1]).unwrap_or(12.0);
|
||||
}
|
||||
}
|
||||
"TL" => {
|
||||
// Set text leading (used by T*, ', and ")
|
||||
if let Some(tl) = op.operands.first().and_then(get_number) {
|
||||
text_leading = tl;
|
||||
}
|
||||
}
|
||||
"Tc" => {
|
||||
if let Some(tc) = op.operands.first().and_then(get_number) {
|
||||
char_spacing = tc;
|
||||
}
|
||||
}
|
||||
"Tw" => {
|
||||
if let Some(tw) = op.operands.first().and_then(get_number) {
|
||||
word_spacing = tw;
|
||||
}
|
||||
}
|
||||
"Td" | "TD" => {
|
||||
// Move text position: TLM = T(tx,ty) x TLM; Tm = TLM
|
||||
if op.operands.len() >= 2 {
|
||||
let tx = get_number(&op.operands[0]).unwrap_or(0.0);
|
||||
let ty = get_number(&op.operands[1]).unwrap_or(0.0);
|
||||
text_matrix[4] += tx * text_matrix[0] + ty * text_matrix[2];
|
||||
text_matrix[5] += tx * text_matrix[1] + ty * text_matrix[3];
|
||||
line_matrix[4] += tx * line_matrix[0] + ty * line_matrix[2];
|
||||
line_matrix[5] += tx * line_matrix[1] + ty * line_matrix[3];
|
||||
text_matrix = line_matrix;
|
||||
if op.operator == "TD" {
|
||||
text_leading = -ty;
|
||||
}
|
||||
}
|
||||
}
|
||||
"Tm" => {
|
||||
@@ -345,8 +486,20 @@ fn extract_form_xobject_text_inner(
|
||||
text_matrix[i] =
|
||||
get_number(operand).unwrap_or(if i == 0 || i == 3 { 1.0 } else { 0.0 });
|
||||
}
|
||||
line_matrix = text_matrix;
|
||||
}
|
||||
}
|
||||
"T*" => {
|
||||
// Move to start of next line: equivalent to `0 -TL Td`
|
||||
let tl = if text_leading != 0.0 {
|
||||
text_leading
|
||||
} else {
|
||||
current_font_size * 1.2
|
||||
};
|
||||
line_matrix[4] += (-tl) * line_matrix[2];
|
||||
line_matrix[5] += (-tl) * line_matrix[3];
|
||||
text_matrix = line_matrix;
|
||||
}
|
||||
"g" => {
|
||||
if let Some(gray) = op.operands.first().and_then(get_number) {
|
||||
fill_is_white = gray > 0.95;
|
||||
@@ -382,17 +535,33 @@ fn extract_form_xobject_text_inner(
|
||||
_ => fill_is_white = false,
|
||||
}
|
||||
}
|
||||
"Tj" => {
|
||||
if in_text_block && !op.operands.is_empty() {
|
||||
"Tj" | "'" | "\"" => {
|
||||
// `'` = move to next line then show; `"` = set word/char spacing,
|
||||
// move to next line, then show (string is the last operand).
|
||||
if op.operator != "Tj" {
|
||||
if op.operator == "\"" && op.operands.len() >= 3 {
|
||||
word_spacing = get_number(&op.operands[0]).unwrap_or(word_spacing);
|
||||
char_spacing = get_number(&op.operands[1]).unwrap_or(char_spacing);
|
||||
}
|
||||
let tl = if text_leading != 0.0 {
|
||||
text_leading
|
||||
} else {
|
||||
current_font_size * 1.2
|
||||
};
|
||||
line_matrix[4] += (-tl) * line_matrix[2];
|
||||
line_matrix[5] += (-tl) * line_matrix[3];
|
||||
text_matrix = line_matrix;
|
||||
}
|
||||
if let (true, Some(show_operand)) = (in_text_block, op.operands.last()) {
|
||||
if fill_is_white {
|
||||
if let Some(font_info) = font_widths.get(¤t_font) {
|
||||
if let Some(raw_bytes) = get_operand_bytes(&op.operands[0]) {
|
||||
if let Some(raw_bytes) = get_operand_bytes(show_operand) {
|
||||
let w_ts = compute_string_width_ts(
|
||||
raw_bytes,
|
||||
font_info,
|
||||
current_font_size,
|
||||
0.0,
|
||||
0.0,
|
||||
char_spacing,
|
||||
word_spacing,
|
||||
);
|
||||
text_matrix[4] += w_ts * text_matrix[0];
|
||||
text_matrix[5] += w_ts * text_matrix[1];
|
||||
@@ -401,7 +570,7 @@ fn extract_form_xobject_text_inner(
|
||||
continue;
|
||||
}
|
||||
if let Some(text) = extract_text_from_operand(
|
||||
&op.operands[0],
|
||||
show_operand,
|
||||
¤t_font,
|
||||
font_base_names.get(¤t_font).map(|s| s.as_str()),
|
||||
font_cmaps,
|
||||
@@ -413,16 +582,17 @@ fn extract_form_xobject_text_inner(
|
||||
&font_widths,
|
||||
) {
|
||||
let combined = multiply_matrices(&text_matrix, &ctm);
|
||||
let rendered_size = effective_font_size(current_font_size, &combined);
|
||||
let rendered_size = effective_font_size(current_font_size, &combined)
|
||||
* type3_scales.get(¤t_font).copied().unwrap_or(1.0);
|
||||
let (x, y) = (combined[4], combined[5]);
|
||||
let width = if let Some(font_info) = font_widths.get(¤t_font) {
|
||||
if let Some(raw_bytes) = get_operand_bytes(&op.operands[0]) {
|
||||
if let Some(raw_bytes) = get_operand_bytes(show_operand) {
|
||||
let w_ts = compute_string_width_ts(
|
||||
raw_bytes,
|
||||
font_info,
|
||||
current_font_size,
|
||||
0.0,
|
||||
0.0,
|
||||
char_spacing,
|
||||
word_spacing,
|
||||
);
|
||||
text_matrix[4] += w_ts * text_matrix[0];
|
||||
text_matrix[5] += w_ts * text_matrix[1];
|
||||
@@ -545,8 +715,8 @@ fn extract_form_xobject_text_inner(
|
||||
raw_bytes,
|
||||
fi,
|
||||
current_font_size,
|
||||
0.0,
|
||||
0.0,
|
||||
char_spacing,
|
||||
word_spacing,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -572,7 +742,8 @@ 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);
|
||||
let rendered_size = effective_font_size(current_font_size, &combined)
|
||||
* type3_scales.get(¤t_font).copied().unwrap_or(1.0);
|
||||
let base_font = font_base_names
|
||||
.get(¤t_font)
|
||||
.map(|s| s.as_str())
|
||||
@@ -679,3 +850,415 @@ pub(crate) fn get_form_fonts<'a>(
|
||||
|
||||
fonts
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::extractor::content_stream::extract_page_text_items;
|
||||
use lopdf::{dictionary, Dictionary, Stream};
|
||||
|
||||
/// Build an acyclic Form XObject DAG: `levels` form objects, each non-leaf
|
||||
/// invoking the next form `branches` times. The leaf draws a single `(X)`.
|
||||
/// Returns `(doc, root_form_id)`.
|
||||
fn form_dag(branches: usize, levels: usize) -> (Document, ObjectId) {
|
||||
assert!(levels >= 2);
|
||||
let mut doc = Document::new();
|
||||
let font_id = doc.add_object(dictionary! {
|
||||
"Type" => "Font",
|
||||
"Subtype" => "Type1",
|
||||
"BaseFont" => "Helvetica",
|
||||
});
|
||||
let ids: Vec<ObjectId> = (0..levels).map(|_| doc.new_object_id()).collect();
|
||||
for level in 0..levels {
|
||||
let stream = if level + 1 == levels {
|
||||
Stream::new(
|
||||
dictionary! {
|
||||
"Type" => "XObject",
|
||||
"Subtype" => "Form",
|
||||
"BBox" => vec![0.into(), 0.into(), 100.into(), 100.into()],
|
||||
"Resources" => dictionary! {
|
||||
"Font" => dictionary! {
|
||||
"F1" => Object::Reference(font_id),
|
||||
},
|
||||
},
|
||||
},
|
||||
b"BT /F1 10 Tf 10 10 Td (X) Tj ET\n".to_vec(),
|
||||
)
|
||||
} else {
|
||||
let next_name = format!("Fm{}", level + 1);
|
||||
let content = format!("/{next_name} Do\n").repeat(branches);
|
||||
let mut xobjects = Dictionary::new();
|
||||
xobjects.set(next_name, Object::Reference(ids[level + 1]));
|
||||
let mut resources = Dictionary::new();
|
||||
resources.set("XObject", Object::Dictionary(xobjects));
|
||||
let mut dict = dictionary! {
|
||||
"Type" => "XObject",
|
||||
"Subtype" => "Form",
|
||||
"BBox" => vec![0.into(), 0.into(), 100.into(), 100.into()],
|
||||
};
|
||||
dict.set("Resources", Object::Dictionary(resources));
|
||||
Stream::new(dict, content.into_bytes())
|
||||
};
|
||||
doc.set_object(ids[level], Object::Stream(stream));
|
||||
}
|
||||
(doc, ids[0])
|
||||
}
|
||||
|
||||
fn page_invoking_form(mut doc: Document, form_id: ObjectId) -> (Document, ObjectId) {
|
||||
let content_id = doc.add_object(Object::Stream(Stream::new(
|
||||
dictionary! {},
|
||||
b"/Fm0 Do\n".to_vec(),
|
||||
)));
|
||||
let page_id = doc.add_object(dictionary! {
|
||||
"Type" => "Page",
|
||||
"Contents" => Object::Reference(content_id),
|
||||
"Resources" => dictionary! {
|
||||
"XObject" => dictionary! {
|
||||
"Fm0" => Object::Reference(form_id),
|
||||
},
|
||||
},
|
||||
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
|
||||
});
|
||||
let pages_id = doc.add_object(dictionary! {
|
||||
"Type" => "Pages",
|
||||
"Count" => Object::Integer(1),
|
||||
"Kids" => vec![Object::Reference(page_id)],
|
||||
});
|
||||
let catalog_id = doc.add_object(dictionary! {
|
||||
"Type" => "Catalog",
|
||||
"Pages" => Object::Reference(pages_id),
|
||||
});
|
||||
doc.trailer.set("Root", Object::Reference(catalog_id));
|
||||
(doc, page_id)
|
||||
}
|
||||
|
||||
fn extract_form(
|
||||
doc: &Document,
|
||||
form_id: ObjectId,
|
||||
budget: &mut FormWalkBudget,
|
||||
) -> Vec<TextItem> {
|
||||
extract_form_xobject_text(
|
||||
doc,
|
||||
form_id,
|
||||
1,
|
||||
&FontCMaps::from_doc(doc),
|
||||
&[1.0, 0.0, 0.0, 1.0, 0.0, 0.0],
|
||||
&mut CMapDecisionCache::new(),
|
||||
&mut FontStyleCache::new(),
|
||||
budget,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_form_still_extracts_leaf_text() {
|
||||
let (doc, root) = form_dag(1, 3);
|
||||
let items = extract_form(&doc, root, &mut FormWalkBudget::new());
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].text, "X");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acyclic_form_dag_within_budget_keeps_all_leaves() {
|
||||
// 4 sibling invocations across 4 nested levels → 4^4 leaf drawings.
|
||||
// Default budgets are far above 256, so legitimate nesting is intact.
|
||||
let (doc, root) = form_dag(4, 5);
|
||||
let items = extract_form(&doc, root, &mut FormWalkBudget::new());
|
||||
assert_eq!(items.len(), 4usize.pow(4));
|
||||
assert!(items.iter().all(|item| item.text == "X"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acyclic_form_dag_stops_at_invocation_budget() {
|
||||
// Same DAG as above would draw 256 leaves; a tiny invocation cap must
|
||||
// stop expansion rather than walking the full tree.
|
||||
let (doc, root) = form_dag(4, 5);
|
||||
let mut budget = FormWalkBudget::with_limits(20, MAX_FORM_XOBJECT_OPERATIONS);
|
||||
let items = extract_form(&doc, root, &mut budget);
|
||||
assert!(
|
||||
items.len() < 4usize.pow(4),
|
||||
"invocation budget must truncate DAG expansion; got {} items",
|
||||
items.len()
|
||||
);
|
||||
assert!(budget.was_truncated());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn form_operations_stop_at_budget() {
|
||||
let mut doc = Document::new();
|
||||
let font_id = doc.add_object(dictionary! {
|
||||
"Type" => "Font",
|
||||
"Subtype" => "Type1",
|
||||
"BaseFont" => "Helvetica",
|
||||
});
|
||||
let mut content = b"q Q\n".repeat(50);
|
||||
content.extend_from_slice(b"BT /F1 10 Tf 10 10 Td (X) Tj ET\n");
|
||||
let form_id = doc.add_object(Object::Stream(Stream::new(
|
||||
dictionary! {
|
||||
"Type" => "XObject",
|
||||
"Subtype" => "Form",
|
||||
"BBox" => vec![0.into(), 0.into(), 100.into(), 100.into()],
|
||||
"Resources" => dictionary! {
|
||||
"Font" => dictionary! {
|
||||
"F1" => Object::Reference(font_id),
|
||||
},
|
||||
},
|
||||
},
|
||||
content,
|
||||
)));
|
||||
|
||||
let mut budget = FormWalkBudget::with_limits(MAX_FORM_XOBJECT_INVOCATIONS, 10);
|
||||
let items = extract_form(&doc, form_id, &mut budget);
|
||||
assert!(
|
||||
items.is_empty(),
|
||||
"operation budget must stop before the trailing text show"
|
||||
);
|
||||
assert!(budget.was_truncated());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_level_form_dag_stays_within_production_budget() {
|
||||
// A page-level `/Do` of an 8-wide, 6-level Form DAG would expand to
|
||||
// 8^5 = 32_768 leaf drawings without a budget. The production
|
||||
// invocation cap must keep extraction bounded.
|
||||
let (doc, root) = form_dag(8, 6);
|
||||
let (doc, page_id) = page_invoking_form(doc, root);
|
||||
|
||||
let font_cmaps = FontCMaps::from_doc(&doc);
|
||||
let ((items, _, _), _, _, _) = extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
1,
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut FontStyleCache::new(),
|
||||
&mut FormWalkBudget::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
items.len() <= MAX_FORM_XOBJECT_INVOCATIONS,
|
||||
"page-level Form expansion must stay within the invocation cap; got {}",
|
||||
items.len()
|
||||
);
|
||||
assert!(
|
||||
!items.is_empty(),
|
||||
"budget must still allow some nested form text through"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_form_budget_spans_two_extraction_passes() {
|
||||
// The invisible-layer retry calls extract_page_text_items twice for
|
||||
// the same page; both passes must share one budget.
|
||||
let (doc, root) = form_dag(1, 2);
|
||||
let (doc, page_id) = page_invoking_form(doc, root);
|
||||
let font_cmaps = FontCMaps::from_doc(&doc);
|
||||
// Root + leaf = 2 invocations on the first pass.
|
||||
let mut budget = FormWalkBudget::with_limits(2, MAX_FORM_XOBJECT_OPERATIONS);
|
||||
let ((first, _, _), _, _, _) = extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
1,
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut FontStyleCache::new(),
|
||||
&mut budget,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(first.iter().filter(|item| item.text == "X").count(), 1);
|
||||
assert!(!budget.was_truncated());
|
||||
|
||||
let ((second, _, _), _, _, _) = extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
1,
|
||||
&font_cmaps,
|
||||
true,
|
||||
&mut FontStyleCache::new(),
|
||||
&mut budget,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
second.iter().all(|item| item.text != "X"),
|
||||
"second pass must not get a fresh invocation budget"
|
||||
);
|
||||
assert!(budget.was_truncated());
|
||||
}
|
||||
|
||||
/// Build a document whose page draws *all* of its content through a single
|
||||
/// Form XObject — the shape emitted by print-to-PDF producers like PDFlib,
|
||||
/// where the page stream itself is only `q /X1 Do Q`.
|
||||
fn doc_with_form_content(form_content: &[u8]) -> (Document, ObjectId) {
|
||||
let mut doc = Document::new();
|
||||
let widths: Vec<Object> = (0..=255).map(|_| 600.into()).collect();
|
||||
let font_id = doc.add_object(dictionary! {
|
||||
"Type" => "Font",
|
||||
"Subtype" => "Type1",
|
||||
"BaseFont" => "Helvetica",
|
||||
"FirstChar" => 0,
|
||||
"LastChar" => 255,
|
||||
"Widths" => Object::Array(widths),
|
||||
});
|
||||
let form_id = doc.add_object(Object::Stream(Stream::new(
|
||||
dictionary! {
|
||||
"Type" => "XObject",
|
||||
"Subtype" => "Form",
|
||||
"BBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
|
||||
"Resources" => dictionary! {
|
||||
"Font" => dictionary! { "F1" => Object::Reference(font_id) },
|
||||
},
|
||||
},
|
||||
form_content.to_vec(),
|
||||
)));
|
||||
let content_id = doc.add_object(Object::Stream(Stream::new(
|
||||
dictionary! {},
|
||||
b"q /X1 Do Q".to_vec(),
|
||||
)));
|
||||
let page_id = doc.add_object(dictionary! {
|
||||
"Type" => "Page",
|
||||
"Contents" => Object::Reference(content_id),
|
||||
"Resources" => dictionary! {
|
||||
"XObject" => dictionary! { "X1" => Object::Reference(form_id) },
|
||||
},
|
||||
"MediaBox" => vec![0.into(), 0.into(), 612.into(), 792.into()],
|
||||
});
|
||||
let pages_id = doc.add_object(dictionary! {
|
||||
"Type" => "Pages",
|
||||
"Count" => Object::Integer(1),
|
||||
"Kids" => vec![Object::Reference(page_id)],
|
||||
});
|
||||
let catalog_id = doc.add_object(dictionary! {
|
||||
"Type" => "Catalog",
|
||||
"Pages" => Object::Reference(pages_id),
|
||||
});
|
||||
doc.trailer.set("Root", Object::Reference(catalog_id));
|
||||
(doc, page_id)
|
||||
}
|
||||
|
||||
fn form_items(form_content: &[u8]) -> Vec<TextItem> {
|
||||
let (doc, page_id) = doc_with_form_content(form_content);
|
||||
let font_cmaps = FontCMaps::from_doc(&doc);
|
||||
let ((items, _, _), _, _, _) = extract_page_text_items(
|
||||
&doc,
|
||||
page_id,
|
||||
1,
|
||||
&font_cmaps,
|
||||
false,
|
||||
&mut FontStyleCache::new(),
|
||||
&mut FormWalkBudget::new(),
|
||||
)
|
||||
.unwrap();
|
||||
items
|
||||
}
|
||||
|
||||
fn find<'a>(items: &'a [TextItem], text: &str) -> &'a TextItem {
|
||||
items
|
||||
.iter()
|
||||
.find(|item| item.text == text)
|
||||
.unwrap_or_else(|| {
|
||||
let found: Vec<&String> = items.iter().map(|i| &i.text).collect();
|
||||
panic!("no item {text:?} in {found:?}")
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn t_star_inside_form_moves_to_next_line() {
|
||||
// T* was previously unhandled inside Form XObjects, so every line after
|
||||
// the first piled onto the preceding baseline and drifted right.
|
||||
let items =
|
||||
form_items(b"BT /F1 12 Tf 12 TL 1 0 0 1 100 700 Tm (first) Tj T* (second) Tj ET");
|
||||
|
||||
let first = find(&items, "first");
|
||||
let second = find(&items, "second");
|
||||
assert!((first.y - 700.0).abs() < 0.1, "first y = {}", first.y);
|
||||
assert!((second.y - 688.0).abs() < 0.1, "second y = {}", second.y);
|
||||
assert!((second.x - 100.0).abs() < 0.1, "second x = {}", second.x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn td_inside_form_is_relative_to_line_start_not_shown_text() {
|
||||
// Td moves relative to the text *line* matrix. Applying it to the
|
||||
// matrix already advanced by Tj marched each line off the right edge.
|
||||
let items = form_items(b"BT /F1 12 Tf 1 0 0 1 100 700 Tm (AAAAA) Tj 0 -12 Td (B) Tj ET");
|
||||
|
||||
let b = find(&items, "B");
|
||||
assert!((b.x - 100.0).abs() < 0.1, "B x = {} (expected 100)", b.x);
|
||||
assert!((b.y - 688.0).abs() < 0.1, "B y = {}", b.y);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn td_inside_form_sets_leading_for_later_t_star() {
|
||||
// `TD` sets the leading to -ty as a side effect; a following T* must
|
||||
// reuse it.
|
||||
let items = form_items(
|
||||
b"BT /F1 12 Tf 1 0 0 1 100 700 Tm (one) Tj 0 -15 TD (two) Tj T* (three) Tj ET",
|
||||
);
|
||||
|
||||
assert!((find(&items, "two").y - 685.0).abs() < 0.1);
|
||||
let three = find(&items, "three");
|
||||
assert!((three.y - 670.0).abs() < 0.1, "three y = {}", three.y);
|
||||
assert!((three.x - 100.0).abs() < 0.1, "three x = {}", three.x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quote_operator_inside_form_moves_to_next_line() {
|
||||
let items = form_items(b"BT /F1 12 Tf 12 TL 1 0 0 1 100 700 Tm (first) Tj (second) ' ET");
|
||||
|
||||
let second = find(&items, "second");
|
||||
assert!((second.y - 688.0).abs() < 0.1, "second y = {}", second.y);
|
||||
assert!((second.x - 100.0).abs() < 0.1, "second x = {}", second.x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn double_quote_operator_inside_form_sets_spacing_and_moves() {
|
||||
// `aw ac (string) "` — set word spacing and char spacing, then T* and show.
|
||||
let items =
|
||||
form_items(b"BT /F1 12 Tf 12 TL 1 0 0 1 100 700 Tm (first) Tj 0 0 (second) \" ET");
|
||||
|
||||
let second = find(&items, "second");
|
||||
assert!((second.y - 688.0).abs() < 0.1, "second y = {}", second.y);
|
||||
assert!((second.x - 100.0).abs() < 0.1, "second x = {}", second.x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_spacing_inside_form_widens_advance() {
|
||||
// Tc was hardcoded to 0 in the form parser, so advance widths drifted.
|
||||
// 2 glyphs x 600/1000 x 12pt = 14.4, plus 2 x Tc(2.0) = 18.4.
|
||||
let items = form_items(b"BT /F1 12 Tf 1 0 0 1 100 700 Tm 2 Tc (AB) Tj ET");
|
||||
|
||||
let ab = find(&items, "AB");
|
||||
assert!((ab.width - 18.4).abs() < 0.1, "AB width = {}", ab.width);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn q_restores_fill_colour_inside_form() {
|
||||
// A white fill set inside q/Q must not leak past the Q — otherwise the
|
||||
// following black text is treated as invisible and dropped entirely.
|
||||
let items = form_items(
|
||||
b"BT /F1 12 Tf 12 TL 1 0 0 1 100 700 Tm q 1 g (hidden) Tj Q T* (visible) Tj ET",
|
||||
);
|
||||
|
||||
assert!(
|
||||
items.iter().any(|item| item.text == "visible"),
|
||||
"text after Q was dropped: {:?}",
|
||||
items.iter().map(|i| &i.text).collect::<Vec<_>>()
|
||||
);
|
||||
assert!(
|
||||
!items.iter().any(|item| item.text == "hidden"),
|
||||
"white-filled text should still be suppressed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn q_restores_text_state_inside_form() {
|
||||
// Tc/TL live in the graphics state; `Q` must roll them back.
|
||||
let items =
|
||||
form_items(b"BT /F1 12 Tf 12 TL 1 0 0 1 100 700 Tm q 30 TL (a) Tj Q T* (b) Tj ET");
|
||||
|
||||
let b = find(&items, "b");
|
||||
assert!(
|
||||
(b.y - 688.0).abs() < 0.1,
|
||||
"b y = {} (leading should restore to 12)",
|
||||
b.y
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+40
-3
@@ -4566,9 +4566,13 @@ pub fn glyph_to_char(name: &str) -> Option<char> {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// 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) {
|
||||
// Strip PUA F000 offset: uniF0XX → U+00XX (Windows Symbol encoding convention)
|
||||
let code = if (0xF000..=0xF0FF).contains(&code) {
|
||||
code - 0xF000
|
||||
@@ -4588,3 +4592,36 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
+745
-61
File diff suppressed because it is too large
Load Diff
@@ -171,6 +171,74 @@ pub(crate) fn is_toc_marker_heading(text: &str) -> bool {
|
||||
/// 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();
|
||||
|
||||
@@ -244,9 +312,133 @@ pub(crate) fn is_heading_fragment(text: &str) -> bool {
|
||||
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
|
||||
|
||||
@@ -127,7 +127,9 @@ fn visual_style(line: &TextLine) -> Option<VisualStyle> {
|
||||
})
|
||||
}
|
||||
|
||||
fn roman_value(token: &str) -> Option<u32> {
|
||||
/// Shared with `analysis::starts_with_numbering_prefix` so the veto
|
||||
/// exemption and the heading parser agree on what a roman numeral is.
|
||||
pub(super) fn roman_value(token: &str) -> Option<u32> {
|
||||
if token.is_empty() || token.len() > 8 {
|
||||
return None;
|
||||
}
|
||||
|
||||
+250
-40
@@ -69,7 +69,7 @@ fn is_chart_adjacent_label(item: &TextItem, region: (f32, f32, f32, f32)) -> boo
|
||||
|| (mostly_inside_chart_width && close_to_chart_edge && category_sized))
|
||||
}
|
||||
|
||||
fn item_is_in_chart_region(item: &TextItem, regions: &[(f32, f32, f32, f32)]) -> bool {
|
||||
pub(crate) fn item_is_in_chart_region(item: &TextItem, regions: &[(f32, f32, f32, f32)]) -> bool {
|
||||
regions.iter().any(|&(x0, y0, x1, y1)| {
|
||||
let cx = item.x + item.width / 2.0;
|
||||
let within_padded_x = cx >= x0 - CHART_REGION_PAD && cx <= x1 + CHART_REGION_PAD;
|
||||
@@ -92,6 +92,72 @@ fn items_outside_chart_regions(
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn merge_chart_regions(
|
||||
regions: impl IntoIterator<Item = (f32, f32, f32, f32)>,
|
||||
) -> Vec<(f32, f32, f32, f32)> {
|
||||
const MERGE_TOLERANCE: f32 = 3.0;
|
||||
|
||||
let mut merged: Vec<(f32, f32, f32, f32)> = Vec::new();
|
||||
for (x0, y0, x1, y1) in regions {
|
||||
let mut current = (x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1));
|
||||
let mut index = 0;
|
||||
while index < merged.len() {
|
||||
let candidate = merged[index];
|
||||
let overlaps = current.2 + MERGE_TOLERANCE >= candidate.0
|
||||
&& candidate.2 + MERGE_TOLERANCE >= current.0
|
||||
&& current.3 + MERGE_TOLERANCE >= candidate.1
|
||||
&& candidate.3 + MERGE_TOLERANCE >= current.1;
|
||||
if overlaps {
|
||||
current = (
|
||||
current.0.min(candidate.0),
|
||||
current.1.min(candidate.1),
|
||||
current.2.max(candidate.2),
|
||||
current.3.max(candidate.3),
|
||||
);
|
||||
merged.swap_remove(index);
|
||||
} else {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
merged.push(current);
|
||||
}
|
||||
merged
|
||||
}
|
||||
|
||||
pub(crate) type PageChartRegions = HashMap<u32, Vec<(f32, f32, f32, f32)>>;
|
||||
|
||||
/// Compute the chart masks used by both layout analysis and Markdown output.
|
||||
///
|
||||
/// Keeping the rect-backed and dense-line heuristics behind one entry point
|
||||
/// ensures metadata and extraction cannot drift when either detector changes.
|
||||
pub(crate) fn chart_regions_by_page(
|
||||
items: &[TextItem],
|
||||
rects: &[PdfRect],
|
||||
lines: &[PdfLine],
|
||||
) -> PageChartRegions {
|
||||
let mut page_items: HashMap<u32, Vec<TextItem>> = HashMap::new();
|
||||
for item in items.iter().filter(|item| {
|
||||
matches!(
|
||||
&item.item_type,
|
||||
crate::types::ItemType::Text | crate::types::ItemType::FormField
|
||||
)
|
||||
}) {
|
||||
page_items.entry(item.page).or_default().push(item.clone());
|
||||
}
|
||||
|
||||
page_items
|
||||
.into_iter()
|
||||
.filter_map(|(page, items)| {
|
||||
let rect_regions = crate::tables::detect_chart_regions(&items, rects, page);
|
||||
let line_regions = crate::tables::detect_dense_line_chart_regions(lines, rects, page)
|
||||
.into_iter()
|
||||
.filter(|®ion| chart_region_separates_prose_columns(&items, region));
|
||||
let regions = merge_chart_regions(rect_regions.into_iter().chain(line_regions));
|
||||
(!regions.is_empty()).then_some((page, regions))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Detect side-by-side table layout by finding a significant X-position gap.
|
||||
///
|
||||
/// Returns X-band boundaries `[(x_min, split_x), (split_x, x_max)]` when a
|
||||
@@ -329,6 +395,15 @@ fn chart_spans_prose_split(region: (f32, f32, f32, f32), split_x: f32) -> bool {
|
||||
split_x - left >= MIN_CHART_WIDTH_PER_SIDE && right - split_x >= MIN_CHART_WIDTH_PER_SIDE
|
||||
}
|
||||
|
||||
pub(crate) fn chart_region_separates_prose_columns(
|
||||
items: &[TextItem],
|
||||
region: (f32, f32, f32, f32),
|
||||
) -> bool {
|
||||
let outside = items_outside_chart_regions(items, &[region]);
|
||||
chart_page_prose_column_split(&outside)
|
||||
.is_some_and(|split_x| chart_spans_prose_split(region, split_x))
|
||||
}
|
||||
|
||||
/// True when adjacent physical rows form an unterminated, lowercase prose
|
||||
/// continuation in the same projected column.
|
||||
fn is_cross_row_prose_continuation(previous: &str, current: &str) -> bool {
|
||||
@@ -975,18 +1050,58 @@ pub fn to_markdown_from_items_with_rects(
|
||||
items: Vec<TextItem>,
|
||||
options: MarkdownOptions,
|
||||
rects: &[crate::types::PdfRect],
|
||||
) -> String {
|
||||
let document_page_count = items.iter().map(|item| item.page).max().unwrap_or(0);
|
||||
to_markdown_from_items_with_rects_and_page_count(items, options, rects, document_page_count)
|
||||
}
|
||||
|
||||
/// Convert positioned text items to Markdown with an authoritative PDF page count.
|
||||
///
|
||||
/// Use this overload when the owning PDF is available so trailing blank or
|
||||
/// unextracted pages are included in document-level header and folio coverage.
|
||||
/// Item-only callers can continue using [`to_markdown_from_items_with_rects`],
|
||||
/// which falls back to the highest observed item page.
|
||||
pub fn to_markdown_from_items_with_rects_and_page_count(
|
||||
items: Vec<TextItem>,
|
||||
options: MarkdownOptions,
|
||||
rects: &[crate::types::PdfRect],
|
||||
document_page_count: u32,
|
||||
) -> String {
|
||||
to_markdown_from_items_with_rects_and_lines(
|
||||
items,
|
||||
options,
|
||||
rects,
|
||||
&[],
|
||||
&HashMap::new(),
|
||||
None,
|
||||
&[],
|
||||
MarkdownDocumentContext {
|
||||
page_thresholds: &HashMap::new(),
|
||||
struct_roles: None,
|
||||
struct_tables: &[],
|
||||
page_count: document_page_count,
|
||||
prefiltered_page_number_pages: None,
|
||||
prefiltered_page_number_mask: None,
|
||||
precomputed_chart_regions: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) struct MarkdownDocumentContext<'a> {
|
||||
pub(crate) page_thresholds: &'a HashMap<u32, f32>,
|
||||
pub(crate) struct_roles:
|
||||
Option<&'a HashMap<u32, HashMap<i64, crate::structure_tree::StructRole>>>,
|
||||
pub(crate) struct_tables: &'a [crate::structure_tree::StructTable],
|
||||
pub(crate) page_count: u32,
|
||||
/// Pages where an upstream document-level pass removed folios. This keeps
|
||||
/// table-continuation classification consistent after masked items drop.
|
||||
pub(crate) prefiltered_page_number_pages: Option<&'a HashSet<u32>>,
|
||||
/// Document-level removal decisions aligned with this call's input items.
|
||||
/// Table detection consumes the original items; the mask is applied only
|
||||
/// after table claims have been established.
|
||||
pub(crate) prefiltered_page_number_mask: Option<&'a [bool]>,
|
||||
/// Optional chart masks shared with layout analysis so the geometry is
|
||||
/// detected once and interpreted identically by both pipelines.
|
||||
pub(crate) precomputed_chart_regions: Option<&'a PageChartRegions>,
|
||||
}
|
||||
|
||||
/// Convert positioned text items to markdown, using rectangles and line segments for table detection.
|
||||
///
|
||||
/// Line-based detection runs first (strongest structural evidence), then rect-based,
|
||||
@@ -996,27 +1111,44 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
options: MarkdownOptions,
|
||||
rects: &[crate::types::PdfRect],
|
||||
pdf_lines: &[crate::types::PdfLine],
|
||||
page_thresholds: &HashMap<u32, f32>,
|
||||
struct_roles: Option<&HashMap<u32, HashMap<i64, crate::structure_tree::StructRole>>>,
|
||||
struct_tables: &[crate::structure_tree::StructTable],
|
||||
context: MarkdownDocumentContext<'_>,
|
||||
) -> String {
|
||||
use crate::tables::{
|
||||
detect_tables, detect_tables_from_lines, detect_tables_from_rects,
|
||||
detect_tables_from_struct_tree, try_build_rect_guided_table,
|
||||
content_width, detect_tables_from_lines, detect_tables_from_rects,
|
||||
detect_tables_from_struct_tree, detect_tables_with_page_width, try_build_rect_guided_table,
|
||||
};
|
||||
use crate::types::ItemType;
|
||||
|
||||
let MarkdownDocumentContext {
|
||||
page_thresholds,
|
||||
struct_roles,
|
||||
struct_tables,
|
||||
page_count: document_page_count,
|
||||
prefiltered_page_number_pages,
|
||||
prefiltered_page_number_mask,
|
||||
precomputed_chart_regions,
|
||||
} = context;
|
||||
|
||||
if items.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
// Table detection must retain the original collection because short
|
||||
// numeric table cells can be indistinguishable from folios until
|
||||
// structural context is available. A precomputed mask carries the
|
||||
// document-wide decision without removing items before table claims.
|
||||
debug_assert!(prefiltered_page_number_mask.is_none_or(|mask| mask.len() == items.len()));
|
||||
let has_precomputed_page_number_mask = prefiltered_page_number_mask.is_some();
|
||||
let removed_page_number_pages = prefiltered_page_number_pages.cloned().unwrap_or_default();
|
||||
|
||||
// Separate images and links from text items
|
||||
let mut images: Vec<TextItem> = Vec::new();
|
||||
let mut page_image_regions: HashMap<u32, Vec<(f32, f32, f32, f32)>> = HashMap::new();
|
||||
let mut links: Vec<TextItem> = Vec::new();
|
||||
let mut text_items: Vec<TextItem> = Vec::new();
|
||||
let mut text_item_page_number_mask: Vec<bool> = Vec::new();
|
||||
|
||||
for item in items {
|
||||
for (input_index, item) in items.into_iter().enumerate() {
|
||||
match &item.item_type {
|
||||
ItemType::Image => {
|
||||
page_image_regions.entry(item.page).or_default().push((
|
||||
@@ -1035,6 +1167,12 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
}
|
||||
}
|
||||
ItemType::Text | ItemType::FormField => {
|
||||
text_item_page_number_mask.push(
|
||||
prefiltered_page_number_mask
|
||||
.and_then(|mask| mask.get(input_index))
|
||||
.copied()
|
||||
.unwrap_or(false),
|
||||
);
|
||||
text_items.push(item);
|
||||
}
|
||||
}
|
||||
@@ -1061,21 +1199,12 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
|
||||
// Chart regions per page: their text must not steer column detection
|
||||
// during line grouping (it fills the gutter and fuses two-column lines).
|
||||
let mut page_chart_map: HashMap<u32, Vec<(f32, f32, f32, f32)>> = HashMap::new();
|
||||
for &page in page_groups.keys() {
|
||||
let page_items_ref: Vec<TextItem> = page_groups[&page]
|
||||
.iter()
|
||||
.map(|(_, item)| (*item).clone())
|
||||
.collect();
|
||||
let regions = crate::tables::detect_chart_regions(&page_items_ref, rects, page);
|
||||
if !regions.is_empty() {
|
||||
page_chart_map.insert(page, regions);
|
||||
}
|
||||
}
|
||||
let page_chart_map = precomputed_chart_regions
|
||||
.cloned()
|
||||
.unwrap_or_else(|| chart_regions_by_page(&text_items, rects, pdf_lines));
|
||||
|
||||
let mut pages: Vec<u32> = page_groups.keys().copied().collect();
|
||||
pages.sort();
|
||||
let page_count = pages.last().copied().unwrap_or(0) + 1;
|
||||
|
||||
// Track band splits per page so we can split non-table items later
|
||||
let mut page_band_splits: HashMap<u32, Vec<(f32, f32)>> = HashMap::new();
|
||||
@@ -1087,6 +1216,7 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
for page in pages {
|
||||
let group = page_groups.get(&page).unwrap();
|
||||
let page_items: Vec<TextItem> = group.iter().map(|(_, item)| (*item).clone()).collect();
|
||||
let page_content_width = content_width(&page_items);
|
||||
|
||||
// Chart-bar regions: bar charts drawn as filled rects read as cell
|
||||
// rects or aligned text and get gridded into phantom tables. Their
|
||||
@@ -1128,7 +1258,10 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
});
|
||||
let chart_prose_columns = chart_prose_split.is_some();
|
||||
|
||||
// Check for side-by-side layout (e.g. two tables placed left and right)
|
||||
// Check for side-by-side table layout using the original items. Sparse
|
||||
// numeric cells need table context before they can be distinguished
|
||||
// safely from folios; cleaned evidence is reserved for column and
|
||||
// final non-table layout decisions.
|
||||
let mut bands = split_side_by_side(&page_items);
|
||||
// A rect table crossing a proposed split boundary means the "gutter"
|
||||
// is really the gap between ruled and borderless table columns —
|
||||
@@ -1368,7 +1501,12 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
// table can share the prose anchors. Reject only candidates
|
||||
// whose cells prove they are parallel prose fragments.
|
||||
let reject_parallel_prose = chart_prose_columns && !was_split;
|
||||
let tables = detect_tables(subset_items, base_size, false);
|
||||
let tables = detect_tables_with_page_width(
|
||||
subset_items,
|
||||
base_size,
|
||||
false,
|
||||
page_content_width,
|
||||
);
|
||||
for table in tables {
|
||||
if reject_parallel_prose && is_parallel_prose_table(&table) {
|
||||
log::debug!(
|
||||
@@ -1549,7 +1687,12 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
// and reject chart-page prose candidates individually below.
|
||||
let skip_body_font =
|
||||
merged_retry_skips_body_font(detected_columns, !chart_regions.is_empty());
|
||||
let heuristic_tables = detect_tables(&chart_free, base_size, skip_body_font);
|
||||
let heuristic_tables = detect_tables_with_page_width(
|
||||
&chart_free,
|
||||
base_size,
|
||||
skip_body_font,
|
||||
page_content_width,
|
||||
);
|
||||
for table in &heuristic_tables {
|
||||
if !chart_regions.is_empty() && is_parallel_prose_table(table) {
|
||||
log::debug!(
|
||||
@@ -1634,16 +1777,20 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
};
|
||||
|
||||
// Filter out table items and process the rest
|
||||
let non_table_items: Vec<TextItem> = text_items
|
||||
let non_table_items: Vec<(usize, TextItem)> = text_items
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter(|(idx, _)| !table_items.contains(idx))
|
||||
.map(|(_, item)| item)
|
||||
.collect();
|
||||
|
||||
// Find pages that are table-only (no remaining non-table text)
|
||||
let table_only_pages: HashSet<u32> = {
|
||||
let pages_with_text: HashSet<u32> = non_table_items.iter().map(|i| i.page).collect();
|
||||
let mut pages_with_text: HashSet<u32> =
|
||||
non_table_items.iter().map(|(_, item)| item.page).collect();
|
||||
// Preserve the pre-filter continuation classification: a page that
|
||||
// originally also contained a folio does not become table-only merely
|
||||
// because an upstream document-level pass removed it.
|
||||
pages_with_text.extend(removed_page_number_pages);
|
||||
page_tables
|
||||
.keys()
|
||||
.filter(|p| !pages_with_text.contains(p))
|
||||
@@ -1658,11 +1805,25 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
// column detection on pages where table column gaps would be misidentified.
|
||||
let table_page_set: HashSet<u32> = page_tables.keys().copied().collect();
|
||||
|
||||
let non_table_items = if has_precomputed_page_number_mask {
|
||||
non_table_items
|
||||
.into_iter()
|
||||
.filter(|(index, _)| !text_item_page_number_mask[*index])
|
||||
.map(|(_, item)| item)
|
||||
.collect()
|
||||
} else {
|
||||
crate::extractor::filter_markdown_page_numbers_with_removed_pages(
|
||||
non_table_items.into_iter().map(|(_, item)| item).collect(),
|
||||
document_page_count,
|
||||
)
|
||||
.0
|
||||
};
|
||||
|
||||
// Split non-table items by band boundaries before line grouping so that
|
||||
// items from different side-by-side zones (e.g. left/right month columns
|
||||
// in a calendar) don't merge into the same line.
|
||||
let lines = if page_band_splits.is_empty() && page_chart_prose_splits.is_empty() {
|
||||
crate::extractor::group_into_lines_with_thresholds_and_regions(
|
||||
crate::extractor::group_prefiltered_items_into_lines_with_thresholds_and_regions(
|
||||
non_table_items,
|
||||
page_thresholds,
|
||||
&table_page_set,
|
||||
@@ -1690,13 +1851,14 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
}
|
||||
}
|
||||
// Process unsplit pages normally
|
||||
let mut all_lines = crate::extractor::group_into_lines_with_thresholds_and_regions(
|
||||
unsplit_items,
|
||||
page_thresholds,
|
||||
&table_page_set,
|
||||
&page_chart_map,
|
||||
&page_image_regions,
|
||||
);
|
||||
let mut all_lines =
|
||||
crate::extractor::group_prefiltered_items_into_lines_with_thresholds_and_regions(
|
||||
unsplit_items,
|
||||
page_thresholds,
|
||||
&table_page_set,
|
||||
&page_chart_map,
|
||||
&page_image_regions,
|
||||
);
|
||||
// Process each split page's bands independently, then interleave
|
||||
// by Y position so paired zones (e.g. left/right months) appear together.
|
||||
let mut split_pages: Vec<u32> = split_page_items.keys().copied().collect();
|
||||
@@ -1714,7 +1876,7 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
.collect();
|
||||
if !band_items.is_empty() {
|
||||
page_lines.extend(
|
||||
crate::extractor::group_into_lines_with_thresholds_and_charts(
|
||||
crate::extractor::group_prefiltered_items_into_lines_with_thresholds_and_charts(
|
||||
band_items,
|
||||
page_thresholds,
|
||||
&table_page_set,
|
||||
@@ -1748,7 +1910,7 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
.collect();
|
||||
if !column_items.is_empty() {
|
||||
zone_lines.extend(
|
||||
crate::extractor::group_into_lines_with_thresholds_and_charts(
|
||||
crate::extractor::group_prefiltered_items_into_lines_with_thresholds_and_charts(
|
||||
column_items,
|
||||
page_thresholds,
|
||||
&table_page_set,
|
||||
@@ -1789,7 +1951,7 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
item.y >= low || item_is_in_chart_region(item, chart_regions)
|
||||
});
|
||||
all_lines.extend(
|
||||
crate::extractor::group_into_lines_with_thresholds_and_charts(
|
||||
crate::extractor::group_prefiltered_items_into_lines_with_thresholds_and_charts(
|
||||
chart_zone,
|
||||
page_thresholds,
|
||||
&table_page_set,
|
||||
@@ -1808,7 +1970,7 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
|
||||
|
||||
// Strip repeated headers/footers before conversion
|
||||
let lines = if options.strip_headers_footers {
|
||||
preprocess::strip_repeated_lines(lines, page_count)
|
||||
preprocess::strip_repeated_lines(lines, document_page_count)
|
||||
} else {
|
||||
lines
|
||||
};
|
||||
@@ -1921,6 +2083,54 @@ mod tests {
|
||||
it
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precomputed_folio_mask_preserves_numeric_table_cells() {
|
||||
let mut items = Vec::new();
|
||||
let mut rects = Vec::new();
|
||||
for row in 0..4 {
|
||||
for column in 0..2 {
|
||||
let mut item = make_item_w(
|
||||
110.0 + column as f32 * 100.0,
|
||||
30.0 + row as f32 * 20.0,
|
||||
20.0,
|
||||
1,
|
||||
);
|
||||
item.text = (row * 2 + column + 1).to_string();
|
||||
items.push(item);
|
||||
rects.push(PdfRect {
|
||||
x: 100.0 + column as f32 * 100.0,
|
||||
y: 20.0 + row as f32 * 20.0,
|
||||
width: 100.0,
|
||||
height: 20.0,
|
||||
page: 1,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Simulate document-level folio decisions that would remove every
|
||||
// short numeric item if applied before structural table detection.
|
||||
let removal_mask = vec![true; items.len()];
|
||||
let removed_pages = HashSet::from([1]);
|
||||
let markdown = to_markdown_from_items_with_rects_and_lines(
|
||||
items,
|
||||
MarkdownOptions::default(),
|
||||
&rects,
|
||||
&[],
|
||||
MarkdownDocumentContext {
|
||||
page_thresholds: &HashMap::new(),
|
||||
struct_roles: None,
|
||||
struct_tables: &[],
|
||||
page_count: 1,
|
||||
prefiltered_page_number_pages: Some(&removed_pages),
|
||||
prefiltered_page_number_mask: Some(&removal_mask),
|
||||
precomputed_chart_regions: None,
|
||||
},
|
||||
);
|
||||
|
||||
assert!(markdown.contains("|1|2|"), "{markdown}");
|
||||
assert!(markdown.contains("|7|8|"), "{markdown}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn early_layout_excludes_chart_items_before_column_detection() {
|
||||
let mut items = Vec::new();
|
||||
|
||||
+29
-64
@@ -3,6 +3,7 @@
|
||||
use regex::Regex;
|
||||
|
||||
use super::{MarkdownOptions, MarkdownProfile};
|
||||
use crate::text_utils::is_page_number_line;
|
||||
|
||||
/// Clean up markdown output with post-processing
|
||||
pub(crate) fn clean_markdown(mut text: String, options: &MarkdownOptions) -> String {
|
||||
@@ -145,7 +146,7 @@ fn fix_hyphenation(text: &str) -> String {
|
||||
result
|
||||
}
|
||||
|
||||
/// Remove standalone page numbers (lines that are just 1-4 digit numbers)
|
||||
/// Remove isolated page-number expressions from Markdown.
|
||||
fn remove_page_numbers(text: &str) -> String {
|
||||
let mut result = Vec::new();
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
@@ -183,69 +184,6 @@ 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;
|
||||
@@ -489,12 +427,14 @@ 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]
|
||||
@@ -524,6 +464,13 @@ 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 ---
|
||||
@@ -551,6 +498,24 @@ 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";
|
||||
|
||||
@@ -271,6 +271,12 @@ pub struct PyTextItem {
|
||||
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]
|
||||
@@ -286,6 +292,32 @@ 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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -356,6 +388,18 @@ fn convert_text_items(items: Vec<crate::TextItem>) -> Vec<PyTextItem> {
|
||||
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()
|
||||
}
|
||||
@@ -613,6 +657,48 @@ 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<()> {
|
||||
@@ -620,6 +706,7 @@ fn pdf_inspector(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
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>()?;
|
||||
@@ -634,6 +721,8 @@ 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)?)?;
|
||||
|
||||
+819
-38
File diff suppressed because it is too large
Load Diff
+998
-12
File diff suppressed because it is too large
Load Diff
+450
-2
@@ -4,10 +4,10 @@
|
||||
//! gridlines. Many IRS forms and government PDFs use these instead of
|
||||
//! `re` (rectangle) operators.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::tables::Table;
|
||||
use crate::types::{PdfLine, TextItem};
|
||||
use crate::types::{PdfLine, PdfRect, TextItem};
|
||||
|
||||
use super::detect_rects::{assign_items_to_grid, snap_edges};
|
||||
|
||||
@@ -15,11 +15,33 @@ const RULE_Y_TOLERANCE: f32 = 2.0;
|
||||
const RULE_JOIN_GAP: f32 = 6.0;
|
||||
const RULE_SPAN_TOLERANCE: f32 = 8.0;
|
||||
const TEXT_ROW_TOLERANCE: f32 = 2.5;
|
||||
const DENSE_CHART_MIN_VERTICAL_EDGES: usize = 27;
|
||||
const DENSE_CHART_LABEL_PAD: f32 = 20.0;
|
||||
const DENSE_CHART_MAX_SHARED_PANEL_GRIDS: usize = 4;
|
||||
|
||||
type HorizontalRule = (f32, f32, f32); // (y, x_min, x_max)
|
||||
type VerticalRule = (f32, f32, f32); // (x, y_min, y_max)
|
||||
type AnchoredRow<'a> = (f32, Vec<(usize, &'a TextItem)>);
|
||||
|
||||
fn dense_chart_grids_are_co_located(
|
||||
left: (f32, f32, f32, f32),
|
||||
right: (f32, f32, f32, f32),
|
||||
) -> bool {
|
||||
let left_width = left.2 - left.0;
|
||||
let right_width = right.2 - right.0;
|
||||
let left_height = left.3 - left.1;
|
||||
let right_height = right.3 - right.1;
|
||||
let horizontal_overlap = (left.2.min(right.2) - left.0.max(right.0)).max(0.0);
|
||||
let vertical_overlap = (left.3.min(right.3) - left.1.max(right.1)).max(0.0);
|
||||
let horizontal_gap = (left.0.max(right.0) - left.2.min(right.2)).max(0.0);
|
||||
let vertical_gap = (left.1.max(right.1) - left.3.min(right.3)).max(0.0);
|
||||
|
||||
(vertical_overlap >= left_height.min(right_height) * 0.5
|
||||
&& horizontal_gap <= left_width.min(right_width) * 0.5)
|
||||
|| (horizontal_overlap >= left_width.min(right_width) * 0.5
|
||||
&& vertical_gap <= left_height.min(right_height) * 0.5)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct TextAnchorTable {
|
||||
table: Table,
|
||||
@@ -1187,6 +1209,279 @@ pub fn detect_tables_from_lines(items: &[TextItem], lines: &[PdfLine], page: u32
|
||||
detect_tables_from_lines_inner(items, lines, page, true, true)
|
||||
}
|
||||
|
||||
/// Bounding boxes of chart panels backed by a very dense vector grid.
|
||||
///
|
||||
/// Tables support at most 25 columns, so a panel with at least 27 distinct,
|
||||
/// long vertical coordinates plus repeated horizontal rules is treated as
|
||||
/// chart geometry. When the grid is enclosed by a painted panel rectangle,
|
||||
/// the region expands to that rectangle so axis labels, legends, and source
|
||||
/// notes remain part of the figure instead of forming a heuristic table.
|
||||
pub(crate) fn detect_dense_line_chart_regions(
|
||||
lines: &[PdfLine],
|
||||
rects: &[PdfRect],
|
||||
page: u32,
|
||||
) -> Vec<(f32, f32, f32, f32)> {
|
||||
const ANGLE_TOLERANCE: f32 = 0.035;
|
||||
const MIN_GRID_LINE_LENGTH: f32 = 40.0;
|
||||
const EXTENT_TOLERANCE: f32 = 6.0;
|
||||
|
||||
let mut verticals = Vec::new();
|
||||
let mut horizontals = Vec::new();
|
||||
for line in lines.iter().filter(|line| line.page == page) {
|
||||
let dx = (line.x2 - line.x1).abs();
|
||||
let dy = (line.y2 - line.y1).abs();
|
||||
let length = dx.hypot(dy);
|
||||
if length < MIN_GRID_LINE_LENGTH {
|
||||
continue;
|
||||
}
|
||||
if dy > 0.01 && dx / dy <= ANGLE_TOLERANCE {
|
||||
verticals.push((
|
||||
(line.x1 + line.x2) / 2.0,
|
||||
line.y1.min(line.y2),
|
||||
line.y1.max(line.y2),
|
||||
));
|
||||
} else if dx > 0.01 && dy / dx <= ANGLE_TOLERANCE {
|
||||
horizontals.push((
|
||||
(line.y1 + line.y2) / 2.0,
|
||||
line.x1.min(line.x2),
|
||||
line.x1.max(line.x2),
|
||||
));
|
||||
}
|
||||
}
|
||||
if verticals.len() < DENSE_CHART_MIN_VERTICAL_EDGES || horizontals.len() < 3 {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Group similar vertical extents once. Neighboring buckets are consulted
|
||||
// below so coordinates that straddle a bucket boundary still form one
|
||||
// family, while each line participates in only a constant number of
|
||||
// candidates instead of being re-scanned for every vertical anchor.
|
||||
let extent_key = |value: f32| (value / EXTENT_TOLERANCE).round() as i32;
|
||||
let mut extent_buckets: HashMap<(i32, i32), Vec<VerticalRule>> = HashMap::new();
|
||||
for vertical in verticals {
|
||||
extent_buckets
|
||||
.entry((extent_key(vertical.1), extent_key(vertical.2)))
|
||||
.or_default()
|
||||
.push(vertical);
|
||||
}
|
||||
|
||||
let mut grid_regions = Vec::new();
|
||||
let extent_keys: Vec<(i32, i32)> = extent_buckets.keys().copied().collect();
|
||||
for key in extent_keys {
|
||||
let anchor_family = &extent_buckets[&key];
|
||||
let anchor_bottom = anchor_family.iter().map(|vertical| vertical.1).sum::<f32>()
|
||||
/ anchor_family.len() as f32;
|
||||
let anchor_top = anchor_family.iter().map(|vertical| vertical.2).sum::<f32>()
|
||||
/ anchor_family.len() as f32;
|
||||
|
||||
let mut family = Vec::new();
|
||||
for bottom_offset in -1..=1 {
|
||||
for top_offset in -1..=1 {
|
||||
if let Some(bucket) =
|
||||
extent_buckets.get(&(key.0 + bottom_offset, key.1 + top_offset))
|
||||
{
|
||||
family.extend(bucket.iter().copied().filter(|vertical| {
|
||||
(vertical.1 - anchor_bottom).abs() <= EXTENT_TOLERANCE
|
||||
&& (vertical.2 - anchor_top).abs() <= EXTENT_TOLERANCE
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let xs = snap_edges(&family.iter().map(|&(x, _, _)| x).collect::<Vec<_>>(), 3.0);
|
||||
if xs.len() < DENSE_CHART_MIN_VERTICAL_EDGES {
|
||||
continue;
|
||||
}
|
||||
|
||||
let grid_bottom =
|
||||
family.iter().map(|vertical| vertical.1).sum::<f32>() / family.len() as f32;
|
||||
let grid_top = family.iter().map(|vertical| vertical.2).sum::<f32>() / family.len() as f32;
|
||||
if grid_top - grid_bottom < 60.0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
// A horizontal rule must support the same contiguous dense run of
|
||||
// vertical coordinates. Splitting at sparse X gaps prevents a shared
|
||||
// rule from joining a chart to a neighboring ruled table. Keying by
|
||||
// the covered X-index range also lets multiple chart panels sharing
|
||||
// the same Y extents produce independent regions.
|
||||
let mut supported_spans: HashMap<(usize, usize), Vec<f32>> = HashMap::new();
|
||||
for &(y, line_left, line_right) in &horizontals {
|
||||
if y < grid_bottom - EXTENT_TOLERANCE || y > grid_top + EXTENT_TOLERANCE {
|
||||
continue;
|
||||
}
|
||||
let start = xs.partition_point(|&x| x < line_left - EXTENT_TOLERANCE);
|
||||
let end = xs.partition_point(|&x| x <= line_right + EXTENT_TOLERANCE);
|
||||
if end - start < DENSE_CHART_MIN_VERTICAL_EDGES {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut gaps: Vec<f32> = xs[start..end]
|
||||
.windows(2)
|
||||
.map(|pair| pair[1] - pair[0])
|
||||
.collect();
|
||||
gaps.sort_by(f32::total_cmp);
|
||||
let dense_gap = gaps[gaps.len() / 4];
|
||||
let run_break = (dense_gap * 3.0).max(12.0);
|
||||
let locally_dense_gap_limit = (dense_gap * 1.5).max(6.0);
|
||||
let locally_dense_gaps = gaps
|
||||
.iter()
|
||||
.filter(|&&gap| gap <= locally_dense_gap_limit)
|
||||
.count();
|
||||
|
||||
let mut run_start = start;
|
||||
let mut retained_dense_run = false;
|
||||
for index in start..end - 1 {
|
||||
if xs[index + 1] - xs[index] <= run_break {
|
||||
continue;
|
||||
}
|
||||
let run_end = index + 1;
|
||||
if run_end - run_start >= DENSE_CHART_MIN_VERTICAL_EDGES
|
||||
&& xs[run_end - 1] - xs[run_start] >= 120.0
|
||||
{
|
||||
supported_spans
|
||||
.entry((run_start, run_end))
|
||||
.or_default()
|
||||
.push(y);
|
||||
retained_dense_run = true;
|
||||
}
|
||||
run_start = run_end;
|
||||
}
|
||||
if end - run_start >= DENSE_CHART_MIN_VERTICAL_EDGES
|
||||
&& xs[end - 1] - xs[run_start] >= 120.0
|
||||
{
|
||||
supported_spans.entry((run_start, end)).or_default().push(y);
|
||||
retained_dense_run = true;
|
||||
}
|
||||
|
||||
// One or two wider category gaps may split an otherwise dense
|
||||
// chart into sub-threshold runs. Keep the full family only when
|
||||
// its total width remains close to the expected dense spacing;
|
||||
// a neighboring sparse table makes this ratio much larger.
|
||||
let span_width = xs[end - 1] - xs[start];
|
||||
let expected_dense_width = dense_gap * (end - start - 1) as f32;
|
||||
if !retained_dense_run
|
||||
&& span_width >= 120.0
|
||||
&& span_width <= expected_dense_width * 1.35
|
||||
&& gaps.len().saturating_sub(locally_dense_gaps) <= 2
|
||||
{
|
||||
supported_spans.entry((start, end)).or_default().push(y);
|
||||
}
|
||||
}
|
||||
|
||||
for ((start, end), ys) in supported_spans {
|
||||
if snap_edges(&ys, 3.0).len() >= 3 {
|
||||
grid_regions.push((xs[start], grid_bottom, xs[end - 1], grid_top));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Prefer the smallest qualifying region when a broad rule happens to
|
||||
// cover a denser nested panel, and retain every non-overlapping panel.
|
||||
grid_regions.sort_by(|left, right| {
|
||||
let left_area = (left.2 - left.0) * (left.3 - left.1);
|
||||
let right_area = (right.2 - right.0) * (right.3 - right.1);
|
||||
left_area.total_cmp(&right_area)
|
||||
});
|
||||
let mut selected_regions: Vec<(f32, f32, f32, f32)> = Vec::new();
|
||||
for region in grid_regions {
|
||||
let area = (region.2 - region.0) * (region.3 - region.1);
|
||||
let duplicates_existing = selected_regions.iter().any(|existing| {
|
||||
let overlap_width = (region.2.min(existing.2) - region.0.max(existing.0)).max(0.0);
|
||||
let overlap_height = (region.3.min(existing.3) - region.1.max(existing.1)).max(0.0);
|
||||
let overlap_area = overlap_width * overlap_height;
|
||||
let existing_area = (existing.2 - existing.0) * (existing.3 - existing.1);
|
||||
overlap_area >= area.min(existing_area) * 0.8
|
||||
});
|
||||
if !duplicates_existing {
|
||||
selected_regions.push(region);
|
||||
}
|
||||
}
|
||||
|
||||
let all_grid_regions = selected_regions.clone();
|
||||
let mut regions: Vec<_> = selected_regions
|
||||
.into_iter()
|
||||
.map(|grid_region| {
|
||||
let (grid_left, grid_bottom, grid_right, grid_top) = grid_region;
|
||||
let enclosing_panel = rects
|
||||
.iter()
|
||||
.filter(|rect| rect.page == page)
|
||||
.filter_map(|rect| {
|
||||
let (left, width) = if rect.width < 0.0 {
|
||||
(rect.x + rect.width, -rect.width)
|
||||
} else {
|
||||
(rect.x, rect.width)
|
||||
};
|
||||
let (bottom, height) = if rect.height < 0.0 {
|
||||
(rect.y + rect.height, -rect.height)
|
||||
} else {
|
||||
(rect.y, rect.height)
|
||||
};
|
||||
let right = left + width;
|
||||
let top = bottom + height;
|
||||
let enclosed_grids: Vec<_> = all_grid_regions
|
||||
.iter()
|
||||
.filter(|&&(other_left, other_bottom, other_right, other_top)| {
|
||||
left <= other_left + EXTENT_TOLERANCE
|
||||
&& right >= other_right - EXTENT_TOLERANCE
|
||||
&& bottom <= other_bottom + EXTENT_TOLERANCE
|
||||
&& top >= other_top - EXTENT_TOLERANCE
|
||||
})
|
||||
.copied()
|
||||
.collect();
|
||||
if enclosed_grids.len() > DENSE_CHART_MAX_SHARED_PANEL_GRIDS
|
||||
|| enclosed_grids.iter().any(|&other| {
|
||||
other != grid_region
|
||||
&& !dense_chart_grids_are_co_located(grid_region, other)
|
||||
})
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let enclosed_grid_bounds =
|
||||
enclosed_grids.into_iter().reduce(|bounds, other| {
|
||||
(
|
||||
bounds.0.min(other.0),
|
||||
bounds.1.min(other.1),
|
||||
bounds.2.max(other.2),
|
||||
bounds.3.max(other.3),
|
||||
)
|
||||
})?;
|
||||
let enclosed_width = enclosed_grid_bounds.2 - enclosed_grid_bounds.0;
|
||||
let enclosed_height = enclosed_grid_bounds.3 - enclosed_grid_bounds.1;
|
||||
(left <= grid_left + EXTENT_TOLERANCE
|
||||
&& right >= grid_right - EXTENT_TOLERANCE
|
||||
&& bottom <= grid_bottom + EXTENT_TOLERANCE
|
||||
&& top >= grid_top - EXTENT_TOLERANCE
|
||||
&& width <= enclosed_width * 2.0
|
||||
&& height <= enclosed_height * 4.0
|
||||
&& !(left < 5.0 && bottom < 5.0))
|
||||
.then_some(((left, bottom, right, top), width * height))
|
||||
})
|
||||
.min_by(|left, right| left.1.total_cmp(&right.1))
|
||||
.map(|(region, _)| region);
|
||||
|
||||
enclosing_panel.unwrap_or((
|
||||
grid_left - DENSE_CHART_LABEL_PAD,
|
||||
grid_bottom - DENSE_CHART_LABEL_PAD,
|
||||
grid_right + DENSE_CHART_LABEL_PAD,
|
||||
grid_top + DENSE_CHART_LABEL_PAD,
|
||||
))
|
||||
})
|
||||
.collect();
|
||||
regions.sort_by(|left, right| {
|
||||
left.0
|
||||
.total_cmp(&right.0)
|
||||
.then_with(|| left.1.total_cmp(&right.1))
|
||||
});
|
||||
regions.dedup_by(|left, right| {
|
||||
(left.0 - right.0).abs() <= EXTENT_TOLERANCE
|
||||
&& (left.1 - right.1).abs() <= EXTENT_TOLERANCE
|
||||
&& (left.2 - right.2).abs() <= EXTENT_TOLERANCE
|
||||
&& (left.3 - right.3).abs() <= EXTENT_TOLERANCE
|
||||
});
|
||||
regions
|
||||
}
|
||||
|
||||
/// Detect only tables whose cell grid is backed by explicit vector geometry.
|
||||
///
|
||||
/// Region-level TSR callers need physical cell boundaries for crop bboxes, so
|
||||
@@ -1621,6 +1916,159 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dense_vector_grid_expands_to_enclosing_chart_panel() {
|
||||
let mut lines: Vec<PdfLine> = (0..30)
|
||||
.map(|column| make_vline(100.0 + column as f32 * 8.0, 400.0, 550.0, 1))
|
||||
.collect();
|
||||
lines.extend((0..6).map(|row| make_hline(400.0 + row as f32 * 30.0, 100.0, 332.0, 1)));
|
||||
let rects = vec![PdfRect {
|
||||
x: 80.0,
|
||||
y: 350.0,
|
||||
width: 280.0,
|
||||
height: 240.0,
|
||||
page: 1,
|
||||
}];
|
||||
|
||||
assert_eq!(
|
||||
detect_dense_line_chart_regions(&lines, &rects, 1),
|
||||
vec![(80.0, 350.0, 360.0, 590.0)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frameless_dense_vector_grid_includes_label_padding() {
|
||||
let mut lines: Vec<PdfLine> = (0..30)
|
||||
.map(|column| make_vline(100.0 + column as f32 * 8.0, 400.0, 550.0, 1))
|
||||
.collect();
|
||||
lines.extend((0..6).map(|row| make_hline(400.0 + row as f32 * 30.0, 100.0, 332.0, 1)));
|
||||
|
||||
assert_eq!(
|
||||
detect_dense_line_chart_regions(&lines, &[], 1),
|
||||
vec![(80.0, 380.0, 352.0, 570.0)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_dense_vector_panels_are_retained() {
|
||||
let mut lines = Vec::new();
|
||||
for panel_left in [60.0, 380.0] {
|
||||
lines.extend(
|
||||
(0..30).map(|column| make_vline(panel_left + column as f32 * 8.0, 400.0, 550.0, 1)),
|
||||
);
|
||||
lines.extend((0..6).map(|row| {
|
||||
make_hline(400.0 + row as f32 * 30.0, panel_left, panel_left + 232.0, 1)
|
||||
}));
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
detect_dense_line_chart_regions(&lines, &[], 1),
|
||||
vec![(40.0, 380.0, 312.0, 570.0), (360.0, 380.0, 632.0, 570.0),]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_dense_vector_panels_use_shared_enclosing_panel() {
|
||||
let mut lines = Vec::new();
|
||||
for panel_left in [60.0, 380.0] {
|
||||
lines.extend(
|
||||
(0..30).map(|column| make_vline(panel_left + column as f32 * 8.0, 400.0, 550.0, 1)),
|
||||
);
|
||||
lines.extend((0..6).map(|row| {
|
||||
make_hline(400.0 + row as f32 * 30.0, panel_left, panel_left + 232.0, 1)
|
||||
}));
|
||||
}
|
||||
let rects = vec![PdfRect {
|
||||
x: 40.0,
|
||||
y: 350.0,
|
||||
width: 592.0,
|
||||
height: 240.0,
|
||||
page: 1,
|
||||
}];
|
||||
|
||||
assert_eq!(
|
||||
detect_dense_line_chart_regions(&lines, &rects, 1),
|
||||
vec![(40.0, 350.0, 632.0, 590.0)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shared_rules_do_not_join_dense_chart_to_adjacent_table() {
|
||||
let mut lines: Vec<PdfLine> = (0..30)
|
||||
.map(|column| make_vline(60.0 + column as f32 * 8.0, 400.0, 550.0, 1))
|
||||
.collect();
|
||||
|
||||
lines.extend(
|
||||
[330.0, 390.0, 450.0, 510.0, 570.0, 630.0]
|
||||
.into_iter()
|
||||
.map(|x| make_vline(x, 400.0, 550.0, 1)),
|
||||
);
|
||||
lines.extend((0..6).map(|row| make_hline(400.0 + row as f32 * 30.0, 60.0, 630.0, 1)));
|
||||
|
||||
assert_eq!(
|
||||
detect_dense_line_chart_regions(&lines, &[], 1),
|
||||
vec![(40.0, 380.0, 312.0, 570.0)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uneven_dense_spacing_keeps_the_complete_chart_region() {
|
||||
let mut xs: Vec<f32> = (0..15).map(|column| 60.0 + column as f32 * 8.0).collect();
|
||||
xs.extend((0..15).map(|column| 212.0 + column as f32 * 8.0));
|
||||
let mut lines: Vec<PdfLine> = xs.iter().map(|&x| make_vline(x, 400.0, 550.0, 1)).collect();
|
||||
lines.extend((0..6).map(|row| make_hline(400.0 + row as f32 * 30.0, 60.0, 324.0, 1)));
|
||||
|
||||
assert_eq!(
|
||||
detect_dense_line_chart_regions(&lines, &[], 1),
|
||||
vec![(40.0, 380.0, 344.0, 570.0)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subthreshold_dense_run_does_not_absorb_adjacent_sparse_grid() {
|
||||
let mut xs: Vec<f32> = (0..21).map(|column| 60.0 + column as f32 * 8.0).collect();
|
||||
xs.extend((0..6).map(|column| 248.0 + column as f32 * 18.0));
|
||||
let mut lines: Vec<PdfLine> = xs.iter().map(|&x| make_vline(x, 400.0, 550.0, 1)).collect();
|
||||
lines.extend((0..6).map(|row| make_hline(400.0 + row as f32 * 30.0, 60.0, 338.0, 1)));
|
||||
|
||||
assert!(detect_dense_line_chart_regions(&lines, &[], 1).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broad_frame_does_not_merge_distant_dense_grids() {
|
||||
let mut lines = Vec::new();
|
||||
for panel_left in [60.0, 700.0] {
|
||||
lines.extend(
|
||||
(0..30).map(|column| make_vline(panel_left + column as f32 * 8.0, 400.0, 550.0, 1)),
|
||||
);
|
||||
lines.extend((0..6).map(|row| {
|
||||
make_hline(400.0 + row as f32 * 30.0, panel_left, panel_left + 232.0, 1)
|
||||
}));
|
||||
}
|
||||
let rects = vec![PdfRect {
|
||||
x: 40.0,
|
||||
y: 350.0,
|
||||
width: 912.0,
|
||||
height: 240.0,
|
||||
page: 1,
|
||||
}];
|
||||
|
||||
assert_eq!(
|
||||
detect_dense_line_chart_regions(&lines, &rects, 1),
|
||||
vec![(40.0, 380.0, 312.0, 570.0), (680.0, 380.0, 952.0, 570.0)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_width_vector_table_is_not_a_dense_chart() {
|
||||
let mut lines: Vec<PdfLine> = (0..26)
|
||||
.map(|column| make_vline(100.0 + column as f32 * 10.0, 400.0, 550.0, 1))
|
||||
.collect();
|
||||
lines.extend((0..6).map(|row| make_hline(400.0 + row as f32 * 30.0, 100.0, 350.0, 1)));
|
||||
|
||||
assert!(detect_dense_line_chart_regions(&lines, &[], 1).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_basic_grid_detection() {
|
||||
// 3x2 grid with horizontal lines at y=500, 480, 460 and vertical at x=100, 200, 300
|
||||
|
||||
+789
-25
@@ -1,6 +1,6 @@
|
||||
//! Rectangle-based table detection using union-find clustering.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
|
||||
use log::debug;
|
||||
|
||||
@@ -78,19 +78,111 @@ pub(crate) fn rects_overlap(a: &(f32, f32, f32, f32), b: &(f32, f32, f32, f32),
|
||||
!(a_right < b_left || b_right < a_left || a_top < b_bottom || b_top < a_bottom)
|
||||
}
|
||||
|
||||
fn grid_coord(value: f32, cell: f32) -> i32 {
|
||||
(value / cell).floor().clamp(-1_000_000.0, 1_000_000.0) as i32
|
||||
}
|
||||
|
||||
/// Inclusive grid range. `None` if the rect covers more cells than we will
|
||||
/// materialize — those rects are clustered via a bounded fallback.
|
||||
fn grid_span(lo: f32, hi: f32, cell: f32) -> Option<std::ops::RangeInclusive<i32>> {
|
||||
let a = grid_coord(lo.min(hi), cell);
|
||||
let b = grid_coord(lo.max(hi), cell);
|
||||
let span = b.saturating_sub(a);
|
||||
if span > 64 {
|
||||
return None;
|
||||
}
|
||||
Some(a..=b)
|
||||
}
|
||||
|
||||
fn union_bucket_pairs(
|
||||
uf: &mut UnionFind,
|
||||
rects: &[(f32, f32, f32, f32)],
|
||||
bucket: &[usize],
|
||||
tolerance: f32,
|
||||
) {
|
||||
let m = bucket.len();
|
||||
let mut pairs = 0usize;
|
||||
'cell: for a in 0..m {
|
||||
let i = bucket[a];
|
||||
if uf.component_size(i) >= MAX_CLUSTER_RECTS {
|
||||
continue;
|
||||
}
|
||||
for &j in &bucket[a + 1..] {
|
||||
if pairs >= MAX_CLUSTER_PAIRS_PER_CELL {
|
||||
break 'cell;
|
||||
}
|
||||
if uf.component_size(j) >= MAX_CLUSTER_RECTS {
|
||||
continue;
|
||||
}
|
||||
pairs += 1;
|
||||
if rects_overlap(&rects[i], &rects[j], tolerance) {
|
||||
uf.union(i, j);
|
||||
if uf.component_size(i) >= MAX_CLUSTER_RECTS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn union_rect_against_bands(
|
||||
uf: &mut UnionFind,
|
||||
rects: &[(f32, f32, f32, f32)],
|
||||
i: usize,
|
||||
bands: &BTreeMap<i32, Vec<usize>>,
|
||||
lo: i32,
|
||||
hi: i32,
|
||||
tolerance: f32,
|
||||
) {
|
||||
if uf.component_size(i) >= MAX_CLUSTER_RECTS {
|
||||
return;
|
||||
}
|
||||
let mut pairs = 0usize;
|
||||
let mut seen = HashSet::new();
|
||||
for (_, bucket) in bands.range(lo..=hi) {
|
||||
for &j in bucket {
|
||||
if !seen.insert(j) {
|
||||
continue;
|
||||
}
|
||||
if pairs >= MAX_CLUSTER_PAIRS_PER_CELL {
|
||||
return;
|
||||
}
|
||||
if i == j || uf.component_size(j) >= MAX_CLUSTER_RECTS {
|
||||
continue;
|
||||
}
|
||||
pairs += 1;
|
||||
if rects_overlap(&rects[i], &rects[j], tolerance) {
|
||||
uf.union(i, j);
|
||||
if uf.component_size(i) >= MAX_CLUSTER_RECTS {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Maximum component size for rect clustering. No real table has thousands
|
||||
/// of cell rects — once a component exceeds this, it is a vector drawing or
|
||||
/// page-spanning clipping path. We skip overlap checks for rects already in
|
||||
/// an oversized component, keeping the original O(n²) loop but making it
|
||||
/// effectively O(n) for pathological pages.
|
||||
/// an oversized component.
|
||||
const MAX_CLUSTER_RECTS: usize = 2000;
|
||||
|
||||
/// Pairwise-disjoint rects never merge, so a component-size cap does not
|
||||
/// stop an all-pairs loop. Rects are hashed into this many points of grid
|
||||
/// and compared only against others in the same cell.
|
||||
const CLUSTER_GRID_CELL: f32 = 64.0;
|
||||
|
||||
/// All-pairs AABB tests allowed inside one grid cell. A real table cell is
|
||||
/// tens of points wide, so a 64-pt cell holds a handful of neighbors — not
|
||||
/// thousands of stacked drawings.
|
||||
const MAX_CLUSTER_PAIRS_PER_CELL: usize = 16_384;
|
||||
|
||||
/// Cluster rects by spatial overlap using union-find.
|
||||
/// Returns groups of rect indices; only groups with ≥ `min_size` rects are returned.
|
||||
///
|
||||
/// Skips overlap checks for rects whose component has already exceeded
|
||||
/// [`MAX_CLUSTER_RECTS`], so pages with tens of thousands of vector-drawing
|
||||
/// rects complete in milliseconds instead of minutes.
|
||||
/// Overlap tests run inside a uniform grid so far-apart rects are never
|
||||
/// compared, and each cell is pair-capped so a dense stack cannot go
|
||||
/// quadratic or starve an independent table in another cell.
|
||||
pub(crate) fn cluster_rects(
|
||||
rects: &[(f32, f32, f32, f32)],
|
||||
tolerance: f32,
|
||||
@@ -98,23 +190,144 @@ pub(crate) fn cluster_rects(
|
||||
) -> Vec<Vec<usize>> {
|
||||
let n = rects.len();
|
||||
let mut uf = UnionFind::new(n);
|
||||
let cell = CLUSTER_GRID_CELL.max(tolerance * 4.0);
|
||||
|
||||
for i in 0..n {
|
||||
// If rect i is already in an oversized component, no point comparing
|
||||
// it against further rects — the component won't be used for table
|
||||
// detection anyway.
|
||||
let mut grid: HashMap<(i32, i32), Vec<usize>> = HashMap::new();
|
||||
let mut large: Vec<usize> = Vec::new();
|
||||
for (idx, &(x, y, w, h)) in rects.iter().enumerate() {
|
||||
match (
|
||||
grid_span(x - tolerance, x + w + tolerance, cell),
|
||||
grid_span(y - tolerance, y + h + tolerance, cell),
|
||||
) {
|
||||
(Some(xs), Some(ys)) => {
|
||||
for gx in xs {
|
||||
for gy in ys.clone() {
|
||||
grid.entry((gx, gy)).or_default().push(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => large.push(idx),
|
||||
}
|
||||
}
|
||||
|
||||
let mut keys: Vec<_> = grid.keys().copied().collect();
|
||||
keys.sort_unstable();
|
||||
let mut keys_by_y: BTreeMap<i32, Vec<i32>> = BTreeMap::new();
|
||||
for &key in &keys {
|
||||
union_bucket_pairs(&mut uf, rects, &grid[&key], tolerance);
|
||||
keys_by_y.entry(key.1).or_default().push(key.0);
|
||||
}
|
||||
|
||||
// Oversized spans skip insert. Range-query occupied cells they cover so
|
||||
// later X-ranges are not starved and we do not scan unrelated rows.
|
||||
for &i in &large {
|
||||
if uf.component_size(i) >= MAX_CLUSTER_RECTS {
|
||||
continue;
|
||||
}
|
||||
for j in (i + 1)..n {
|
||||
if rects_overlap(&rects[i], &rects[j], tolerance) {
|
||||
uf.union(i, j);
|
||||
// Check if the merged component just exceeded the cap —
|
||||
// if so, no need to test more pairs for rect i.
|
||||
let (x, y, w, h) = rects[i];
|
||||
let x_lo = grid_coord(x - tolerance, cell);
|
||||
let x_hi = grid_coord(x + w + tolerance, cell);
|
||||
let y_lo = grid_coord(y - tolerance, cell);
|
||||
let y_hi = grid_coord(y + h + tolerance, cell);
|
||||
for (&gy, gxs) in keys_by_y.range(y_lo..=y_hi) {
|
||||
let start = gxs.partition_point(|&gx| gx < x_lo);
|
||||
for &gx in &gxs[start..] {
|
||||
if gx > x_hi {
|
||||
break;
|
||||
}
|
||||
let bucket = &grid[&(gx, gy)];
|
||||
let mut pairs = 0usize;
|
||||
for &j in bucket {
|
||||
if pairs >= MAX_CLUSTER_PAIRS_PER_CELL {
|
||||
break;
|
||||
}
|
||||
if uf.component_size(j) >= MAX_CLUSTER_RECTS {
|
||||
continue;
|
||||
}
|
||||
pairs += 1;
|
||||
if rects_overlap(&rects[i], &rects[j], tolerance) {
|
||||
uf.union(i, j);
|
||||
if uf.component_size(i) >= MAX_CLUSTER_RECTS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if uf.component_size(i) >= MAX_CLUSTER_RECTS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if uf.component_size(i) >= MAX_CLUSTER_RECTS {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Oversized-vs-oversized: band on the short axis so stacked or side-by-side
|
||||
// page-spanning rules stay linear. Wide vs tall pairs are matched by
|
||||
// querying the tall X-index; dual-oversized rects occupy every coarse-Y
|
||||
// cell they span.
|
||||
let mut large_x: BTreeMap<i32, Vec<usize>> = BTreeMap::new();
|
||||
let mut large_y: BTreeMap<i32, Vec<usize>> = BTreeMap::new();
|
||||
let mut large_coarse_y: BTreeMap<i32, Vec<usize>> = BTreeMap::new();
|
||||
let mut wide: Vec<usize> = Vec::new();
|
||||
let mut dual: Vec<usize> = Vec::new();
|
||||
for &i in &large {
|
||||
let (x, y, w, h) = rects[i];
|
||||
let xs = grid_span(x - tolerance, x + w + tolerance, cell);
|
||||
let ys = grid_span(y - tolerance, y + h + tolerance, cell);
|
||||
match (xs, ys) {
|
||||
(Some(xs), _) => {
|
||||
for gx in xs {
|
||||
large_x.entry(gx).or_default().push(i);
|
||||
}
|
||||
}
|
||||
(_, Some(ys)) => {
|
||||
wide.push(i);
|
||||
for gy in ys {
|
||||
large_y.entry(gy).or_default().push(i);
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
dual.push(i);
|
||||
let coarse = cell * 64.0;
|
||||
match grid_span(y - tolerance, y + h + tolerance, coarse) {
|
||||
Some(ys) => {
|
||||
for gy in ys {
|
||||
large_coarse_y.entry(gy).or_default().push(i);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
large_coarse_y.entry(i32::MIN).or_default().push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for bands in [&large_x, &large_y, &large_coarse_y] {
|
||||
for bucket in bands.values() {
|
||||
union_bucket_pairs(&mut uf, rects, bucket, tolerance);
|
||||
}
|
||||
}
|
||||
// Cross-orientation is |wide|×|tall| if every wide rule spans the page.
|
||||
// Skip that pass when the product cannot be a table (a few rules).
|
||||
let tall_n = large
|
||||
.len()
|
||||
.saturating_sub(wide.len())
|
||||
.saturating_sub(dual.len());
|
||||
let cross_n =
|
||||
(wide.len() + dual.len()).saturating_mul(tall_n) + dual.len().saturating_mul(wide.len());
|
||||
if cross_n > 0 && cross_n <= MAX_CLUSTER_PAIRS_PER_CELL {
|
||||
for &i in wide.iter().chain(&dual) {
|
||||
let (x, _, w, _) = rects[i];
|
||||
let x_lo = grid_coord(x - tolerance, cell);
|
||||
let x_hi = grid_coord(x + w + tolerance, cell);
|
||||
union_rect_against_bands(&mut uf, rects, i, &large_x, x_lo, x_hi, tolerance);
|
||||
}
|
||||
for &i in &dual {
|
||||
let (_, y, _, h) = rects[i];
|
||||
let y_lo = grid_coord(y - tolerance, cell);
|
||||
let y_hi = grid_coord(y + h + tolerance, cell);
|
||||
union_rect_against_bands(&mut uf, rects, i, &large_y, y_lo, y_hi, tolerance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1996,17 +2209,249 @@ fn without_dominant_page_backgrounds(rects: &[(f32, f32, f32, f32)]) -> Vec<(f32
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Detect a table from cell-background rects that failed grid detection.
|
||||
/// Repeated rows of touching cell rectangles are stronger table evidence
|
||||
/// than the bar-length variation used by the chart detector.
|
||||
///
|
||||
/// Uses rect Y-edges for row boundaries and text X-position clustering for
|
||||
/// columns. Handles tables with cell backgrounds that don't form a clean
|
||||
/// X-edge grid (variable column widths, decorative fills).
|
||||
/// Chart-bar signature: ≥3 rects sharing an aligned bottom edge (the axis),
|
||||
/// with similar widths (bars) but strongly varying heights (data-driven),
|
||||
/// holding at most a single numeric data label each. Bar charts drawn as
|
||||
/// filled rects otherwise read as cell rects and grid their axis labels
|
||||
/// into a phantom table. The mirrored check catches horizontal bar charts.
|
||||
fn is_chart_bar_cluster(
|
||||
/// Ruled tables with wrapped labels naturally have variable row heights, and
|
||||
/// numeric-heavy cells can otherwise resemble horizontal or vertical bars.
|
||||
/// Require several rows to repeat a shared edge schema before overriding the
|
||||
/// chart hypothesis so sparse plots and independent bars remain unaffected.
|
||||
fn is_repeated_cell_grid(group_rects: &[(f32, f32, f32, f32)]) -> bool {
|
||||
type RowGroup = (f32, f32, Vec<(f32, f32)>);
|
||||
|
||||
const ROW_EDGE_TOLERANCE: f32 = 3.0;
|
||||
const MIN_GRID_ROWS: usize = 4;
|
||||
const MIN_CELLS_PER_ROW: usize = 3;
|
||||
|
||||
if group_rects.len() < MIN_GRID_ROWS * MIN_CELLS_PER_ROW {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut row_groups: Vec<RowGroup> = Vec::new();
|
||||
for &(x, y, width, height) in group_rects {
|
||||
if width < 5.0 || height < 5.0 {
|
||||
continue;
|
||||
}
|
||||
let top = y + height;
|
||||
if let Some((_, _, cells)) = row_groups.iter_mut().find(|(bottom, row_top, _)| {
|
||||
(y - *bottom).abs() <= ROW_EDGE_TOLERANCE
|
||||
&& (top - *row_top).abs() <= ROW_EDGE_TOLERANCE
|
||||
}) {
|
||||
cells.push((x, x + width));
|
||||
} else {
|
||||
row_groups.push((y, top, vec![(x, x + width)]));
|
||||
}
|
||||
}
|
||||
|
||||
let mut row_schemas = Vec::new();
|
||||
for (_, _, mut cells) in row_groups {
|
||||
if cells.len() < MIN_CELLS_PER_ROW {
|
||||
continue;
|
||||
}
|
||||
let mut widths: Vec<f32> = cells.iter().map(|&(left, right)| right - left).collect();
|
||||
widths.sort_by(f32::total_cmp);
|
||||
let median_width = widths[widths.len() / 2];
|
||||
cells.retain(|&(left, right)| right - left <= median_width * 2.5);
|
||||
cells.sort_by(|left, right| {
|
||||
left.0
|
||||
.total_cmp(&right.0)
|
||||
.then_with(|| left.1.total_cmp(&right.1))
|
||||
});
|
||||
cells.dedup_by(|left, right| {
|
||||
(left.0 - right.0).abs() <= ROW_EDGE_TOLERANCE
|
||||
&& (left.1 - right.1).abs() <= ROW_EDGE_TOLERANCE
|
||||
});
|
||||
if cells.len() < MIN_CELLS_PER_ROW
|
||||
|| cells
|
||||
.windows(2)
|
||||
.any(|pair| pair[1].0 > pair[0].1 + ROW_EDGE_TOLERANCE)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let edges: Vec<f32> = cells
|
||||
.iter()
|
||||
.flat_map(|&(left, right)| [left, right])
|
||||
.collect();
|
||||
let schema = snap_edges(&edges, ROW_EDGE_TOLERANCE);
|
||||
if schema.len() > MIN_CELLS_PER_ROW {
|
||||
row_schemas.push(schema);
|
||||
}
|
||||
}
|
||||
if row_schemas.len() < MIN_GRID_ROWS {
|
||||
return false;
|
||||
}
|
||||
|
||||
let reference = row_schemas
|
||||
.iter()
|
||||
.max_by_key(|schema| schema.len())
|
||||
.expect("grid rows are non-empty");
|
||||
row_schemas
|
||||
.iter()
|
||||
.filter(|schema| {
|
||||
let comparable_edges = reference.len().min(schema.len());
|
||||
let matched_edges = schema
|
||||
.iter()
|
||||
.filter(|edge| {
|
||||
reference
|
||||
.iter()
|
||||
.any(|reference_edge| (*edge - *reference_edge).abs() <= ROW_EDGE_TOLERANCE)
|
||||
})
|
||||
.count();
|
||||
matched_edges > MIN_CELLS_PER_ROW && matched_edges * 4 >= comparable_edges * 3
|
||||
})
|
||||
.count()
|
||||
>= MIN_GRID_ROWS
|
||||
}
|
||||
|
||||
fn repeated_cell_grid_overrides_bar_hypothesis(group_rects: &[(f32, f32, f32, f32)]) -> bool {
|
||||
is_repeated_cell_grid(group_rects)
|
||||
&& without_dominant_page_backgrounds(group_rects).len() == group_rects.len()
|
||||
}
|
||||
|
||||
/// Detect horizontal segmented stacks from aligned rows of touching rects.
|
||||
///
|
||||
/// Category rows must have visible gutters and data-varying internal segment
|
||||
/// boundaries, unlike the stable boundaries of a ruled table.
|
||||
struct SegmentedBarGeometry {
|
||||
bounds: (f32, f32, f32, f32),
|
||||
row_bands: Vec<(f32, f32)>,
|
||||
}
|
||||
|
||||
fn segmented_stacked_bar_geometry(
|
||||
group_rects: &[(f32, f32, f32, f32)],
|
||||
) -> Option<SegmentedBarGeometry> {
|
||||
type BarRow = (f32, f32, Vec<(f32, f32)>);
|
||||
|
||||
const EDGE_TOLERANCE: f32 = 3.0;
|
||||
const MIN_ROWS: usize = 4;
|
||||
const MIN_SEGMENTS: usize = 3;
|
||||
|
||||
let mut rows: Vec<BarRow> = Vec::new();
|
||||
for &(x, y, width, height) in group_rects {
|
||||
if width < 5.0 || height < 5.0 {
|
||||
continue;
|
||||
}
|
||||
let top = y + height;
|
||||
if let Some((_, _, segments)) = rows.iter_mut().find(|(bottom, row_top, _)| {
|
||||
(y - *bottom).abs() <= EDGE_TOLERANCE && (top - *row_top).abs() <= EDGE_TOLERANCE
|
||||
}) {
|
||||
segments.push((x, x + width));
|
||||
} else {
|
||||
rows.push((y, top, vec![(x, x + width)]));
|
||||
}
|
||||
}
|
||||
|
||||
rows.retain_mut(|(_, _, segments)| {
|
||||
segments.sort_by(|left, right| left.0.total_cmp(&right.0));
|
||||
segments.len() >= MIN_SEGMENTS
|
||||
&& segments
|
||||
.windows(2)
|
||||
.all(|pair| (pair[1].0 - pair[0].1).abs() <= EDGE_TOLERANCE)
|
||||
});
|
||||
if rows.len() < MIN_ROWS {
|
||||
return None;
|
||||
}
|
||||
rows.sort_by(|left, right| left.0.total_cmp(&right.0));
|
||||
|
||||
// Table rows normally share borders. Horizontal stacked bars instead
|
||||
// leave a visible gutter between category rows.
|
||||
if rows.windows(2).any(|pair| {
|
||||
let shorter_height = (pair[0].1 - pair[0].0).min(pair[1].1 - pair[1].0);
|
||||
pair[1].0 - pair[0].1 < (shorter_height * 0.25).max(2.0)
|
||||
}) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// At least two rows must move an internal segment boundary. Stable
|
||||
// boundaries across every row are stronger evidence for a ruled table.
|
||||
let reference_edges: Vec<f32> = rows[0]
|
||||
.2
|
||||
.iter()
|
||||
.take(rows[0].2.len() - 1)
|
||||
.map(|segment| segment.1)
|
||||
.collect();
|
||||
let drifting_rows = rows
|
||||
.iter()
|
||||
.skip(1)
|
||||
.filter(|(_, _, segments)| {
|
||||
let edges: Vec<f32> = segments
|
||||
.iter()
|
||||
.take(segments.len() - 1)
|
||||
.map(|segment| segment.1)
|
||||
.collect();
|
||||
edges.len() == reference_edges.len()
|
||||
&& edges
|
||||
.iter()
|
||||
.zip(&reference_edges)
|
||||
.any(|(edge, reference)| (edge - reference).abs() > EDGE_TOLERANCE)
|
||||
})
|
||||
.count();
|
||||
if drifting_rows < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let left = rows
|
||||
.iter()
|
||||
.flat_map(|row| &row.2)
|
||||
.map(|segment| segment.0)
|
||||
.reduce(f32::min)?;
|
||||
let right = rows
|
||||
.iter()
|
||||
.flat_map(|row| &row.2)
|
||||
.map(|segment| segment.1)
|
||||
.reduce(f32::max)?;
|
||||
let bottom = rows.iter().map(|row| row.0).reduce(f32::min)?;
|
||||
let top = rows.iter().map(|row| row.1).reduce(f32::max)?;
|
||||
let row_bands = rows.iter().map(|row| (row.0, row.1)).collect();
|
||||
Some(SegmentedBarGeometry {
|
||||
bounds: (left, bottom, right, top),
|
||||
row_bands,
|
||||
})
|
||||
}
|
||||
|
||||
/// Category labels beside multiple bar rows are independent chart evidence:
|
||||
/// numeric table text stays inside its cells, regardless of whether the table
|
||||
/// has an outer border or extra padding.
|
||||
fn has_external_segmented_bar_labels(
|
||||
items: &[TextItem],
|
||||
page: u32,
|
||||
geometry: &SegmentedBarGeometry,
|
||||
) -> bool {
|
||||
const LABEL_EDGE_TOLERANCE: f32 = 3.0;
|
||||
const LABEL_CLAIM_PAD: f32 = 20.0;
|
||||
|
||||
let (content_left, _, content_right, _) = geometry.bounds;
|
||||
let labeled_rows = geometry
|
||||
.row_bands
|
||||
.iter()
|
||||
.filter(|&&(row_bottom, row_top)| {
|
||||
items.iter().any(|item| {
|
||||
if item.page != page || item.text.trim().is_empty() {
|
||||
return false;
|
||||
}
|
||||
let item_left = item.x.min(item.x + item.width);
|
||||
let item_right = item.x.max(item.x + item.width);
|
||||
let item_center_x = (item_left + item_right) / 2.0;
|
||||
let item_center_y = item.y + item.height / 2.0;
|
||||
let beside_stack = (item_center_x <= content_left + LABEL_EDGE_TOLERANCE
|
||||
&& item_center_x >= content_left - LABEL_CLAIM_PAD
|
||||
&& item_left < content_left)
|
||||
|| (item_center_x >= content_right - LABEL_EDGE_TOLERANCE
|
||||
&& item_center_x <= content_right + LABEL_CLAIM_PAD
|
||||
&& item_right > content_right);
|
||||
beside_stack
|
||||
&& item_center_y >= row_bottom - LABEL_EDGE_TOLERANCE
|
||||
&& item_center_y <= row_top + LABEL_EDGE_TOLERANCE
|
||||
})
|
||||
})
|
||||
.count();
|
||||
|
||||
labeled_rows >= 2 && labeled_rows * 2 >= geometry.row_bands.len()
|
||||
}
|
||||
|
||||
/// Recognize filled vertical or horizontal bars whose geometry and labels are
|
||||
/// data-driven rather than uniform table cells.
|
||||
fn has_chart_bar_signature(
|
||||
items: &[TextItem],
|
||||
group_rects: &[(f32, f32, f32, f32)],
|
||||
page: u32,
|
||||
@@ -2113,6 +2558,30 @@ fn is_chart_bar_cluster(
|
||||
|| bar_family(|r| r.1, |r| r.3, |r| r.2, |r| r.0)
|
||||
}
|
||||
|
||||
fn is_chart_bar_cluster(
|
||||
items: &[TextItem],
|
||||
group_rects: &[(f32, f32, f32, f32)],
|
||||
page: u32,
|
||||
) -> bool {
|
||||
let has_bar_signature = has_chart_bar_signature(items, group_rects, page);
|
||||
|
||||
// A segmented horizontal chart can share most of its edges across rows.
|
||||
// Row-aligned category labels outside the stack distinguish it from a
|
||||
// numeric table without depending on whether either shape has a frame.
|
||||
if has_bar_signature {
|
||||
if let Some(geometry) = segmented_stacked_bar_geometry(group_rects) {
|
||||
if has_external_segmented_bar_labels(items, page, &geometry) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if repeated_cell_grid_overrides_bar_hypothesis(group_rects) {
|
||||
return false;
|
||||
}
|
||||
|
||||
has_bar_signature
|
||||
}
|
||||
|
||||
fn detect_row_stripe_table_from_cell_rects(
|
||||
items: &[TextItem],
|
||||
group_rects: &[(f32, f32, f32, f32)],
|
||||
@@ -3083,6 +3552,194 @@ mod tests {
|
||||
assert!(detect_chart_regions(&items, &rects, 1).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn variable_height_ruled_grid_overrides_bar_hypothesis() {
|
||||
let edge_sets = [
|
||||
[80.0, 140.0, 200.0, 260.0, 320.0, 380.0, 440.0, 500.0, 560.0],
|
||||
[80.0, 140.0, 210.0, 260.0, 320.0, 380.0, 450.0, 500.0, 560.0],
|
||||
];
|
||||
let heights = [20.0, 34.0, 26.0, 42.0, 20.0, 34.0];
|
||||
let edge_variants = [0, 0, 0, 0, 1, 1];
|
||||
let mut rects = Vec::new();
|
||||
let mut y = 650.0;
|
||||
for (row, height) in heights.into_iter().enumerate() {
|
||||
let edges = edge_sets[edge_variants[row]];
|
||||
rects.extend(
|
||||
edges
|
||||
.windows(2)
|
||||
.map(|edge| (edge[0], y, edge[1] - edge[0], height)),
|
||||
);
|
||||
y -= height;
|
||||
}
|
||||
|
||||
assert!(is_repeated_cell_grid(&rects));
|
||||
assert!(has_chart_bar_signature(&[], &rects, 1));
|
||||
assert!(repeated_cell_grid_overrides_bar_hypothesis(&rects));
|
||||
assert!(segmented_stacked_bar_geometry(&rects).is_none());
|
||||
assert!(!is_chart_bar_cluster(&[], &rects, 1));
|
||||
|
||||
let mut with_page_fills =
|
||||
vec![(0.0, 0.0, 600.0, 800.0); DOMINANT_PAGE_BACKGROUND_MIN_REPETITIONS];
|
||||
with_page_fills.extend(rects);
|
||||
assert!(!repeated_cell_grid_overrides_bar_hypothesis(
|
||||
&with_page_fills
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn touching_segments_with_spaced_rows_remain_a_chart() {
|
||||
let row_edges = [
|
||||
[100.0, 140.0, 180.0, 220.0, 260.0],
|
||||
[100.0, 140.0, 180.0, 228.0, 260.0],
|
||||
[100.0, 140.0, 180.0, 214.0, 260.0],
|
||||
[100.0, 140.0, 180.0, 232.0, 260.0],
|
||||
];
|
||||
let mut raw_rects = vec![(90.0, 530.0, 190.0, 100.0)];
|
||||
for (row, edges) in row_edges.into_iter().enumerate() {
|
||||
let y = 540.0 + row as f32 * 20.0;
|
||||
raw_rects.extend(
|
||||
edges
|
||||
.windows(2)
|
||||
.map(|edge| (edge[0], y, edge[1] - edge[0], 12.0)),
|
||||
);
|
||||
}
|
||||
let items: Vec<TextItem> = (0..4)
|
||||
.map(|row| make_item("Category", 62.0, 541.0 + row as f32 * 20.0, 9.0))
|
||||
.collect();
|
||||
|
||||
assert!(is_repeated_cell_grid(&raw_rects));
|
||||
assert!(has_chart_bar_signature(&items, &raw_rects, 1));
|
||||
let geometry = segmented_stacked_bar_geometry(&raw_rects).expect("segmented stack");
|
||||
assert!(has_external_segmented_bar_labels(&items, 1, &geometry));
|
||||
assert!(is_chart_bar_cluster(&items, &raw_rects, 1));
|
||||
|
||||
let numeric_items: Vec<TextItem> = (0..4)
|
||||
.map(|row| make_item("2024", 80.0, 541.0 + row as f32 * 18.0, 9.0))
|
||||
.collect();
|
||||
assert!(has_external_segmented_bar_labels(
|
||||
&numeric_items,
|
||||
1,
|
||||
&geometry
|
||||
));
|
||||
assert!(is_chart_bar_cluster(&numeric_items, &raw_rects, 1));
|
||||
|
||||
let edge_adjacent_items: Vec<TextItem> = (0..4)
|
||||
.map(|row| make_item("2024", 92.0, 541.0 + row as f32 * 18.0, 9.0))
|
||||
.collect();
|
||||
assert!(has_external_segmented_bar_labels(
|
||||
&edge_adjacent_items,
|
||||
1,
|
||||
&geometry
|
||||
));
|
||||
assert!(is_chart_bar_cluster(&edge_adjacent_items, &raw_rects, 1));
|
||||
|
||||
let far_items: Vec<TextItem> = (0..4)
|
||||
.map(|row| make_item("Category", 20.0, 541.0 + row as f32 * 18.0, 9.0))
|
||||
.collect();
|
||||
assert!(!has_external_segmented_bar_labels(&far_items, 1, &geometry));
|
||||
assert!(!is_chart_bar_cluster(&far_items, &raw_rects, 1));
|
||||
|
||||
let rects: Vec<PdfRect> = raw_rects
|
||||
.into_iter()
|
||||
.map(|(x, y, width, height)| PdfRect {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
page: 1,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(detect_chart_regions(&items, &rects, 1).len(), 1);
|
||||
let (tables, hints) = detect_tables_from_rects(&items, &rects, 1);
|
||||
assert!(tables.is_empty());
|
||||
assert!(hints.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn padded_numeric_grid_frame_remains_a_table() {
|
||||
let row_edges = [
|
||||
[100.0, 140.0, 180.0, 220.0, 260.0],
|
||||
[100.0, 140.0, 180.0, 228.0, 260.0],
|
||||
[100.0, 140.0, 180.0, 214.0, 260.0],
|
||||
[100.0, 140.0, 180.0, 232.0, 260.0],
|
||||
];
|
||||
let mut raw_rects = vec![(96.0, 536.0, 168.0, 80.0)];
|
||||
let mut items = Vec::new();
|
||||
for (row, edges) in row_edges.into_iter().enumerate() {
|
||||
let y = 540.0 + row as f32 * 20.0;
|
||||
for edge in edges.windows(2) {
|
||||
raw_rects.push((edge[0], y, edge[1] - edge[0], 12.0));
|
||||
items.push(make_item("42", edge[0] + 8.0, y + 1.0, 9.0));
|
||||
}
|
||||
}
|
||||
|
||||
assert!(is_repeated_cell_grid(&raw_rects));
|
||||
assert!(has_chart_bar_signature(&items, &raw_rects, 1));
|
||||
let geometry = segmented_stacked_bar_geometry(&raw_rects).expect("segmented rows");
|
||||
assert!(!has_external_segmented_bar_labels(&items, 1, &geometry));
|
||||
assert!(!is_chart_bar_cluster(&items, &raw_rects, 1));
|
||||
|
||||
let flush_items: Vec<TextItem> = (0..4)
|
||||
.map(|row| make_item("1", 100.0, 541.0 + row as f32 * 20.0, 9.0))
|
||||
.collect();
|
||||
assert!(!has_external_segmented_bar_labels(
|
||||
&flush_items,
|
||||
1,
|
||||
&geometry
|
||||
));
|
||||
assert!(!is_chart_bar_cluster(&flush_items, &raw_rects, 1));
|
||||
|
||||
let rects: Vec<PdfRect> = raw_rects
|
||||
.into_iter()
|
||||
.map(|(x, y, width, height)| PdfRect {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
page: 1,
|
||||
})
|
||||
.collect();
|
||||
assert!(detect_chart_regions(&items, &rects, 1).is_empty());
|
||||
assert!(!detect_tables_from_rects(&items, &rects, 1).0.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frameless_segmented_chart_with_category_labels_remains_a_chart() {
|
||||
let row_edges = [
|
||||
[100.0, 140.0, 180.0, 220.0, 260.0],
|
||||
[100.0, 140.0, 180.0, 228.0, 260.0],
|
||||
[100.0, 140.0, 180.0, 214.0, 260.0],
|
||||
[100.0, 140.0, 180.0, 232.0, 260.0],
|
||||
];
|
||||
let mut raw_rects = Vec::new();
|
||||
let mut items = Vec::new();
|
||||
for (row, edges) in row_edges.into_iter().enumerate() {
|
||||
let y = 540.0 + row as f32 * 18.0;
|
||||
raw_rects.extend(
|
||||
edges
|
||||
.windows(2)
|
||||
.map(|edge| (edge[0], y, edge[1] - edge[0], 12.0)),
|
||||
);
|
||||
items.push(make_item("Category", 62.0, y + 1.0, 9.0));
|
||||
}
|
||||
|
||||
let geometry = segmented_stacked_bar_geometry(&raw_rects).expect("segmented stack");
|
||||
assert!(has_external_segmented_bar_labels(&items, 1, &geometry));
|
||||
assert!(is_chart_bar_cluster(&items, &raw_rects, 1));
|
||||
|
||||
let rects: Vec<PdfRect> = raw_rects
|
||||
.into_iter()
|
||||
.map(|(x, y, width, height)| PdfRect {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
page: 1,
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(detect_chart_regions(&items, &rects, 1).len(), 1);
|
||||
}
|
||||
|
||||
// --- detect_stacked_box_table ---
|
||||
|
||||
/// N stacked boxes at x=100, w=300, h=22, top-to-bottom from y=600.
|
||||
@@ -3366,6 +4023,113 @@ mod tests {
|
||||
assert_eq!(groups[0].len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cluster_rects_overlapping_grid_still_clusters() {
|
||||
// Neighboring cells overlap; the grid must still union the whole table.
|
||||
let mut rects = Vec::new();
|
||||
for row in 0..4 {
|
||||
for col in 0..4 {
|
||||
rects.push((col as f32 * 9.0, row as f32 * 9.0, 10.0, 10.0));
|
||||
}
|
||||
}
|
||||
let groups = cluster_rects(&rects, 0.0, 1);
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].len(), 16);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cluster_rects_many_disjoint_stays_subquadratic() {
|
||||
// Pairwise-disjoint rects never merge, so a component-size cap does
|
||||
// not stop all-pairs overlap tests. Spread in X so they land in
|
||||
// different grid cells; 8k is enough that n² tests would dominate.
|
||||
let n = 8_000usize;
|
||||
let rects: Vec<(f32, f32, f32, f32)> =
|
||||
(0..n).map(|i| (i as f32 * 20.0, 0.0, 10.0, 10.0)).collect();
|
||||
let groups = cluster_rects(&rects, 0.0, 2);
|
||||
assert!(groups.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cluster_rects_stacked_disjoint_does_not_starve_later_table() {
|
||||
// Same X, spread in Y: a spatial grid must still union an overlapping
|
||||
// pair in another region of the page.
|
||||
let n = 8_000usize;
|
||||
let mut rects: Vec<(f32, f32, f32, f32)> =
|
||||
(0..n).map(|i| (0.0, i as f32 * 20.0, 10.0, 10.0)).collect();
|
||||
rects.push((500.0, 0.0, 10.0, 10.0));
|
||||
rects.push((508.0, 0.0, 10.0, 10.0));
|
||||
let groups = cluster_rects(&rects, 0.0, 2);
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cluster_rects_oversized_span_still_unions() {
|
||||
// Wider than 64 grid cells; must still union the small overlapping rect.
|
||||
let rects = vec![(0.0, 0.0, 5000.0, 10.0), (4900.0, 0.0, 10.0, 10.0)];
|
||||
let groups = cluster_rects(&rects, 0.0, 1);
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cluster_rects_many_oversized_spans_all_get_a_pass() {
|
||||
// More than 32 huge rects: the last one must still union its overlap.
|
||||
let mut rects: Vec<(f32, f32, f32, f32)> = (0..40)
|
||||
.map(|i| (0.0, i as f32 * 20.0, 5000.0, 10.0))
|
||||
.collect();
|
||||
rects.push((4900.0, 39.0 * 20.0, 10.0, 10.0));
|
||||
let groups = cluster_rects(&rects, 0.0, 2);
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cluster_rects_oversized_not_starved_by_earlier_disjoint() {
|
||||
// 9k earlier disjoint drawings would exhaust an index-order cap of
|
||||
// 8,192 before the overlapping cell is visited.
|
||||
let mut rects: Vec<(f32, f32, f32, f32)> = (0..9_000)
|
||||
.map(|i| (10_000.0, i as f32 * 20.0, 10.0, 10.0))
|
||||
.collect();
|
||||
let wide = rects.len();
|
||||
rects.push((0.0, 0.0, 5000.0, 10.0));
|
||||
let target = rects.len();
|
||||
rects.push((4900.0, 0.0, 10.0, 10.0));
|
||||
let groups = cluster_rects(&rects, 0.0, 2);
|
||||
assert!(
|
||||
groups
|
||||
.iter()
|
||||
.any(|g| g.contains(&wide) && g.contains(&target)),
|
||||
"wide rule and far-end cell must share a cluster"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cluster_rects_wide_and_tall_oversized_union() {
|
||||
let rects = vec![(0.0, 0.0, 5000.0, 10.0), (0.0, 0.0, 10.0, 5000.0)];
|
||||
let groups = cluster_rects(&rects, 0.0, 2);
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cluster_rects_dual_oversized_spans_coarse_y() {
|
||||
let rects = vec![(0.0, 0.0, 5000.0, 5000.0), (0.0, 4500.0, 5000.0, 5000.0)];
|
||||
let groups = cluster_rects(&rects, 0.0, 2);
|
||||
assert_eq!(groups.len(), 1);
|
||||
assert_eq!(groups[0].len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cluster_rects_many_wide_and_tall_stays_subquadratic() {
|
||||
let mut rects = Vec::with_capacity(4_000);
|
||||
for i in 0..2_000 {
|
||||
rects.push((0.0, i as f32 * 20.0, 5000.0, 10.0));
|
||||
rects.push((i as f32 * 20.0, 0.0, 10.0, 5000.0));
|
||||
}
|
||||
let _groups = cluster_rects(&rects, 0.0, 2);
|
||||
}
|
||||
|
||||
// --- snap_edges ---
|
||||
|
||||
#[test]
|
||||
|
||||
+27
-1
@@ -436,7 +436,8 @@ pub(crate) fn recover_header_row(
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, item)| {
|
||||
item.font_size > small_font_threshold
|
||||
!item.is_strikeout
|
||||
&& item.font_size > small_font_threshold
|
||||
&& item.y > first_row_y
|
||||
&& item.y <= first_row_y + row_gap_limit
|
||||
})
|
||||
@@ -804,6 +805,31 @@ 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![
|
||||
|
||||
+6
-2
@@ -12,9 +12,13 @@ mod grid;
|
||||
pub mod structured;
|
||||
|
||||
pub use detect_heuristic::detect_tables;
|
||||
pub(crate) use detect_heuristic::is_table_of_contents;
|
||||
pub(crate) use detect_heuristic::{
|
||||
content_width, detect_tables_with_page_width, is_table_of_contents,
|
||||
};
|
||||
pub use detect_lines::detect_tables_from_lines;
|
||||
pub(crate) use detect_lines::detect_vector_grid_tables_from_lines;
|
||||
pub(crate) use detect_lines::{
|
||||
detect_dense_line_chart_regions, detect_vector_grid_tables_from_lines,
|
||||
};
|
||||
pub(crate) use detect_rects::cluster_rects;
|
||||
pub use detect_rects::{detect_chart_regions, detect_tables_from_rects, RectHintRegion};
|
||||
pub use detect_struct::detect_tables_from_struct_tree;
|
||||
|
||||
@@ -7,6 +7,83 @@
|
||||
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.
|
||||
|
||||
+374
-40
@@ -4,11 +4,17 @@
|
||||
|
||||
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 {
|
||||
@@ -534,18 +540,14 @@ impl ToUnicodeCMap {
|
||||
|
||||
/// Remap a CMap that references pre-subsetting GIDs to sequential post-subsetting GIDs.
|
||||
/// Collects all source CIDs, sorts them, and reassigns to 1, 2, 3, ...
|
||||
///
|
||||
/// Range expansion stops after `MAX_CID_W_EXPANSION` CID visits, counting
|
||||
/// overwrites, so repeated full-width `bfrange`s cannot re-expand the
|
||||
/// 16-bit domain. Later overlapping ranges that would have introduced new
|
||||
/// CIDs after that many visits are truncated.
|
||||
pub fn remap_to_sequential(&self) -> ToUnicodeCMap {
|
||||
let mut cid_to_unicode: HashMap<u16, String> = HashMap::new();
|
||||
|
||||
// Expand ranges first
|
||||
for &(start, end, base) in &self.ranges {
|
||||
for cid in start..=end {
|
||||
let unicode_cp = base + (cid - start) as u32;
|
||||
if let Some(ch) = char::from_u32(unicode_cp) {
|
||||
cid_to_unicode.insert(cid, ch.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
expand_bfranges_for_remap(&self.ranges, &mut cid_to_unicode, MAX_CID_W_EXPANSION);
|
||||
|
||||
// char_map entries override range entries
|
||||
for (&cid, unicode) in &self.char_map {
|
||||
@@ -570,6 +572,33 @@ impl ToUnicodeCMap {
|
||||
}
|
||||
}
|
||||
|
||||
/// Expand `bfrange` entries into individual CID→Unicode inserts.
|
||||
/// Returns how many CIDs were visited. Counts overwrites so a repeated
|
||||
/// full-width range cannot keep working after `max_assignments`.
|
||||
fn expand_bfranges_for_remap(
|
||||
ranges: &[(u16, u16, u32)],
|
||||
cid_to_unicode: &mut HashMap<u16, String>,
|
||||
max_assignments: usize,
|
||||
) -> usize {
|
||||
let mut assigned = 0usize;
|
||||
'ranges: for &(start, end, base) in ranges {
|
||||
if start > end {
|
||||
continue;
|
||||
}
|
||||
for cid in start..=end {
|
||||
if assigned >= max_assignments {
|
||||
break 'ranges;
|
||||
}
|
||||
assigned += 1;
|
||||
let unicode_cp = base + (cid - start) as u32;
|
||||
if let Some(ch) = char::from_u32(unicode_cp) {
|
||||
cid_to_unicode.insert(cid, ch.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
assigned
|
||||
}
|
||||
|
||||
/// Parse a hex string to u16
|
||||
fn parse_hex_u16(hex: &str) -> Option<u16> {
|
||||
u16::from_str_radix(hex.trim(), 16).ok()
|
||||
@@ -588,7 +617,7 @@ fn hex_to_unicode_string(hex: &str) -> Option<String> {
|
||||
|
||||
let bytes: Option<Vec<u8>> = (0..hex.len())
|
||||
.step_by(2)
|
||||
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).ok())
|
||||
.map(|i| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok())
|
||||
.collect();
|
||||
let bytes = bytes?;
|
||||
|
||||
@@ -874,6 +903,25 @@ 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) {
|
||||
@@ -1149,9 +1197,7 @@ 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 dir = find_bcmaps_dir()?;
|
||||
let path = dir.join(name);
|
||||
let data = std::fs::read(&path).ok()?;
|
||||
let data = read_builtin_cmap_file(&name)?;
|
||||
let mut cmap = parse_binary_cmap(&data).ok()?;
|
||||
if cmap.char_map.is_empty() && cmap.ranges.is_empty() {
|
||||
return None;
|
||||
@@ -1159,13 +1205,14 @@ fn build_cmap_from_builtin_cmap(ordering: &str) -> Option<ToUnicodeCMap> {
|
||||
cmap.code_byte_length = 2;
|
||||
debug!(
|
||||
"Built-in CMap {}: char_map={} ranges={}",
|
||||
path.display(),
|
||||
name,
|
||||
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);
|
||||
@@ -1182,6 +1229,18 @@ 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")?;
|
||||
@@ -1492,9 +1551,7 @@ fn parse_encoding_cmap_object(obj: &Object, doc: &Document) -> Option<EncodingCM
|
||||
}
|
||||
|
||||
fn load_builtin_encoding_cmap(name: &str) -> Option<EncodingCMap> {
|
||||
let dir = find_bcmaps_dir()?;
|
||||
let path = dir.join(format!("{}.bcmap", name));
|
||||
let data = std::fs::read(&path).ok()?;
|
||||
let data = read_builtin_cmap_file(&format!("{}.bcmap", name))?;
|
||||
parse_binary_cmap_encoding(&data).ok()
|
||||
}
|
||||
|
||||
@@ -1527,23 +1584,31 @@ fn parse_encoding_cmap_stream(data: &[u8]) -> Option<EncodingCMap> {
|
||||
}
|
||||
|
||||
let mut map = HashMap::new();
|
||||
let mut assigned = 0usize;
|
||||
let mut pos = 0;
|
||||
while let Some(start) = text[pos..].find("begincidchar") {
|
||||
let section_start = pos + start + "begincidchar".len();
|
||||
if let Some(end) = text[section_start..].find("endcidchar") {
|
||||
let section = &text[section_start..section_start + end];
|
||||
parse_cidchar_section(section, &mut map, &mut src_hex_lengths);
|
||||
if !parse_cidchar_section(section, &mut map, &mut src_hex_lengths, &mut assigned) {
|
||||
break;
|
||||
}
|
||||
pos = section_start + end;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
pos = 0;
|
||||
while let Some(start) = text[pos..].find("begincidrange") {
|
||||
while assigned < MAX_CID_W_EXPANSION {
|
||||
let Some(start) = text[pos..].find("begincidrange") else {
|
||||
break;
|
||||
};
|
||||
let section_start = pos + start + "begincidrange".len();
|
||||
if let Some(end) = text[section_start..].find("endcidrange") {
|
||||
let section = &text[section_start..section_start + end];
|
||||
parse_cidrange_section(section, &mut map, &mut src_hex_lengths);
|
||||
if !parse_cidrange_section(section, &mut map, &mut src_hex_lengths, &mut assigned) {
|
||||
break;
|
||||
}
|
||||
pos = section_start + end;
|
||||
} else {
|
||||
break;
|
||||
@@ -1578,7 +1643,8 @@ fn parse_cidchar_section(
|
||||
section: &str,
|
||||
map: &mut HashMap<u16, u16>,
|
||||
src_hex_lengths: &mut Vec<usize>,
|
||||
) {
|
||||
assigned: &mut usize,
|
||||
) -> bool {
|
||||
let mut chars = section.chars().peekable();
|
||||
loop {
|
||||
while chars.peek().is_some_and(|c| c.is_whitespace()) {
|
||||
@@ -1609,16 +1675,20 @@ fn parse_cidchar_section(
|
||||
}
|
||||
}
|
||||
if let (Some(code), Ok(cid)) = (parse_hex_u16(&src_hex), cid_str.parse::<u16>()) {
|
||||
map.insert(code, cid);
|
||||
if !assign_encoding_cid(map, code, cid, assigned) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn parse_cidrange_section(
|
||||
section: &str,
|
||||
map: &mut HashMap<u16, u16>,
|
||||
src_hex_lengths: &mut Vec<usize>,
|
||||
) {
|
||||
assigned: &mut usize,
|
||||
) -> bool {
|
||||
let mut chars = section.chars().peekable();
|
||||
loop {
|
||||
while chars.peek().is_some_and(|c| c.is_whitespace()) {
|
||||
@@ -1669,12 +1739,34 @@ fn parse_cidrange_section(
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
if start > end {
|
||||
continue;
|
||||
}
|
||||
let mut cid = start_cid;
|
||||
for code in start..=end {
|
||||
map.insert(code, cid);
|
||||
if !assign_encoding_cid(map, code, cid, assigned) {
|
||||
return false;
|
||||
}
|
||||
cid = cid.saturating_add(1);
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn assign_encoding_cid(
|
||||
map: &mut HashMap<u16, u16>,
|
||||
code: u16,
|
||||
cid: u16,
|
||||
assigned: &mut usize,
|
||||
) -> bool {
|
||||
// Count overwrites: unique-key coverage alone would not stop a repeated
|
||||
// full-width range from re-inserting all 65,536 codes.
|
||||
if *assigned >= MAX_CID_W_EXPANSION {
|
||||
return false;
|
||||
}
|
||||
map.insert(code, cid);
|
||||
*assigned += 1;
|
||||
true
|
||||
}
|
||||
|
||||
fn parse_binary_cmap_encoding(data: &[u8]) -> Result<EncodingCMap, String> {
|
||||
@@ -1779,9 +1871,7 @@ fn load_builtin_cmap_by_name(name: &str) -> Option<ToUnicodeCMap> {
|
||||
if !name.ends_with("UCS2") {
|
||||
return None;
|
||||
}
|
||||
let dir = find_bcmaps_dir()?;
|
||||
let path = dir.join(format!("{}.bcmap", name));
|
||||
let data = std::fs::read(&path).ok()?;
|
||||
let data = read_builtin_cmap_file(&format!("{}.bcmap", name))?;
|
||||
let mut cmap = parse_binary_cmap(&data).ok()?;
|
||||
if cmap.char_map.is_empty() && cmap.ranges.is_empty() {
|
||||
return None;
|
||||
@@ -1800,6 +1890,13 @@ fn merge_cmaps(mut base: ToUnicodeCMap, overlay: ToUnicodeCMap) -> ToUnicodeCMap
|
||||
base
|
||||
}
|
||||
|
||||
/// Shared 16-bit CID expansion cap (65,536).
|
||||
/// Encoding `begincidrange`, `/W` width assignment, and ToUnicode sequential
|
||||
/// remap count every insert, including overwrites, so a repeated full-width
|
||||
/// range cannot keep working after the domain is filled. The `/W` unicode
|
||||
/// heuristic caps unique CIDs with the same number.
|
||||
pub(crate) const MAX_CID_W_EXPANSION: usize = 65_536;
|
||||
|
||||
/// Check if a CIDFont's /W (widths) array contains CID values that look like
|
||||
/// Unicode codepoints rather than low-value GIDs.
|
||||
///
|
||||
@@ -1811,20 +1908,23 @@ pub(crate) fn cid_values_look_like_unicode(cid_font_dict: &lopdf::Dictionary) ->
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
// The /W array format: [cid [w1 w2 ...]] or [cid_start cid_end w]
|
||||
// We extract all CID values (the first element of each group).
|
||||
let mut cids: Vec<u16> = Vec::new();
|
||||
// The /W array format: [cid [w1 w2 ...]] or [cid_start cid_end w].
|
||||
// Collect unique CIDs only: repeating a full-width range must not grow a
|
||||
// temporary vector (or the sort) with the range length on every copy.
|
||||
let mut seen = HashSet::new();
|
||||
let mut i = 0;
|
||||
while i < w_arr.len() {
|
||||
while i < w_arr.len() && seen.len() < MAX_CID_W_EXPANSION {
|
||||
if let Ok(cid) = w_arr[i].as_i64() {
|
||||
cids.push(cid as u16);
|
||||
// Skip the width data
|
||||
let start = cid as u16;
|
||||
if i + 1 < w_arr.len() {
|
||||
match &w_arr[i + 1] {
|
||||
Object::Array(widths) => {
|
||||
// [cid [w1 w2 ...]] — CIDs are cid, cid+1, ..., cid+len-1
|
||||
for j in 1..widths.len() {
|
||||
cids.push((cid as u16).wrapping_add(j as u16));
|
||||
for j in 0..widths.len() {
|
||||
if seen.len() >= MAX_CID_W_EXPANSION {
|
||||
break;
|
||||
}
|
||||
seen.insert(start.wrapping_add(j as u16));
|
||||
}
|
||||
i += 2;
|
||||
}
|
||||
@@ -1832,9 +1932,7 @@ pub(crate) fn cid_values_look_like_unicode(cid_font_dict: &lopdf::Dictionary) ->
|
||||
// [cid_start cid_end w] — range of CIDs
|
||||
if i + 2 < w_arr.len() {
|
||||
if let Ok(cid_end) = w_arr[i + 1].as_i64() {
|
||||
for c in (cid as u16)..=(cid_end as u16) {
|
||||
cids.push(c);
|
||||
}
|
||||
record_unique_cid_range(start, cid_end as u16, &mut seen);
|
||||
}
|
||||
i += 3;
|
||||
} else {
|
||||
@@ -1843,6 +1941,7 @@ pub(crate) fn cid_values_look_like_unicode(cid_font_dict: &lopdf::Dictionary) ->
|
||||
}
|
||||
}
|
||||
} else {
|
||||
seen.insert(start);
|
||||
i += 1;
|
||||
}
|
||||
} else {
|
||||
@@ -1850,10 +1949,11 @@ pub(crate) fn cid_values_look_like_unicode(cid_font_dict: &lopdf::Dictionary) ->
|
||||
}
|
||||
}
|
||||
|
||||
if cids.is_empty() {
|
||||
if seen.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mut cids: Vec<u16> = seen.into_iter().collect();
|
||||
cids.sort_unstable();
|
||||
let median = cids[cids.len() / 2];
|
||||
// Unicode text CIDs are typically >= 0x20 (space) with letters at 0x41+.
|
||||
@@ -1862,6 +1962,18 @@ pub(crate) fn cid_values_look_like_unicode(cid_font_dict: &lopdf::Dictionary) ->
|
||||
median >= 0x41
|
||||
}
|
||||
|
||||
fn record_unique_cid_range(start: u16, end: u16, seen: &mut HashSet<u16>) {
|
||||
if start > end {
|
||||
return;
|
||||
}
|
||||
for cid in start..=end {
|
||||
if seen.len() >= MAX_CID_W_EXPANSION {
|
||||
return;
|
||||
}
|
||||
seen.insert(cid);
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a ToUnicodeCMap from predefined CID→Unicode mapping based on CIDSystemInfo.
|
||||
///
|
||||
/// Supports Adobe-Korea1 (Korean) character collection. Can be extended for
|
||||
@@ -2574,6 +2686,24 @@ 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>
|
||||
@@ -2817,6 +2947,33 @@ endbfrange
|
||||
assert!(remapped.ranges.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remap_to_sequential_repeated_full_bfranges_stay_bounded() {
|
||||
// 5,000 copies of `<0003> <ffff>` must stop after 65,536 CID visits,
|
||||
// not 5,000 × ~65,533 expansions.
|
||||
let ranges = vec![(3u16, 65535u16, 0x41u32); 5_000];
|
||||
let mut map = std::collections::HashMap::new();
|
||||
let assigned = expand_bfranges_for_remap(&ranges, &mut map, MAX_CID_W_EXPANSION);
|
||||
assert_eq!(assigned, MAX_CID_W_EXPANSION);
|
||||
assert!(map.len() <= MAX_CID_W_EXPANSION);
|
||||
|
||||
let mut body = String::new();
|
||||
let mut remaining = 5_000usize;
|
||||
while remaining > 0 {
|
||||
let n = remaining.min(100);
|
||||
body.push_str(&format!("{n} beginbfrange\n"));
|
||||
for _ in 0..n {
|
||||
body.push_str("<0003> <ffff> <0041>\n");
|
||||
}
|
||||
body.push_str("endbfrange\n");
|
||||
remaining -= n;
|
||||
}
|
||||
let data = format!("1 begincodespacerange\n<0000> <ffff>\nendcodespacerange\n{body}");
|
||||
let cmap = ToUnicodeCMap::parse(data.as_bytes()).unwrap();
|
||||
let remapped = cmap.remap_to_sequential();
|
||||
assert_eq!(remapped.lookup(1), Some("A".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_min_source_cid() {
|
||||
let cmap_content = r#"
|
||||
@@ -3146,4 +3303,181 @@ 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));
|
||||
}
|
||||
}
|
||||
|
||||
+113
-10
@@ -148,14 +148,17 @@ impl TextLine {
|
||||
self.text_with_formatting(false, false, false)
|
||||
}
|
||||
|
||||
/// Get text with optional bold/italic/underline markdown formatting
|
||||
/// 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_underline: bool,
|
||||
format_decorations: bool,
|
||||
) -> String {
|
||||
if !format_bold && !format_italic && !format_underline {
|
||||
if !format_bold && !format_italic && !format_decorations {
|
||||
return self.text_plain();
|
||||
}
|
||||
|
||||
@@ -165,6 +168,7 @@ impl TextLine {
|
||||
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();
|
||||
@@ -190,13 +194,16 @@ impl TextLine {
|
||||
// we push text_trimmed below (which strips it).
|
||||
let has_leading_space = text.starts_with(' ');
|
||||
|
||||
// Check for style changes. Underline is exclusive: `<u>` content
|
||||
// stays free of `**`/`*` markers — consumers (and the eval
|
||||
// harnesses this feeds) match the tag content literally, and
|
||||
// mixed `<u>**x**</u>` nesting breaks that.
|
||||
let item_underline = format_underline && item.is_underline;
|
||||
let item_bold = format_bold && item.is_bold && !item_underline;
|
||||
let item_italic = format_italic && item.is_italic && !item_underline;
|
||||
// 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;
|
||||
|
||||
// Close previous styles if they change
|
||||
if current_italic && !item_italic {
|
||||
@@ -211,6 +218,10 @@ impl TextLine {
|
||||
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(' ')) {
|
||||
@@ -222,6 +233,10 @@ impl TextLine {
|
||||
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;
|
||||
@@ -244,6 +259,9 @@ impl TextLine {
|
||||
if current_underline {
|
||||
result.push_str("</u>");
|
||||
}
|
||||
if current_strikeout {
|
||||
result.push_str("</s>");
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
@@ -309,3 +327,88 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
%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
|
||||
+79
File diff suppressed because one or more lines are too long
BIN
Binary file not shown.
Binary file not shown.
+1239
File diff suppressed because it is too large
Load Diff
+751
-4
@@ -8,12 +8,13 @@ use pdf_inspector::{
|
||||
detect_pdf_type, detect_vector_grid_in_region_mem, extract_pages_markdown,
|
||||
extract_pages_markdown_mem, extract_tables_in_regions_mem, extract_text,
|
||||
extract_text_in_regions_mem, extract_text_with_positions, extract_text_with_positions_mem,
|
||||
process_pdf_mem, process_pdf_with_options, to_markdown, MarkdownOptions, PdfError, PdfOptions,
|
||||
process_pdf_mem, process_pdf_mem_with_options, process_pdf_with_options, to_markdown,
|
||||
to_markdown_from_items_with_rects_and_page_count, MarkdownOptions, PdfError, PdfOptions,
|
||||
PdfType, TextItem,
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn make_minimal_text_pdf() -> Vec<u8> {
|
||||
fn make_text_pdf(content: &str, media_box: &str) -> Vec<u8> {
|
||||
let mut pdf = b"%PDF-1.4\n".to_vec();
|
||||
let mut offsets = vec![0usize];
|
||||
|
||||
@@ -40,10 +41,11 @@ fn make_minimal_text_pdf() -> Vec<u8> {
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
3,
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>",
|
||||
&format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [{media_box}] /Resources << /Font << /F1 5 0 R >> >> /Contents 4 0 R >>"
|
||||
),
|
||||
);
|
||||
|
||||
let content = "BT /F1 12 Tf 100 700 Td (Hello World) Tj 0 -14 Td (Second Line) Tj 0 -14 Td (Third Line) Tj ET";
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
@@ -79,6 +81,186 @@ fn make_minimal_text_pdf() -> Vec<u8> {
|
||||
pdf
|
||||
}
|
||||
|
||||
fn make_recurring_contextual_folio_pdf() -> Vec<u8> {
|
||||
let mut pdf = b"%PDF-1.4\n".to_vec();
|
||||
let mut offsets = vec![0usize];
|
||||
|
||||
fn add_object(pdf: &mut Vec<u8>, offsets: &mut Vec<usize>, id: usize, body: &str) {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
|
||||
pdf.extend_from_slice(body.as_bytes());
|
||||
pdf.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
1,
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
);
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
2,
|
||||
"<< /Type /Pages /Kids [3 0 R 5 0 R 7 0 R 9 0 R] /Count 4 >>",
|
||||
);
|
||||
for page_index in 0..4 {
|
||||
let page_id = 3 + page_index * 2;
|
||||
let content_id = page_id + 1;
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
page_id,
|
||||
&format!(
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 11 0 R >> >> /Contents {content_id} 0 R >>"
|
||||
),
|
||||
);
|
||||
let page_number = page_index + 1;
|
||||
let content = format!(
|
||||
"BT /F1 12 Tf 1 0 0 1 25 30 Tm ({page_number}) Tj 1 0 0 1 41 30 Tm (Company report footer) Tj 1 0 0 1 72 700 Tm (Body page {page_number}) Tj ET"
|
||||
);
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
content_id,
|
||||
&format!(
|
||||
"<< /Length {} >>\nstream\n{}\nendstream",
|
||||
content.len(),
|
||||
content
|
||||
),
|
||||
);
|
||||
}
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
11,
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
);
|
||||
|
||||
let xref_start = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n", offsets.len()).as_bytes());
|
||||
pdf.extend_from_slice(b"0000000000 65535 f \n");
|
||||
for offset in offsets.iter().skip(1) {
|
||||
pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(
|
||||
format!(
|
||||
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF",
|
||||
offsets.len(),
|
||||
xref_start
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
|
||||
pdf
|
||||
}
|
||||
|
||||
fn make_pdf_with_malformed_unselected_page() -> Vec<u8> {
|
||||
let mut pdf = b"%PDF-1.4\n".to_vec();
|
||||
let mut offsets = vec![0usize];
|
||||
|
||||
fn add_object(pdf: &mut Vec<u8>, offsets: &mut Vec<usize>, id: usize, body: &str) {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
|
||||
pdf.extend_from_slice(body.as_bytes());
|
||||
pdf.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
1,
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
);
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
2,
|
||||
"<< /Type /Pages /Kids [3 0 R 5 0 R] /Count 2 >>",
|
||||
);
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
3,
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 7 0 R >> >> /Contents 4 0 R >>",
|
||||
);
|
||||
let content = "BT /F1 12 Tf 1 0 0 1 25 30 Tm (1) Tj 1 0 0 1 41 30 Tm (Company report footer) Tj 1 0 0 1 72 700 Tm (Selected page text) Tj 0 -16 Td (More selected text) Tj 0 -16 Td (Still selected text) Tj ET";
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
4,
|
||||
&format!(
|
||||
"<< /Length {} >>\nstream\n{}\nendstream",
|
||||
content.len(),
|
||||
content
|
||||
),
|
||||
);
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
5,
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << /Font << /F1 7 0 R >> >> /Contents 6 0 R >>",
|
||||
);
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
6,
|
||||
"<< /Length 3 >>\nstream\nBI \nendstream",
|
||||
);
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
7,
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
);
|
||||
|
||||
let xref_start = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n", offsets.len()).as_bytes());
|
||||
pdf.extend_from_slice(b"0000000000 65535 f \n");
|
||||
for offset in offsets.iter().skip(1) {
|
||||
pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(
|
||||
format!(
|
||||
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF",
|
||||
offsets.len(),
|
||||
xref_start
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
|
||||
pdf
|
||||
}
|
||||
|
||||
fn make_minimal_text_pdf() -> Vec<u8> {
|
||||
make_text_pdf(
|
||||
"BT /F1 12 Tf 100 700 Td (Hello World) Tj 0 -14 Td (Second Line) Tj 0 -14 Td (Third Line) Tj ET",
|
||||
"0 0 612 792",
|
||||
)
|
||||
}
|
||||
|
||||
fn make_digit_run_repro_pdf() -> Vec<u8> {
|
||||
let content = r#"BT
|
||||
/F1 12 Tf
|
||||
1 0 0 1 72 780 Tm (A\)) Tj
|
||||
1 0 0 1 96 780 Tm (The) Tj
|
||||
1 0 0 1 126 780 Tm (total) Tj
|
||||
1 0 0 1 166 780 Tm (of) Tj
|
||||
1 0 0 1 186 780 Tm (730) Tj
|
||||
1 0 0 1 220 780 Tm (seats) Tj
|
||||
1 0 0 1 262 780 Tm (was) Tj
|
||||
1 0 0 1 296 780 Tm (approved.) Tj
|
||||
1 0 0 1 72 755 Tm (B\)) Tj
|
||||
1 0 0 1 96 755 Tm (let) Tj
|
||||
1 0 0 1 120 755 Tm (log) Tj
|
||||
1 0 0 1 150 755 Tm (2) Tj
|
||||
1 0 0 1 164 755 Tm (=) Tj
|
||||
1 0 0 1 180 755 Tm (a) Tj
|
||||
1 0 0 1 72 720 Tm (C\) Control: The total of 730 seats was approved. let log 2 = a) Tj
|
||||
ET"#;
|
||||
make_text_pdf(content, "0 0 595 842")
|
||||
}
|
||||
|
||||
fn truncate_eof_marker(mut pdf: Vec<u8>) -> Vec<u8> {
|
||||
assert!(pdf.ends_with(b"%%EOF"));
|
||||
pdf.pop();
|
||||
@@ -330,6 +512,21 @@ fn test_group_into_lines_sorting_by_x() {
|
||||
assert_eq!(lines[0].text(), "First Second Third");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_digit_only_text_runs_are_preserved_in_markdown() {
|
||||
let pdf = make_digit_run_repro_pdf();
|
||||
|
||||
let items = extract_text_with_positions_mem(&pdf).expect("extract positioned text");
|
||||
assert!(items.iter().any(|item| item.text == "730"));
|
||||
assert!(items.iter().any(|item| item.text == "2"));
|
||||
|
||||
let result = process_pdf_mem(&pdf).expect("convert PDF to markdown");
|
||||
assert_eq!(
|
||||
result.markdown.expect("markdown output").trim(),
|
||||
"A) The total of 730 seats was approved.\nB) let log 2 = a\nC) Control: The total of 730 seats was approved. let log 2 = a"
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MarkdownOptions Tests
|
||||
// ============================================================================
|
||||
@@ -547,6 +744,30 @@ fn test_markdown_from_items_page_breaks() {
|
||||
assert!(md.contains("Content on second page"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_markdown_page_count_overload_includes_trailing_blank_pages_in_folio_coverage() {
|
||||
let mut items = Vec::new();
|
||||
for (page, value) in [(1, "1"), (2, "2"), (3, "3"), (4, "4")] {
|
||||
items.push(make_text_item(value, 25.0, 30.0, 12.0, page));
|
||||
items.push(make_text_item(
|
||||
"Company report footer",
|
||||
41.0,
|
||||
30.0,
|
||||
12.0,
|
||||
page,
|
||||
));
|
||||
}
|
||||
let options = MarkdownOptions {
|
||||
strip_headers_footers: false,
|
||||
..MarkdownOptions::default()
|
||||
};
|
||||
|
||||
let md = to_markdown_from_items_with_rects_and_page_count(items, options, &[], 20);
|
||||
|
||||
assert!(md.contains("1 Company report footer"));
|
||||
assert!(md.contains("4 Company report footer"));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Markdown From Lines Tests
|
||||
// ============================================================================
|
||||
@@ -1089,6 +1310,18 @@ fn test_snapshot_2013_app2() {
|
||||
assert_snapshot("2013-app2");
|
||||
}
|
||||
|
||||
/// First two pages of Shannon's "A Mathematical Theory of Communication"
|
||||
/// (1998 dvips 5.58 → Distiller 3 retypesetting). Canonical legacy-TeX PDF:
|
||||
/// non-embedded base-14 fonts with no /Widths (exercises the built-in AFM
|
||||
/// metrics fallback), Type3 PK bitmap math fonts with FontMatrix
|
||||
/// [1 0 0 -1 0 0] (exercises visual-size scaling), a two-line embedded drop
|
||||
/// cap, indent-only paragraph breaks, and display math that must not be
|
||||
/// detected as tables or headings.
|
||||
#[test]
|
||||
fn test_snapshot_shannon_entropy() {
|
||||
assert_snapshot("shannon-entropy-p1-2");
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Pages Needing OCR Tests
|
||||
// ============================================================================
|
||||
@@ -1196,6 +1429,77 @@ fn test_firecrawl_tagged_pdf_struct_tree() {
|
||||
assert_eq!(fence_count % 2, 0, "Code fences should be balanced");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_tagged_pdf_text_items_carry_mcid() {
|
||||
let buf = std::fs::read("tests/fixtures/firecrawl_docs_tagged.pdf").unwrap();
|
||||
let items = pdf_inspector::extractor::extract_text_with_positions_mem(&buf).unwrap();
|
||||
assert!(
|
||||
items.iter().any(|i| i.mcid.is_some()),
|
||||
"Tagged PDF text items should carry Marked Content IDs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_structure_elements_tagged_pdf() {
|
||||
let buf = std::fs::read("tests/fixtures/firecrawl_docs_tagged.pdf").unwrap();
|
||||
let elements = pdf_inspector::extract_structure_elements_mem(&buf, None).unwrap();
|
||||
assert!(!elements.is_empty(), "Tagged PDF should yield elements");
|
||||
assert!(
|
||||
elements.iter().any(|e| e.role == "H1"),
|
||||
"Should surface H1 heading roles"
|
||||
);
|
||||
assert!(
|
||||
elements.iter().all(|e| !e.role.is_empty()),
|
||||
"Every element should carry a role name"
|
||||
);
|
||||
|
||||
// Sorted by (page, mcid) for deterministic output
|
||||
assert!(
|
||||
elements
|
||||
.windows(2)
|
||||
.all(|w| (w[0].page, w[0].mcid) <= (w[1].page, w[1].mcid)),
|
||||
"Elements should be sorted by (page, mcid)"
|
||||
);
|
||||
|
||||
// The advertised join: (page, mcid) pairs must line up with the
|
||||
// mcid-carrying TextItems from positioned extraction, and joining the
|
||||
// H1 entries must recover non-empty heading text.
|
||||
let items = pdf_inspector::extractor::extract_text_with_positions_mem(&buf).unwrap();
|
||||
let h1_refs: std::collections::HashSet<(u32, i64)> = elements
|
||||
.iter()
|
||||
.filter(|e| e.role == "H1")
|
||||
.map(|e| (e.page, e.mcid))
|
||||
.collect();
|
||||
let h1_text: String = items
|
||||
.iter()
|
||||
.filter(|i| i.mcid.is_some_and(|mcid| h1_refs.contains(&(i.page, mcid))))
|
||||
.map(|i| i.text.as_str())
|
||||
.collect();
|
||||
assert!(
|
||||
!h1_text.trim().is_empty(),
|
||||
"Joining H1 structure elements to text items should recover heading text"
|
||||
);
|
||||
|
||||
// Page filter is 1-indexed (matching TextItem.page) and equals the
|
||||
// corresponding subset of the full document result.
|
||||
let page1 = pdf_inspector::extract_structure_elements_mem(&buf, Some(&[1])).unwrap();
|
||||
assert!(!page1.is_empty(), "Page 1 should have elements");
|
||||
assert!(page1.iter().all(|e| e.page == 1));
|
||||
let full_page1_count = elements.iter().filter(|e| e.page == 1).count();
|
||||
assert_eq!(page1.len(), full_page1_count);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_structure_elements_untagged_pdf_empty() {
|
||||
let buf = std::fs::read("tests/fixtures/thermo-freon12.pdf").unwrap();
|
||||
let elements = pdf_inspector::extract_structure_elements_mem(&buf, None).unwrap();
|
||||
assert!(
|
||||
elements.is_empty(),
|
||||
"Untagged PDF should yield no structure elements, got {:?}",
|
||||
elements
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_identity_h_no_tounicode_suppresses_garbage() {
|
||||
// shinagawa_identity_h.pdf uses YuGothic with Identity-H encoding and no
|
||||
@@ -1350,6 +1654,282 @@ fn test_extract_regions_mem_basic_text_pdf() {
|
||||
assert_eq!(regions[0].page, 0);
|
||||
}
|
||||
|
||||
/// Build a synthetic "scanned page" PDF: a full-page image XObject with a
|
||||
/// text layer drawn in the given render mode (3 = invisible OCR overlay,
|
||||
/// 0 = normal visible fill). `visible_extra` optionally adds a normally
|
||||
/// rendered line so double-layer behavior can be tested; `layer_lines`
|
||||
/// overrides the layer content (default: three pangram lines);
|
||||
/// `quote_ops` shows every layer line via the `'` operator instead of Tj
|
||||
/// (both are standard show-text encodings for OCR layers).
|
||||
fn make_pdf_with_custom_text_layer(
|
||||
text_render_mode: i32,
|
||||
visible_extra: Option<&str>,
|
||||
layer_lines: Option<&[&str]>,
|
||||
quote_ops: bool,
|
||||
) -> Vec<u8> {
|
||||
let mut pdf = b"%PDF-1.4\n".to_vec();
|
||||
let mut offsets = vec![0usize];
|
||||
|
||||
fn add_object(pdf: &mut Vec<u8>, offsets: &mut Vec<usize>, id: usize, body: &str) {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
|
||||
pdf.extend_from_slice(body.as_bytes());
|
||||
pdf.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
fn add_stream_object(
|
||||
pdf: &mut Vec<u8>,
|
||||
offsets: &mut Vec<usize>,
|
||||
id: usize,
|
||||
dict: &str,
|
||||
stream_bytes: &[u8],
|
||||
) {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
|
||||
pdf.extend_from_slice(
|
||||
format!("<< {} /Length {} >>\nstream\n", dict, stream_bytes.len()).as_bytes(),
|
||||
);
|
||||
pdf.extend_from_slice(stream_bytes);
|
||||
pdf.extend_from_slice(b"\nendstream\nendobj\n");
|
||||
}
|
||||
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
1,
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
);
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
2,
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
);
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
3,
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] \
|
||||
/Resources << /Font << /F1 5 0 R >> /XObject << /Im0 6 0 R >> >> \
|
||||
/Contents 4 0 R >>",
|
||||
);
|
||||
// Full-page raster, then the text layer in the requested render mode —
|
||||
// several lines so the OCR-layer gate's alnum floor (40) is well cleared.
|
||||
let mut content = String::from("q 612 0 0 792 0 0 cm /Im0 Do Q\n");
|
||||
let default_layer = [
|
||||
"The quick brown fox jumps over the lazy dog",
|
||||
"Pack my box with five dozen liquor jugs tonight",
|
||||
"Sphinx of black quartz judge my vow carefully",
|
||||
];
|
||||
let layer: &[&str] = layer_lines.unwrap_or(&default_layer);
|
||||
if quote_ops {
|
||||
// Every line shown via `'` (move-to-next-line + show) — nothing on
|
||||
// this layer goes through Tj, pinning the `'` suppression path.
|
||||
content.push_str(&format!(
|
||||
"BT /F1 12 Tf {text_render_mode} Tr 16 TL 72 716 Td "
|
||||
));
|
||||
for line in layer {
|
||||
content.push_str(&format!("({line}) ' "));
|
||||
}
|
||||
} else {
|
||||
content.push_str(&format!("BT /F1 12 Tf {text_render_mode} Tr 72 700 Td "));
|
||||
for (i, line) in layer.iter().enumerate() {
|
||||
if i > 0 {
|
||||
content.push_str("0 -16 Td ");
|
||||
}
|
||||
content.push_str(&format!("({line}) Tj "));
|
||||
}
|
||||
}
|
||||
content.push_str("ET\n");
|
||||
if let Some(extra) = visible_extra {
|
||||
content.push_str(&format!("BT /F1 12 Tf 0 Tr 72 500 Td ({extra}) Tj ET\n"));
|
||||
}
|
||||
add_stream_object(&mut pdf, &mut offsets, 4, "", content.as_bytes());
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
5,
|
||||
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
|
||||
);
|
||||
let image_pixel = [128u8];
|
||||
add_stream_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
6,
|
||||
"/Type /XObject /Subtype /Image /Width 1 /Height 1 \
|
||||
/ColorSpace /DeviceGray /BitsPerComponent 8",
|
||||
&image_pixel,
|
||||
);
|
||||
|
||||
let xref_start = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n", offsets.len()).as_bytes());
|
||||
pdf.extend_from_slice(b"0000000000 65535 f \n");
|
||||
for offset in offsets.iter().skip(1) {
|
||||
pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(
|
||||
format!(
|
||||
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF",
|
||||
offsets.len(),
|
||||
xref_start
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
pdf
|
||||
}
|
||||
|
||||
fn make_pdf_with_text_layer(text_render_mode: i32, visible_extra: Option<&str>) -> Vec<u8> {
|
||||
make_pdf_with_custom_text_layer(text_render_mode, visible_extra, None, false)
|
||||
}
|
||||
|
||||
/// A scanned page whose only text is an invisible (Tr 3) OCR layer behind
|
||||
/// the raster must serve that layer from the region extractor instead of
|
||||
/// reporting the region as needs_ocr — the exact text is already in the PDF.
|
||||
#[test]
|
||||
fn test_extract_regions_mem_recovers_invisible_ocr_layer() {
|
||||
let buf = make_pdf_with_text_layer(3, None);
|
||||
let regions = extract_text_in_regions_mem(&buf, &full_page_regions(1)).unwrap();
|
||||
assert_eq!(regions.len(), 1);
|
||||
let region = ®ions[0].regions[0];
|
||||
assert!(
|
||||
region.text.contains("quick brown fox"),
|
||||
"invisible OCR layer should be served as region text, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
assert!(
|
||||
!region.needs_ocr,
|
||||
"recovered OCR layer must not fall back to GPU OCR"
|
||||
);
|
||||
}
|
||||
|
||||
/// ANY visible text on the page — even a single short line — must block the
|
||||
/// invisible-layer adoption entirely: the invisible pass returns visible
|
||||
/// items too, so adopting it alongside visible text would duplicate the
|
||||
/// visible words. Strict zero-visible gate, no fuzzy dedupe.
|
||||
#[test]
|
||||
fn test_extract_regions_mem_visible_text_blocks_invisible_layer() {
|
||||
let buf = make_pdf_with_text_layer(3, Some("Folio 142"));
|
||||
let regions = extract_text_in_regions_mem(&buf, &full_page_regions(1)).unwrap();
|
||||
let region = ®ions[0].regions[0];
|
||||
assert!(
|
||||
region.text.contains("Folio 142"),
|
||||
"visible text should be extracted, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
assert!(
|
||||
!region.text.contains("quick brown fox"),
|
||||
"invisible layer must not be adopted when any visible text exists, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
assert_eq!(
|
||||
region.text.matches("Folio 142").count(),
|
||||
1,
|
||||
"visible text must appear exactly once, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
}
|
||||
|
||||
/// An invisible OCR layer shown entirely via the `'` show-text operator
|
||||
/// (move-to-next-line + show) must also be recovered — the skipped_invisible
|
||||
/// signal has to fire on every show-text path, not just Tj/TJ.
|
||||
#[test]
|
||||
fn test_extract_regions_mem_recovers_quote_operator_layer() {
|
||||
let buf = make_pdf_with_custom_text_layer(3, None, None, true);
|
||||
let regions = extract_text_in_regions_mem(&buf, &full_page_regions(1)).unwrap();
|
||||
let region = ®ions[0].regions[0];
|
||||
assert!(
|
||||
region.text.contains("quick brown fox"),
|
||||
"'-operator OCR layer should be recovered, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
assert!(!region.needs_ocr);
|
||||
}
|
||||
|
||||
/// An invisible layer below the 40-alnum floor (a stray watermark line)
|
||||
/// must NOT be adopted — the region keeps its needs_ocr fallback.
|
||||
#[test]
|
||||
fn test_extract_regions_mem_tiny_invisible_layer_not_adopted() {
|
||||
let buf = make_pdf_with_custom_text_layer(3, None, Some(&["Scanned by ACME"]), false);
|
||||
let regions = extract_text_in_regions_mem(&buf, &full_page_regions(1)).unwrap();
|
||||
let region = ®ions[0].regions[0];
|
||||
assert!(
|
||||
!region.text.contains("Scanned by ACME"),
|
||||
"below-floor invisible layer must not be adopted, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
// Only the raster placeholder remains — needs_ocr stays whatever main
|
||||
// reports for placeholder-only regions (false today; downstream
|
||||
// pipelines route placeholder-only text to OCR themselves, and this PR
|
||||
// deliberately does not change that contract).
|
||||
assert!(
|
||||
region.text.trim().starts_with("[Image:"),
|
||||
"region should hold only the raster placeholder, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
}
|
||||
|
||||
/// An invisible layer that clears the alnum floor but is mostly symbol
|
||||
/// garbage (a broken OCR run) must be rejected by the garbage gate.
|
||||
#[test]
|
||||
fn test_extract_regions_mem_garbage_invisible_layer_not_adopted() {
|
||||
// Each line: 5 alphanumerics among 15 symbol chars. Ten lines clear the
|
||||
// 40-alnum floor (50 alnum) while staying well under the half-alnum
|
||||
// ratio is_garbage_text requires.
|
||||
let garbage_lines: Vec<&str> = vec!["a@@b%%c&&d==e~~"; 10];
|
||||
let buf = make_pdf_with_custom_text_layer(3, None, Some(&garbage_lines), false);
|
||||
let regions = extract_text_in_regions_mem(&buf, &full_page_regions(1)).unwrap();
|
||||
let region = ®ions[0].regions[0];
|
||||
assert!(
|
||||
!region.text.contains("a@@b"),
|
||||
"garbage invisible layer must not be adopted, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
assert!(
|
||||
region.text.trim().starts_with("[Image:"),
|
||||
"region should hold only the raster placeholder, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
}
|
||||
|
||||
/// Punctuation-only visible text (zero alphanumerics) must ALSO block
|
||||
/// adoption — the gate is item-presence, not alphanumeric mass. (Real-world
|
||||
/// rationale: an invisible OCR layer transcribes the raster, so visible
|
||||
/// glyphs typically have invisible twins there; this fixture's layers are
|
||||
/// disjoint, so it pins the gate itself, not the duplication scenario.)
|
||||
#[test]
|
||||
fn test_extract_regions_mem_punctuation_visible_blocks_invisible_layer() {
|
||||
let buf = make_pdf_with_text_layer(3, Some("... --- ..."));
|
||||
let regions = extract_text_in_regions_mem(&buf, &full_page_regions(1)).unwrap();
|
||||
let region = ®ions[0].regions[0];
|
||||
assert!(
|
||||
!region.text.contains("quick brown fox"),
|
||||
"invisible layer must not be adopted over punctuation-only visible text, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
assert_eq!(
|
||||
region.text.matches("... --- ...").count(),
|
||||
1,
|
||||
"visible punctuation must be preserved exactly once, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression guard: a normal visible-text page (render mode 0) is served
|
||||
/// once and only once — if the fallback ever mis-fired here and merged a
|
||||
/// second pass, the phrase would duplicate.
|
||||
#[test]
|
||||
fn test_extract_regions_mem_visible_layer_unchanged() {
|
||||
let buf = make_pdf_with_text_layer(0, None);
|
||||
let regions = extract_text_in_regions_mem(&buf, &full_page_regions(1)).unwrap();
|
||||
let region = ®ions[0].regions[0];
|
||||
assert_eq!(
|
||||
region.text.matches("quick brown fox").count(),
|
||||
1,
|
||||
"visible text must appear exactly once, got: {:?}",
|
||||
region.text
|
||||
);
|
||||
assert!(!region.needs_ocr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_regions_mem_identity_h_needs_ocr() {
|
||||
let buf = std::fs::read("tests/fixtures/shinagawa_identity_h.pdf").unwrap();
|
||||
@@ -2915,6 +3495,57 @@ fn test_extract_pages_markdown_basic() {
|
||||
assert!(!result.pages[0].needs_ocr);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_pages_markdown_uses_document_wide_folio_context() {
|
||||
let pdf = make_recurring_contextual_folio_pdf();
|
||||
let result = extract_pages_markdown_mem(&pdf, None).unwrap();
|
||||
|
||||
assert_eq!(result.pages.len(), 4);
|
||||
for (index, page) in result.pages.iter().enumerate() {
|
||||
assert!(page.markdown.contains("Company report footer"));
|
||||
assert!(
|
||||
!page
|
||||
.markdown
|
||||
.contains(&format!("{} Company report footer", index + 1)),
|
||||
"recurring contextual folio survived on page {}: {}",
|
||||
index + 1,
|
||||
page.markdown
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_process_pdf_page_filter_uses_document_wide_folio_context() {
|
||||
let pdf = make_recurring_contextual_folio_pdf();
|
||||
let result = process_pdf_mem_with_options(&pdf, PdfOptions::new().pages([1])).unwrap();
|
||||
let markdown = result.markdown.unwrap();
|
||||
|
||||
assert!(markdown.contains("Company report footer"));
|
||||
assert!(!markdown.contains("1 Company report footer"), "{markdown}");
|
||||
assert!(markdown.contains("Body page 1"));
|
||||
assert!(!markdown.contains("Body page 2"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_selected_page_ignores_context_only_extraction_failure() {
|
||||
let pdf = make_pdf_with_malformed_unselected_page();
|
||||
|
||||
let pages = extract_pages_markdown_mem(&pdf, Some(&[0])).unwrap();
|
||||
assert_eq!(pages.pages.len(), 1);
|
||||
assert!(pages.pages[0].markdown.contains("Selected page text"));
|
||||
|
||||
let result = process_pdf_mem_with_options(&pdf, PdfOptions::new().pages([1])).unwrap();
|
||||
let markdown = result.markdown.unwrap();
|
||||
assert!(markdown.contains("Selected page text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_requested_page_extraction_failure_remains_fatal() {
|
||||
let pdf = make_pdf_with_malformed_unselected_page();
|
||||
|
||||
assert!(extract_pages_markdown_mem(&pdf, Some(&[1])).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_pages_markdown_page_ordering() {
|
||||
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
||||
@@ -3642,6 +4273,65 @@ fn encrypted_pdf_decrypts_with_correct_password() {
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression for the #231 review finding: `extract_pages_markdown`'s
|
||||
/// `has_template_image` check must be gated the same way
|
||||
/// `classify_pdf`/`detect_pdf_type` gates it (image_count <= 1, few text
|
||||
/// ops, low alphanumeric diversity) — not treated as sufficient on its
|
||||
/// own. The fixture is a real text page with substantial, richly varied
|
||||
/// body text (>=50 Tj ops) drawn over a full-bleed background image
|
||||
/// (e.g. letterhead/watermark). Before the fix, has_template_image alone
|
||||
/// forced needs_ocr=true and discarded the page's clean markdown; now the
|
||||
/// page must extract normally.
|
||||
#[test]
|
||||
fn test_extract_pages_markdown_does_not_ocr_text_page_with_watermark_image() {
|
||||
let buf = std::fs::read("tests/fixtures/text_page_with_watermark_image.pdf").unwrap();
|
||||
|
||||
let ext = extract_pages_markdown_mem(&buf, None).expect("fixture should extract");
|
||||
let page = &ext.pages[0];
|
||||
assert!(
|
||||
!page.needs_ocr,
|
||||
"a text page with substantial real text should not be routed to OCR \
|
||||
just because it has a background image"
|
||||
);
|
||||
assert!(
|
||||
page.markdown.contains("watermark"),
|
||||
"expected the page's real body text to be preserved, got: {:?}",
|
||||
page.markdown
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression for the #231 review finding: `extract_pages_markdown` never
|
||||
/// checked `has_vector_text` at all, even though `detect_from_document`'s
|
||||
/// Mixed-type per-page routing always sends vector-outlined-text pages to
|
||||
/// OCR (outlined glyphs can't be extracted as text). A page with massive
|
||||
/// path ops (outlined decorative text) plus a short genuine caption would
|
||||
/// extract that caption cleanly — non-empty, non-garbled — so the
|
||||
/// existing empty/garbage-text checks alone couldn't catch it.
|
||||
#[test]
|
||||
fn test_extract_pages_markdown_ocrs_page_with_vector_outlined_text() {
|
||||
let buf = std::fs::read("tests/fixtures/vector_outlined_text_with_caption.pdf").unwrap();
|
||||
|
||||
let cls = pdf_inspector::detector::detect_pdf_type_mem(&buf).expect("fixture should classify");
|
||||
assert!(
|
||||
cls.pages_needing_ocr.contains(&1),
|
||||
"classify_pdf should flag page 1 as needing OCR (vector-outlined text), got: {:?}",
|
||||
cls.pages_needing_ocr
|
||||
);
|
||||
|
||||
let ext = extract_pages_markdown_mem(&buf, None).expect("fixture should extract");
|
||||
let page = &ext.pages[0];
|
||||
assert!(
|
||||
page.needs_ocr,
|
||||
"extract_pages_markdown must agree with classify_pdf that this page needs OCR"
|
||||
);
|
||||
assert!(
|
||||
page.markdown.is_empty(),
|
||||
"a page flagged needs_ocr must not return markdown as if extraction were \
|
||||
trustworthy, got: {:?}",
|
||||
page.markdown
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_options_debug_redacts_password() {
|
||||
let opts = PdfOptions::new().password("secret123");
|
||||
@@ -3652,3 +4342,60 @@ fn pdf_options_debug_redacts_password() {
|
||||
);
|
||||
assert!(dbg.contains("REDACTED"), "expected redaction marker: {dbg}");
|
||||
}
|
||||
|
||||
/// Regression for #228: a `startxref` pointer corrupted to point at the
|
||||
/// wrong byte offset (a single flipped digit — a real, common writer bug)
|
||||
/// must not make the whole file unprocessable. The real classic xref table
|
||||
/// is still present and findable by scanning for the `xref` keyword; both
|
||||
/// pypdf and pdfium recover the same way. Before this fix, every entry
|
||||
/// point raised "Invalid PDF structure" on a file whose object data was
|
||||
/// otherwise completely intact.
|
||||
#[test]
|
||||
fn test_process_pdf_recovers_corrupted_startxref_pointer() {
|
||||
let result = process_pdf_with_options(
|
||||
"tests/fixtures/broken_startxref_pointer.pdf",
|
||||
PdfOptions::new(),
|
||||
)
|
||||
.expect("a corrupted startxref pointer should be recoverable, like pypdf/pdfium");
|
||||
|
||||
assert_eq!(result.page_count, 1);
|
||||
let md = result.markdown.unwrap_or_default();
|
||||
assert!(
|
||||
md.contains("Order Detail Report by Account") && md.contains("WIDGET ASSEMBLY"),
|
||||
"recovered document should extract its real text, got: {md:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression for #227: `extract_pages_markdown`'s per-page `needs_ocr`
|
||||
/// must agree with `classify_pdf`/`detect_pdf_type` on the same page. The
|
||||
/// fixture is a full-page raster "scan" with a single line of genuine
|
||||
/// native text drawn over it (a header) — the native text extracts
|
||||
/// perfectly cleanly (no decoding issues, non-empty), so a needs_ocr
|
||||
/// computation based on text-quality signals alone says `false`, while
|
||||
/// detection correctly sees a dominant background image and says the page
|
||||
/// needs OCR. Both must now agree it needs OCR, and the markdown must not
|
||||
/// be returned as if the extraction were trustworthy.
|
||||
#[test]
|
||||
fn test_extract_pages_markdown_agrees_with_classify_on_scan_with_native_header() {
|
||||
let buf = std::fs::read("tests/fixtures/scan_with_native_header_text.pdf").unwrap();
|
||||
|
||||
let cls = pdf_inspector::detector::detect_pdf_type_mem(&buf).expect("fixture should classify");
|
||||
assert!(
|
||||
cls.pages_needing_ocr.contains(&1),
|
||||
"classify_pdf should flag page 1 as needing OCR (image-dominated), got: {:?}",
|
||||
cls.pages_needing_ocr
|
||||
);
|
||||
|
||||
let ext = extract_pages_markdown_mem(&buf, None).expect("fixture should extract");
|
||||
let page = &ext.pages[0];
|
||||
assert!(
|
||||
page.needs_ocr,
|
||||
"extract_pages_markdown must agree with classify_pdf that this page needs OCR"
|
||||
);
|
||||
assert!(
|
||||
page.markdown.is_empty(),
|
||||
"a page flagged needs_ocr must not return markdown as if extraction were \
|
||||
trustworthy, got: {:?}",
|
||||
page.markdown
|
||||
);
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ tips of directly from customers received other employees paid tips rec’d. entr
|
||||
**2.** Report total credit card tips (col. **b**) on Form 4070, line **2.**
|
||||
**3.** Report total tips paid out (col. **c**) on Form 4070, line **3.** **Page 4**
|
||||
|
||||
Form Employee’s Report (Rev. July 1996)
|
||||
Form **4070** Employee’s Report (Rev. July 1996)
|
||||
|
||||
## of Tips to EmployerOMB No. 1545-0065
|
||||
|
||||
@@ -81,4 +81,3 @@ forms simpler, we would be happy to hear from you. You can write to the Tax Form
|
||||
### Instructions (continued)
|
||||
|
||||
Use this space to total your tips for the year
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
Reprinted with corrections from *The Bell System Technical Journal,* Vol. 27, pp. 379–423, 623–656, July, October, 1948.
|
||||
|
||||
## A Mathematical Theory of Communication
|
||||
|
||||
### By C. E. SHANNON
|
||||
|
||||
INTRODUCTION
|
||||
|
||||
HE recent development of various methods of modulation such as PCM and PPM which exchange
|
||||
|
||||
# Tbandwidth for signal-to-noise ratio has intensified the interest in a general theory of communication. A
|
||||
|
||||
basis for such a theory is contained in the important papers of Nyquist¹ and Hartley² on this subject. In the present paper we will extend the theory to include a number of new factors, in particular the effect of noise in the channel, and the savings possible due to the statistical structure of the original message and due to the nature of the final destination of the information. The fundamental problem of communication is that of reproducing at one point either exactly or ap- proximately a message selected at another point. Frequently the messages have *meaning*; that is they refer to or are correlated according to some system with certain physical or conceptual entities. These semantic aspects of communication are irrelevant to the engineering problem. The significant aspect is that the actual message is one *selected from a set* of possible messages. The system must be designed to operate for each possible selection, not just the one which will actually be chosen since this is unknown at the time of design. If the number of messages in the set is finite then this number or any monotonic function of this number can be regarded as a measure of the information produced when one message is chosen from the set, all choices being equally likely. As was pointed out by Hartley the most natural choice is the logarithmic function. Although this definition must be generalized considerably when we consider the influence of the statistics of the message and when we have a continuous range of messages, we will in all cases use an essentially logarithmic measure. The logarithmic measure is more convenient for various reasons:
|
||||
|
||||
1. It is practically more useful. Parameters of engineering importance such as time, bandwidth, number of relays, etc., tend to vary linearly with the logarithm of the number of possibilities. For example, adding one relay to a group doubles the number of possible states of the relays. It adds 1 to the base 2 logarithm of this number. Doubling the time roughly squares the number of possible messages, or doubles the logarithm, etc.
|
||||
2. It is nearer to our intuitive feeling as to the proper measure. This is closely related to (1) since we in- tuitively measures entities by linear comparison with common standards. One feels, for example, that two punched cards should have twice the capacity of one for information storage, and two identical channels twice the capacity of one for transmitting information.
|
||||
3. It is mathematically more suitable. Many of the limiting operations are simple in terms of the loga- rithm but would require clumsy restatement in terms of the number of possibilities. The choice of a logarithmic base corresponds to the choice of a unit for measuring information. If the
|
||||
base 2 is used the resulting units may be called binary digits, or more briefly *bits,* a word suggested by
|
||||
|
||||
J. W. Tukey. A device with two stable positions, such as a relay or a flip-flop circuit, can store one bit of information. *N* such devices can store*N* bits, since the total number of possible states is 2
|
||||
*N* and log₂2 *N* = *N*. If the base 10 is used the units may be called decimal digits. Since
|
||||
|
||||
log₂*M* = log₁₀*M*= log₁₀2 = 3:32 log₁₀*M*;
|
||||
|
||||
1 Nyquist, H., “Certain Factors Affecting Telegraph Speed,” *Bell System Technical Journal,* April 1924, p. 324; “Certain Topics in Telegraph Transmission Theory,” *A.I.E.E. Trans.,* v. 47, April 1928, p. 617. 2 Hartley, R. V. L., “Transmission of Information,” *Bell System Technical Journal,* July 1928, p. 535.
|
||||
|
||||
INFORMATION SOURCE TRANSMITTER RECEIVER DESTINATION
|
||||
|
||||
SIGNAL RECEIVED SIGNAL MESSAGE MESSAGE
|
||||
|
||||
NOISE SOURCE
|
||||
|
||||
Fig. 1 — Schematic diagram of a general communication system.
|
||||
|
||||
a decimal digit is about 3 13 bits. A digit wheel on a desk computing machine has ten stable positions and therefore has a storage capacity of one decimal digit. In analytical work where integration and differentiation are involved the base *e* is sometimes useful. The resulting units of information will be called natural units. Change from the base *a* to base *b* merely requires multiplication by log*ba*. By a communication system we will mean a system of the type indicated schematically in Fig. 1. It consists of essentially five parts:
|
||||
|
||||
1. An *information source* which produces a message or sequence of messages to be communicated to the receiving terminal. The message may be of various types: (a) A sequence of letters as in a telegraph of teletype system; (b) A single function of time *f* (*t*) as in radio or telephony; (c) A function of time and other variables as in black and white television — here the message may be thought of as a function *f* (*x*; *y*;*t*) of two space coordinates and time, the light intensity at point (*x*; *y*) and time *t* on a pickup tube plate; (d) Two or more functions of time, say *f* (*t*), *g*(*t*), *h*(*t*) — this is the case in “three- dimensional” sound transmission or if the system is intended to service several individual channels in multiplex; (e) Several functions of several variables — in color television the message consists of three functions *f* (*x*; *y*;*t*), *g*(*x*; *y*;*t*), *h*(*x*; *y*;*t*) defined in a three-dimensional continuum — we may also think of these three functions as components of a vector field defined in the region — similarly, several black and white television sources would produce “messages” consisting of a number of functions of three variables; (f) Various combinations also occur, for example in television with an associated audio channel.
|
||||
2. A *transmitter* which operates on the message in some way to produce a signal suitable for trans- mission over the channel. In telephony this operation consists merely of changing sound pressure into a proportional electrical current. In telegraphy we have an encoding operation which produces a sequence of dots, dashes and spaces on the channel corresponding to the message. In a multiplex PCM system the different speech functions must be sampled, compressed, quantized and encoded, and finally interleaved properly to construct the signal. Vocoder systems, television and frequency modulation are other examples of complex operations applied to the message to obtain the signal.
|
||||
3. The *channel* is merely the medium used to transmit the signal from transmitter to receiver. It may be a pair of wires, a coaxial cable, a band of radio frequencies, a beam of light, etc.
|
||||
4. The *receiver* ordinarily performs the inverse operation of that done by the transmitter, reconstructing the message from the signal.
|
||||
5. The *destination* is the person (or thing) for whom the message is intended. We wish to consider certain general problems involving communication systems. To do this it is first
|
||||
necessary to represent the various elements involved as mathematical entities, suitably idealized from their
|
||||
|
||||
@@ -203,6 +203,79 @@ class TestExtractTextWithPositions:
|
||||
assert len(items) > 0
|
||||
assert all(item.page == 1 for item in items)
|
||||
|
||||
def test_mcid(self):
|
||||
# Untagged fixture: mcid is None or int, never anything else
|
||||
items = pdf_inspector.extract_text_with_positions(
|
||||
fixture_path("thermo-freon12.pdf")
|
||||
)
|
||||
assert all(item.mcid is None or isinstance(item.mcid, int) for item in items)
|
||||
# Tagged fixture: marked content carries MCIDs
|
||||
tagged = pdf_inspector.extract_text_with_positions(
|
||||
fixture_path("firecrawl_docs_tagged.pdf")
|
||||
)
|
||||
assert any(item.mcid is not None for item in tagged)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_structure_elements / extract_structure_elements_bytes
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractStructureElements:
|
||||
def test_tagged_file(self):
|
||||
elements = pdf_inspector.extract_structure_elements(
|
||||
fixture_path("firecrawl_docs_tagged.pdf")
|
||||
)
|
||||
assert len(elements) > 0
|
||||
assert all(isinstance(e.page, int) for e in elements)
|
||||
assert all(isinstance(e.mcid, int) for e in elements)
|
||||
assert all(isinstance(e.role, str) and len(e.role) > 0 for e in elements)
|
||||
assert any(e.role == "H1" for e in elements)
|
||||
|
||||
def test_join_with_text_items(self):
|
||||
# (page, mcid) joins against extract_text_with_positions to recover
|
||||
# heading text
|
||||
path = fixture_path("firecrawl_docs_tagged.pdf")
|
||||
elements = pdf_inspector.extract_structure_elements(path)
|
||||
items = pdf_inspector.extract_text_with_positions(path)
|
||||
h1_refs = {(e.page, e.mcid) for e in elements if e.role == "H1"}
|
||||
h1_text = "".join(
|
||||
item.text
|
||||
for item in items
|
||||
if item.mcid is not None and (item.page, item.mcid) in h1_refs
|
||||
)
|
||||
assert len(h1_text.strip()) > 0
|
||||
|
||||
def test_with_pages(self):
|
||||
# pages filter is 1-indexed, matching TextItem.page
|
||||
elements = pdf_inspector.extract_structure_elements(
|
||||
fixture_path("firecrawl_docs_tagged.pdf"), pages=[1]
|
||||
)
|
||||
assert len(elements) > 0
|
||||
assert all(e.page == 1 for e in elements)
|
||||
|
||||
def test_bytes(self):
|
||||
data = fixture_bytes("firecrawl_docs_tagged.pdf")
|
||||
elements = pdf_inspector.extract_structure_elements_bytes(data)
|
||||
assert len(elements) > 0
|
||||
assert any(e.role == "H1" for e in elements)
|
||||
|
||||
def test_untagged_returns_empty(self):
|
||||
elements = pdf_inspector.extract_structure_elements(
|
||||
fixture_path("thermo-freon12.pdf")
|
||||
)
|
||||
assert elements == []
|
||||
|
||||
def test_repr(self):
|
||||
elements = pdf_inspector.extract_structure_elements(
|
||||
fixture_path("firecrawl_docs_tagged.pdf")
|
||||
)
|
||||
assert "StructureElement" in repr(elements[0])
|
||||
|
||||
def test_not_a_pdf(self):
|
||||
with pytest.raises(ValueError):
|
||||
pdf_inspector.extract_structure_elements_bytes(b"not a pdf")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_text_in_regions / extract_text_in_regions_bytes
|
||||
|
||||
Generated
+1304
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,37 @@
|
||||
[package]
|
||||
name = "pdf-inspector-wasm"
|
||||
version = "1.14.2"
|
||||
edition = "2021"
|
||||
authors = ["Firecrawl Team"]
|
||||
description = "Browser WebAssembly bindings for pdf-inspector"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/firecrawl/pdf-inspector"
|
||||
homepage = "https://github.com/firecrawl/pdf-inspector"
|
||||
readme = "README.md"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib", "rlib"]
|
||||
|
||||
[dependencies]
|
||||
console_error_panic_hook = "0.1"
|
||||
js-sys = "0.3"
|
||||
pdf-inspector = { path = ".." }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde-wasm-bindgen = "0.6"
|
||||
wasm-bindgen = "0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
wasm-bindgen-test = "0.3"
|
||||
|
||||
[profile.release]
|
||||
codegen-units = 1
|
||||
lto = true
|
||||
opt-level = "s"
|
||||
strip = true
|
||||
|
||||
[package.metadata.wasm-pack.profile.release]
|
||||
# Rust 1.95 emits bulk-memory instructions that the binaryen bundled with
|
||||
# wasm-pack 0.15.0 does not yet validate. rustc still performs the release,
|
||||
# size, and LTO optimizations above.
|
||||
wasm-opt = false
|
||||
@@ -0,0 +1,58 @@
|
||||
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.
|
||||
|
||||
Third-party notices
|
||||
===================
|
||||
|
||||
Adobe CMaps
|
||||
-----------
|
||||
|
||||
The WebAssembly binary embeds binary CMaps derived from Adobe CMap resources.
|
||||
|
||||
Copyright 1990-2009 Adobe Systems Incorporated.
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
Neither the name of Adobe Systems Incorporated nor the names of its
|
||||
contributors may be used to endorse or promote products derived from this
|
||||
software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
||||
ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
||||
LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
||||
CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
||||
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
||||
INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
||||
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
||||
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
||||
POSSIBILITY OF SUCH DAMAGE.
|
||||
@@ -0,0 +1,60 @@
|
||||
# @firecrawl/pdf-inspector-wasm
|
||||
|
||||
Browser WebAssembly bindings for [pdf-inspector](https://github.com/firecrawl/pdf-inspector). Classify PDFs and extract structured Markdown locally from a `Uint8Array`, using the same Rust core as the native Node.js, Python, and Rust packages.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm install @firecrawl/pdf-inspector-wasm
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import init, { processPdf } from "@firecrawl/pdf-inspector-wasm";
|
||||
|
||||
await init();
|
||||
|
||||
const response = await fetch("/annual-report.pdf");
|
||||
const pdf = new Uint8Array(await response.arrayBuffer());
|
||||
const result = processPdf(pdf);
|
||||
|
||||
console.log(result.pdfType);
|
||||
console.log(result.markdown);
|
||||
```
|
||||
|
||||
Pass options when you need selected pages or compact Markdown:
|
||||
|
||||
```ts
|
||||
const result = processPdf(pdf, {
|
||||
pages: [1, 3, 5],
|
||||
profile: "compact",
|
||||
includePageMarkers: true,
|
||||
});
|
||||
```
|
||||
|
||||
The package also exports:
|
||||
|
||||
- `detectPdf(pdf, options?)` for detection without extraction.
|
||||
- `classifyPdf(pdf)` for the lightweight result shape shared with the native Node.js API.
|
||||
- `extractText(pdf)` for plain text.
|
||||
- `version()` for the WASM package version.
|
||||
|
||||
## Browser behavior
|
||||
|
||||
- Parsing runs locally. PDF bytes are not uploaded anywhere.
|
||||
- The build is single-threaded and does not require cross-origin isolation.
|
||||
- CMaps are embedded so CJK font decoding does not depend on a filesystem.
|
||||
- Extraction is synchronous after `init()`. For large documents, call it from a Web Worker to keep the UI responsive.
|
||||
- Image-only documents still require a separate OCR step.
|
||||
|
||||
## Build from source
|
||||
|
||||
```bash
|
||||
cargo install wasm-pack --version 0.15.0 --locked
|
||||
wasm-pack build wasm --target web --scope firecrawl --release
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
+440
@@ -0,0 +1,440 @@
|
||||
use pdf_inspector::{
|
||||
LayoutComplexity, MarkdownProfile, PageOcrReasons, PdfOptions, PdfProcessResult, PdfType,
|
||||
ProcessMode,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
#[wasm_bindgen(typescript_custom_section)]
|
||||
const TYPESCRIPT_TYPES: &str = r#"
|
||||
export type PdfType = "TextBased" | "Scanned" | "ImageBased" | "Mixed";
|
||||
export type MarkdownProfile = "fidelity" | "compact";
|
||||
|
||||
export interface ProcessOptions {
|
||||
/** Restrict extraction to these 1-indexed page numbers. */
|
||||
pages?: number[];
|
||||
/** Password for an encrypted PDF. */
|
||||
password?: string;
|
||||
/** Source-faithful output by default, or compact output for fewer tokens. */
|
||||
profile?: MarkdownProfile;
|
||||
/** Insert `<!-- Page N -->` markers between pages. */
|
||||
includePageMarkers?: boolean;
|
||||
/** Include image placeholders in Markdown output. */
|
||||
includeImages?: boolean;
|
||||
}
|
||||
|
||||
export interface PageOcrReasons {
|
||||
/** 1-indexed page number. */
|
||||
page: number;
|
||||
reasons: string[];
|
||||
}
|
||||
|
||||
export interface LayoutComplexity {
|
||||
isComplex: boolean;
|
||||
/** 1-indexed page numbers. */
|
||||
pagesWithTables: number[];
|
||||
/** 1-indexed page numbers. */
|
||||
pagesWithColumns: number[];
|
||||
}
|
||||
|
||||
export interface PdfProcessResult {
|
||||
pdfType: PdfType;
|
||||
markdown?: string;
|
||||
pageCount: number;
|
||||
processingTimeMs: number;
|
||||
/** 1-indexed page numbers. */
|
||||
pagesNeedingOcr: number[];
|
||||
ocrReasonsByPage: PageOcrReasons[];
|
||||
title?: string;
|
||||
confidence: number;
|
||||
layout: LayoutComplexity;
|
||||
hasEncodingIssues: boolean;
|
||||
}
|
||||
|
||||
export interface PdfClassification {
|
||||
pdfType: PdfType;
|
||||
pageCount: number;
|
||||
/** 0-indexed page numbers, matching the native Node.js API. */
|
||||
pagesNeedingOcr: number[];
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export function processPdf(data: Uint8Array, options?: ProcessOptions): PdfProcessResult;
|
||||
export function detectPdf(data: Uint8Array, options?: Pick<ProcessOptions, "password">): PdfProcessResult;
|
||||
export function classifyPdf(data: Uint8Array): PdfClassification;
|
||||
export function extractText(data: Uint8Array): string;
|
||||
export function version(): string;
|
||||
"#;
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(default, rename_all = "camelCase", deny_unknown_fields)]
|
||||
struct WasmProcessOptions {
|
||||
pages: Option<Vec<u32>>,
|
||||
password: Option<String>,
|
||||
profile: Option<WasmMarkdownProfile>,
|
||||
include_page_markers: Option<bool>,
|
||||
include_images: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
enum WasmMarkdownProfile {
|
||||
Fidelity,
|
||||
Compact,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WasmPageOcrReasons {
|
||||
page: u32,
|
||||
reasons: Vec<String>,
|
||||
}
|
||||
|
||||
impl From<PageOcrReasons> for WasmPageOcrReasons {
|
||||
fn from(value: PageOcrReasons) -> Self {
|
||||
Self {
|
||||
page: value.page,
|
||||
reasons: value.reasons,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WasmLayoutComplexity {
|
||||
is_complex: bool,
|
||||
pages_with_tables: Vec<u32>,
|
||||
pages_with_columns: Vec<u32>,
|
||||
}
|
||||
|
||||
impl From<LayoutComplexity> for WasmLayoutComplexity {
|
||||
fn from(value: LayoutComplexity) -> Self {
|
||||
Self {
|
||||
is_complex: value.is_complex,
|
||||
pages_with_tables: value.pages_with_tables,
|
||||
pages_with_columns: value.pages_with_columns,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WasmPdfProcessResult {
|
||||
pdf_type: &'static str,
|
||||
markdown: Option<String>,
|
||||
page_count: u32,
|
||||
processing_time_ms: f64,
|
||||
pages_needing_ocr: Vec<u32>,
|
||||
ocr_reasons_by_page: Vec<WasmPageOcrReasons>,
|
||||
title: Option<String>,
|
||||
confidence: f64,
|
||||
layout: WasmLayoutComplexity,
|
||||
has_encoding_issues: bool,
|
||||
}
|
||||
|
||||
impl From<PdfProcessResult> for WasmPdfProcessResult {
|
||||
fn from(value: PdfProcessResult) -> Self {
|
||||
Self {
|
||||
pdf_type: pdf_type_name(value.pdf_type),
|
||||
markdown: value.markdown,
|
||||
page_count: value.page_count,
|
||||
processing_time_ms: value.processing_time_ms as f64,
|
||||
pages_needing_ocr: value.pages_needing_ocr,
|
||||
ocr_reasons_by_page: value
|
||||
.ocr_reasons_by_page
|
||||
.into_iter()
|
||||
.map(Into::into)
|
||||
.collect(),
|
||||
title: value.title,
|
||||
confidence: value.confidence as f64,
|
||||
layout: value.layout.into(),
|
||||
has_encoding_issues: value.has_encoding_issues,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct WasmPdfClassification {
|
||||
pdf_type: &'static str,
|
||||
page_count: u32,
|
||||
pages_needing_ocr: Vec<u32>,
|
||||
confidence: f64,
|
||||
}
|
||||
|
||||
fn pdf_type_name(pdf_type: PdfType) -> &'static str {
|
||||
match pdf_type {
|
||||
PdfType::TextBased => "TextBased",
|
||||
PdfType::Scanned => "Scanned",
|
||||
PdfType::ImageBased => "ImageBased",
|
||||
PdfType::Mixed => "Mixed",
|
||||
}
|
||||
}
|
||||
|
||||
fn js_error(context: &str, error: impl std::fmt::Display) -> JsValue {
|
||||
js_sys::Error::new(&format!("{context}: {error}")).into()
|
||||
}
|
||||
|
||||
fn deserialize_options(value: JsValue) -> Result<WasmProcessOptions, JsValue> {
|
||||
if value.is_undefined() || value.is_null() {
|
||||
return Ok(WasmProcessOptions::default());
|
||||
}
|
||||
|
||||
serde_wasm_bindgen::from_value(value).map_err(|error| js_error("invalid options", error))
|
||||
}
|
||||
|
||||
fn build_options(value: JsValue, mode: ProcessMode) -> Result<PdfOptions, JsValue> {
|
||||
let options = deserialize_options(value)?;
|
||||
if options
|
||||
.pages
|
||||
.as_ref()
|
||||
.is_some_and(|pages| pages.contains(&0))
|
||||
{
|
||||
return Err(js_error(
|
||||
"invalid options",
|
||||
"pages are 1-indexed; page 0 is invalid",
|
||||
));
|
||||
}
|
||||
|
||||
let mut result = PdfOptions::new().mode(mode);
|
||||
if let Some(pages) = options.pages {
|
||||
result = result.pages(pages);
|
||||
}
|
||||
if let Some(password) = options.password {
|
||||
result = result.password(password);
|
||||
}
|
||||
if let Some(profile) = options.profile {
|
||||
result.markdown.profile = match profile {
|
||||
WasmMarkdownProfile::Fidelity => MarkdownProfile::Fidelity,
|
||||
WasmMarkdownProfile::Compact => MarkdownProfile::Compact,
|
||||
};
|
||||
}
|
||||
if let Some(include_page_markers) = options.include_page_markers {
|
||||
result.markdown.include_page_numbers = include_page_markers;
|
||||
}
|
||||
if let Some(include_images) = options.include_images {
|
||||
result.markdown.include_images = include_images;
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn serialize<T: Serialize>(value: &T) -> Result<JsValue, JsValue> {
|
||||
serde_wasm_bindgen::to_value(value).map_err(|error| js_error("serialize result", error))
|
||||
}
|
||||
|
||||
fn initialize() {
|
||||
console_error_panic_hook::set_once();
|
||||
}
|
||||
|
||||
/// Process PDF bytes entirely inside WebAssembly.
|
||||
#[wasm_bindgen(js_name = processPdf, skip_typescript)]
|
||||
pub fn process_pdf(data: &[u8], options: JsValue) -> Result<JsValue, JsValue> {
|
||||
initialize();
|
||||
let options = build_options(options, ProcessMode::Full)?;
|
||||
let started = js_sys::Date::now();
|
||||
let mut result = pdf_inspector::process_pdf_mem_with_options(data, options)
|
||||
.map_err(|error| js_error("process PDF", error))?;
|
||||
result.processing_time_ms = (js_sys::Date::now() - started).max(0.0) as u64;
|
||||
serialize(&WasmPdfProcessResult::from(result))
|
||||
}
|
||||
|
||||
/// Classify PDF bytes without extracting text or producing Markdown.
|
||||
#[wasm_bindgen(js_name = detectPdf, skip_typescript)]
|
||||
pub fn detect_pdf(data: &[u8], options: JsValue) -> Result<JsValue, JsValue> {
|
||||
initialize();
|
||||
let options = build_options(options, ProcessMode::DetectOnly)?;
|
||||
let started = js_sys::Date::now();
|
||||
let mut result = pdf_inspector::process_pdf_mem_with_options(data, options)
|
||||
.map_err(|error| js_error("detect PDF", error))?;
|
||||
result.processing_time_ms = (js_sys::Date::now() - started).max(0.0) as u64;
|
||||
serialize(&WasmPdfProcessResult::from(result))
|
||||
}
|
||||
|
||||
/// Return the lightweight classification shape used by the native Node API.
|
||||
#[wasm_bindgen(js_name = classifyPdf, skip_typescript)]
|
||||
pub fn classify_pdf(data: &[u8]) -> Result<JsValue, JsValue> {
|
||||
initialize();
|
||||
let result =
|
||||
pdf_inspector::classify_pdf_mem(data).map_err(|error| js_error("classify PDF", error))?;
|
||||
serialize(&WasmPdfClassification {
|
||||
pdf_type: pdf_type_name(result.pdf_type),
|
||||
page_count: result.page_count,
|
||||
pages_needing_ocr: result.pages_needing_ocr,
|
||||
confidence: result.confidence as f64,
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract plain text from PDF bytes without Markdown conversion.
|
||||
#[wasm_bindgen(js_name = extractText, skip_typescript)]
|
||||
pub fn extract_text(data: &[u8]) -> Result<String, JsValue> {
|
||||
initialize();
|
||||
let items = pdf_inspector::extractor::extract_text_with_positions_mem(data)
|
||||
.map_err(|error| js_error("extract text", error))?;
|
||||
Ok(
|
||||
pdf_inspector::extractor::group_into_lines_preserving_all_text(items)
|
||||
.into_iter()
|
||||
.map(|line| line.text())
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Return the WebAssembly package version.
|
||||
#[wasm_bindgen(skip_typescript)]
|
||||
pub fn version() -> String {
|
||||
env!("CARGO_PKG_VERSION").to_string()
|
||||
}
|
||||
|
||||
#[cfg(all(test, target_arch = "wasm32"))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use js_sys::Reflect;
|
||||
use wasm_bindgen_test::*;
|
||||
|
||||
const TEXT_PDF: &[u8] = include_bytes!("../../tests/fixtures/thermo-freon12.pdf");
|
||||
const ENCRYPTED_PDF: &[u8] = include_bytes!("../../tests/fixtures/encrypted-secret123.pdf");
|
||||
|
||||
fn synthetic_korea1_pdf() -> Vec<u8> {
|
||||
let mut pdf = b"%PDF-1.4\n".to_vec();
|
||||
let mut offsets = vec![0usize];
|
||||
|
||||
fn add_object(pdf: &mut Vec<u8>, offsets: &mut Vec<usize>, id: usize, body: &str) {
|
||||
offsets.push(pdf.len());
|
||||
pdf.extend_from_slice(format!("{id} 0 obj\n").as_bytes());
|
||||
pdf.extend_from_slice(body.as_bytes());
|
||||
pdf.extend_from_slice(b"\nendobj\n");
|
||||
}
|
||||
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
1,
|
||||
"<< /Type /Catalog /Pages 2 0 R >>",
|
||||
);
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
2,
|
||||
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
|
||||
);
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
3,
|
||||
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 200 200] /Resources << /Font << /F0 5 0 R >> >> /Contents 4 0 R >>",
|
||||
);
|
||||
|
||||
// Adobe-Korea1 CID 1086 (0x043E) maps to U+AC00 (Korean syllable GA).
|
||||
// There is deliberately no ToUnicode stream: decoding must use the
|
||||
// embedded predefined CMap rather than lopdf's plain-text fallback.
|
||||
// Korea1 CIDs 21 and 19 map to ASCII "4" and "2". Place them near
|
||||
// the bottom edge so they look exactly like a numeric page footer.
|
||||
let content = "BT /F0 12 Tf 50 100 Td <043E> Tj 0 -60 Td <00150013> Tj ET";
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
4,
|
||||
&format!(
|
||||
"<< /Length {} >>\nstream\n{}\nendstream",
|
||||
content.len(),
|
||||
content
|
||||
),
|
||||
);
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
5,
|
||||
"<< /Type /Font /Subtype /Type0 /BaseFont /SyntheticKorea1 /Encoding /Identity-H /DescendantFonts [6 0 R] >>",
|
||||
);
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
6,
|
||||
"<< /Type /Font /Subtype /CIDFontType2 /BaseFont /SyntheticKorea1 /CIDSystemInfo << /Registry (Adobe) /Ordering (Korea1) /Supplement 2 >> /FontDescriptor 7 0 R /DW 1000 >>",
|
||||
);
|
||||
add_object(
|
||||
&mut pdf,
|
||||
&mut offsets,
|
||||
7,
|
||||
"<< /Type /FontDescriptor /FontName /SyntheticKorea1 /Flags 4 /FontBBox [-100 -200 1000 900] /ItalicAngle 0 /Ascent 800 /Descent -200 /CapHeight 700 /StemV 80 >>",
|
||||
);
|
||||
|
||||
let xref_start = pdf.len();
|
||||
pdf.extend_from_slice(format!("xref\n0 {}\n", offsets.len()).as_bytes());
|
||||
pdf.extend_from_slice(b"0000000000 65535 f \n");
|
||||
for offset in offsets.iter().skip(1) {
|
||||
pdf.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes());
|
||||
}
|
||||
pdf.extend_from_slice(
|
||||
format!(
|
||||
"trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{}\n%%EOF",
|
||||
offsets.len(),
|
||||
xref_start
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
pdf
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn processes_pdf_to_markdown() {
|
||||
let result = process_pdf(TEXT_PDF, JsValue::UNDEFINED).expect("process PDF");
|
||||
let pdf_type = Reflect::get(&result, &JsValue::from_str("pdfType"))
|
||||
.expect("pdfType")
|
||||
.as_string()
|
||||
.expect("pdfType string");
|
||||
let markdown = Reflect::get(&result, &JsValue::from_str("markdown"))
|
||||
.expect("markdown")
|
||||
.as_string()
|
||||
.expect("markdown string");
|
||||
|
||||
assert_eq!(pdf_type, "TextBased");
|
||||
assert!(!markdown.is_empty());
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn rejects_non_pdf_bytes() {
|
||||
assert!(process_pdf(b"not a PDF", JsValue::UNDEFINED).is_err());
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn classifies_and_extracts_plain_text() {
|
||||
let classification = classify_pdf(TEXT_PDF).expect("classify PDF");
|
||||
let pdf_type = Reflect::get(&classification, &JsValue::from_str("pdfType"))
|
||||
.expect("pdfType")
|
||||
.as_string()
|
||||
.expect("pdfType string");
|
||||
let text = extract_text(TEXT_PDF).expect("extract text");
|
||||
|
||||
assert_eq!(pdf_type, "TextBased");
|
||||
assert!(!text.is_empty());
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn extracts_cjk_and_preserves_numeric_page_footer() {
|
||||
let text = extract_text(&synthetic_korea1_pdf()).expect("extract predefined CMap text");
|
||||
|
||||
assert_eq!(text, "가\n42");
|
||||
}
|
||||
|
||||
#[wasm_bindgen_test]
|
||||
fn opens_encrypted_pdf_with_password() {
|
||||
assert!(process_pdf(ENCRYPTED_PDF, JsValue::UNDEFINED).is_err());
|
||||
|
||||
let options = js_sys::Object::new();
|
||||
Reflect::set(
|
||||
&options,
|
||||
&JsValue::from_str("password"),
|
||||
&JsValue::from_str("secret123"),
|
||||
)
|
||||
.expect("set password");
|
||||
let result = process_pdf(ENCRYPTED_PDF, options.into()).expect("process encrypted PDF");
|
||||
let markdown = Reflect::get(&result, &JsValue::from_str("markdown"))
|
||||
.expect("markdown")
|
||||
.as_string()
|
||||
.expect("markdown string");
|
||||
|
||||
assert!(!markdown.is_empty());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user