Compare commits
54
Commits
@@ -25,6 +25,9 @@ jobs:
|
||||
- name: Run tests
|
||||
run: cargo test --verbose
|
||||
|
||||
- name: Test developer scripts
|
||||
run: python3 -m unittest discover -s scripts/tests
|
||||
|
||||
fmt:
|
||||
name: Format
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
name: Deploy landing page
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['site/**', '.github/workflows/pages.yml']
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
# Allow one concurrent deployment; don't cancel an in-progress production deploy.
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
name: Build & deploy to GitHub Pages
|
||||
runs-on: ubuntu-latest
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deploy.outputs.page_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Upload site artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: site
|
||||
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deploy
|
||||
uses: actions/deploy-pages@v4
|
||||
@@ -4,6 +4,9 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['Cargo.toml']
|
||||
# Manual fallback: retry a publish that failed after the version was
|
||||
# already merged (a plain re-push won't register as a version change).
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -14,6 +17,10 @@ env:
|
||||
jobs:
|
||||
check-version:
|
||||
name: Check version change
|
||||
# Guard manual dispatches: crates.io trusted publishing matches
|
||||
# repo+workflow+environment but NOT branch, so without this a
|
||||
# workflow_dispatch from any branch could publish unmerged code.
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
changed: ${{ steps.check.outputs.changed }}
|
||||
@@ -28,17 +35,26 @@ jobs:
|
||||
id: check
|
||||
run: |
|
||||
NEW_VERSION=$(python3 -c 'import pathlib, tomllib; print(tomllib.loads(pathlib.Path("Cargo.toml").read_text())["package"]["version"])')
|
||||
OLD_VERSION=$(git show HEAD~1:Cargo.toml | python3 -c 'import sys, tomllib; print(tomllib.loads(sys.stdin.read())["package"]["version"])')
|
||||
echo "old=$OLD_VERSION new=$NEW_VERSION"
|
||||
echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ "$NEW_VERSION" = "$OLD_VERSION" ]; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "published=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
# Manual dispatch publishes the current version regardless of the
|
||||
# previous commit; the crates.io check below still prevents
|
||||
# double-publishing an already-released version.
|
||||
echo "manual dispatch: publishing v$NEW_VERSION"
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
OLD_VERSION=$(git show HEAD~1:Cargo.toml | python3 -c 'import sys, tomllib; print(tomllib.loads(sys.stdin.read())["package"]["version"])')
|
||||
echo "old=$OLD_VERSION new=$NEW_VERSION"
|
||||
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
if [ "$NEW_VERSION" = "$OLD_VERSION" ]; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "published=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
HTTP_STATUS=$(curl --silent --show-error --output /tmp/crate-version.json --write-out "%{http_code}" \
|
||||
-H "User-Agent: firecrawl/pdf-inspector publish workflow (https://github.com/firecrawl/pdf-inspector)" \
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
name: Publish Python package
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['pyproject.toml']
|
||||
# Manual fallback: re-publish the current version without a version bump
|
||||
# (e.g. first run after PyPI trusted publishing is configured).
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-version:
|
||||
name: Check version change
|
||||
# Guard manual dispatches too: PyPI trusted publishing matches
|
||||
# repo+workflow+environment but NOT branch, so without this a
|
||||
# workflow_dispatch from any branch could publish unmerged code.
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
changed: ${{ steps.check.outputs.changed }}
|
||||
published: ${{ steps.check.outputs.published }}
|
||||
version: ${{ steps.check.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: Check if version changed
|
||||
id: check
|
||||
run: |
|
||||
NEW_VERSION=$(python3 -c 'import pathlib, tomllib; print(tomllib.loads(pathlib.Path("pyproject.toml").read_text())["project"]["version"])')
|
||||
echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
# Manual dispatch always rebuilds and publishes. Combined with
|
||||
# skip-existing on the publish step, this repairs partial releases
|
||||
# (PyPI's version endpoint returns 200 even when only some of the
|
||||
# expected wheels were uploaded).
|
||||
echo "manual dispatch: publishing v$NEW_VERSION (skip-existing handles uploaded files)"
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "published=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# .get(): the parent commit may predate the static version field
|
||||
# (pyproject.toml used dynamic = ["version"]) — treat that as a change
|
||||
# so the very first merge of this workflow publishes.
|
||||
OLD_VERSION=$(git show HEAD~1:pyproject.toml | python3 -c 'import sys, tomllib; print(tomllib.loads(sys.stdin.read())["project"].get("version", ""))')
|
||||
echo "old=$OLD_VERSION new=$NEW_VERSION"
|
||||
|
||||
if [ "$NEW_VERSION" = "$OLD_VERSION" ]; then
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
echo "published=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
|
||||
HTTP_STATUS=$(curl --silent --show-error --output /tmp/pypi-version.json --write-out "%{http_code}" \
|
||||
"https://pypi.org/pypi/pdf-inspector/$NEW_VERSION/json")
|
||||
|
||||
case "$HTTP_STATUS" in
|
||||
200)
|
||||
echo "published=true" >> "$GITHUB_OUTPUT"
|
||||
echo "pdf-inspector v$NEW_VERSION is already published to PyPI"
|
||||
;;
|
||||
404)
|
||||
echo "published=false" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
cat /tmp/pypi-version.json
|
||||
echo "Unexpected PyPI response: $HTTP_STATUS" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
build:
|
||||
needs: check-version
|
||||
if: needs.check-version.outputs.changed == 'true' && needs.check-version.outputs.published == 'false'
|
||||
name: Build ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
target: x86_64-unknown-linux-gnu
|
||||
- os: ubuntu-latest
|
||||
target: aarch64-unknown-linux-gnu
|
||||
# macos-13 was retired by GitHub; macos-15-intel is the remaining
|
||||
# Intel runner label (available through 2027).
|
||||
- os: macos-15-intel
|
||||
target: x86_64-apple-darwin
|
||||
- os: macos-14
|
||||
target: aarch64-apple-darwin
|
||||
- os: windows-latest
|
||||
target: x86_64-pc-windows-msvc
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.12'
|
||||
|
||||
- name: Build wheel
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
target: ${{ matrix.target }}
|
||||
args: --release --out dist
|
||||
manylinux: auto
|
||||
|
||||
- name: Upload wheel
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: wheels-${{ matrix.target }}
|
||||
path: dist/*.whl
|
||||
if-no-files-found: error
|
||||
|
||||
sdist:
|
||||
needs: check-version
|
||||
if: needs.check-version.outputs.changed == 'true' && needs.check-version.outputs.published == 'false'
|
||||
name: Build sdist
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Build sdist
|
||||
uses: PyO3/maturin-action@v1
|
||||
with:
|
||||
command: sdist
|
||||
args: --out dist
|
||||
|
||||
- name: Upload sdist
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: sdist
|
||||
path: dist/*.tar.gz
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: Publish to PyPI
|
||||
needs: [check-version, build, sdist]
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: dist
|
||||
merge-multiple: true
|
||||
|
||||
- name: List artifacts
|
||||
run: ls -la dist/
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
packages-dir: dist
|
||||
# Tolerate already-uploaded files so a manual re-run can complete
|
||||
# a release that previously failed partway through.
|
||||
skip-existing: true
|
||||
@@ -4,6 +4,9 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths: ['napi/package.json']
|
||||
# Manual fallback: retry a publish that failed partway (per-package
|
||||
# already-published checks make re-runs idempotent).
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -12,6 +15,10 @@ permissions:
|
||||
jobs:
|
||||
check-version:
|
||||
name: Check version change
|
||||
# Guard manual dispatches: npm trusted publishing matches
|
||||
# repo+workflow+environment but NOT branch, so without this a
|
||||
# workflow_dispatch from any branch could publish unmerged code.
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
changed: ${{ steps.check.outputs.changed }}
|
||||
@@ -25,11 +32,21 @@ jobs:
|
||||
id: check
|
||||
run: |
|
||||
NEW_VERSION=$(node -p "require('./napi/package.json').version")
|
||||
echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
# Manual dispatch rebuilds and publishes the current version; the
|
||||
# per-package already-published checks in the publish job skip
|
||||
# anything that made it out in a previous partial run.
|
||||
echo "manual dispatch: publishing v$NEW_VERSION"
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
OLD_VERSION=$(git show HEAD~1:napi/package.json | node -p "JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')).version")
|
||||
echo "old=$OLD_VERSION new=$NEW_VERSION"
|
||||
if [ "$NEW_VERSION" != "$OLD_VERSION" ]; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
@@ -115,14 +132,81 @@ jobs:
|
||||
with:
|
||||
path: napi/artifacts
|
||||
|
||||
- name: Collect binaries and publish
|
||||
- name: Publish platform packages
|
||||
working-directory: napi
|
||||
run: |
|
||||
cp artifacts/bindings-*/*.node .
|
||||
VERSION="${{ needs.check-version.outputs.version }}"
|
||||
|
||||
for node_file in artifacts/bindings-*/pdf-inspector.*.node; do
|
||||
base=$(basename "$node_file")
|
||||
suffix=${base#pdf-inspector.}
|
||||
suffix=${suffix%.node}
|
||||
pkg="@firecrawl/pdf-inspector-$suffix"
|
||||
|
||||
if npm view "$pkg@$VERSION" version >/dev/null 2>&1; then
|
||||
echo "$pkg@$VERSION already published — skipping"
|
||||
continue
|
||||
fi
|
||||
|
||||
dir="npm-dist/$suffix"
|
||||
mkdir -p "$dir"
|
||||
cp "$node_file" "$dir/"
|
||||
node -e '
|
||||
const [suffix, version] = process.argv.slice(1)
|
||||
const meta = {
|
||||
"linux-x64-gnu": { os: ["linux"], cpu: ["x64"], libc: ["glibc"] },
|
||||
"darwin-arm64": { os: ["darwin"], cpu: ["arm64"] },
|
||||
"win32-x64-msvc": { os: ["win32"], cpu: ["x64"] },
|
||||
}[suffix]
|
||||
if (!meta) {
|
||||
console.error(`unknown platform suffix: ${suffix} — add it to the meta map`)
|
||||
process.exit(1)
|
||||
}
|
||||
const pkg = {
|
||||
name: `@firecrawl/pdf-inspector-${suffix}`,
|
||||
version,
|
||||
description: `Prebuilt ${suffix} binary for @firecrawl/pdf-inspector`,
|
||||
main: `pdf-inspector.${suffix}.node`,
|
||||
files: [`pdf-inspector.${suffix}.node`],
|
||||
license: "MIT",
|
||||
engines: { node: ">= 10" },
|
||||
repository: { type: "git", url: "https://github.com/firecrawl/pdf-inspector" },
|
||||
publishConfig: { access: "public" },
|
||||
...meta,
|
||||
}
|
||||
require("fs").writeFileSync(`npm-dist/${suffix}/package.json`, JSON.stringify(pkg, null, 2) + "\n")
|
||||
' "$suffix" "$VERSION"
|
||||
|
||||
echo "=== $pkg@$VERSION ==="
|
||||
ls -la "$dir"
|
||||
(cd "$dir" && npm publish --provenance --access public)
|
||||
done
|
||||
|
||||
- name: Publish main package
|
||||
working-directory: napi
|
||||
run: |
|
||||
VERSION="${{ needs.check-version.outputs.version }}"
|
||||
|
||||
if npm view "@firecrawl/pdf-inspector@$VERSION" version >/dev/null 2>&1; then
|
||||
echo "@firecrawl/pdf-inspector@$VERSION already published — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cp artifacts/js-bindings/index.js .
|
||||
cp artifacts/js-bindings/index.d.ts .
|
||||
|
||||
echo "=== Package contents ==="
|
||||
ls -la *.node index.js index.d.ts
|
||||
# Stamp optionalDependencies to this exact version so the platform
|
||||
# pins can never drift from the main package version.
|
||||
node -e '
|
||||
const fs = require("fs")
|
||||
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"))
|
||||
for (const dep of Object.keys(pkg.optionalDependencies ?? {})) {
|
||||
pkg.optionalDependencies[dep] = pkg.version
|
||||
}
|
||||
fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2) + "\n")
|
||||
'
|
||||
|
||||
echo "=== Main package contents ==="
|
||||
npm pack --dry-run
|
||||
|
||||
npm publish --provenance --access public
|
||||
|
||||
+15
-2
@@ -1,12 +1,25 @@
|
||||
[package]
|
||||
name = "pdf-inspector"
|
||||
version = "0.1.4"
|
||||
version = "0.1.6"
|
||||
edition = "2021"
|
||||
autobins = false
|
||||
authors = ["Firecrawl Team"]
|
||||
description = "Fast PDF inspection, classification, and text extraction with smart scanned vs text-based detection"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/firecrawl/pdf-inspector"
|
||||
readme = "docs/rust-api.md"
|
||||
# Explicit allowlist: crates.io caps uploads at 10 MiB and tests/fixtures
|
||||
# alone exceeds that. external/bcmaps ships in the crate — tounicode.rs
|
||||
# loads it at runtime relative to CARGO_MANIFEST_DIR.
|
||||
include = [
|
||||
"src/**",
|
||||
"external/bcmaps/**",
|
||||
"docs/rust-api.md",
|
||||
"LICENSE",
|
||||
# maturin derives the sdist file list from this allowlist; the stub must
|
||||
# ship so wheels built from the sdist keep their type hints.
|
||||
"pdf_inspector.pyi",
|
||||
]
|
||||
|
||||
[lib]
|
||||
name = "pdf_inspector"
|
||||
@@ -14,7 +27,7 @@ crate-type = ["lib", "cdylib"]
|
||||
|
||||
[dependencies]
|
||||
# Python bindings
|
||||
pyo3 = { version = "0.25", features = ["extension-module"], optional = true }
|
||||
pyo3 = { version = "0.25", features = ["extension-module", "abi3-py38"], optional = true }
|
||||
|
||||
# PDF parsing
|
||||
lopdf = { version = "0.41.0", features = ["rayon"] }
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
[](https://crates.io/crates/pdf-inspector)
|
||||
[](https://www.npmjs.com/package/@firecrawl/pdf-inspector)
|
||||
[](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).
|
||||
@@ -22,20 +23,23 @@ Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in
|
||||
|
||||
## Benchmark
|
||||
|
||||
Evaluated on the [opendataloader-bench](https://github.com/opendataloader-project/opendataloader-bench) corpus (200 PDFs). Only direct text extraction engines are shown — no OCR, no ML models. Scores are 0-1, higher is better.
|
||||
Evaluated on the [opendataloader-bench](https://github.com/opendataloader-project/opendataloader-bench) corpus (200 PDFs). Only local engines without model-based PDF parsing are shown; OCR was disabled. Scores are 0-1, higher is better.
|
||||
|
||||
| Engine | Overall | Reading Order (NID) | Tables (TEDS) | Headings (MHS) | Speed (200 docs) |
|
||||
|---|---|---|---|---|---|
|
||||
| pdf-inspector | 0.78 | 0.87 | 0.59 | 0.57 | 4s |
|
||||
| opendataloader | 0.84 | 0.91 | 0.49 | 0.74 | 11s |
|
||||
| pymupdf4llm | 0.73 | 0.89 | 0.40 | 0.41 | 18s |
|
||||
| markitdown | 0.58 | 0.88 | 0.00 | 0.00 | 8s |
|
||||
| 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 |
|
||||
|
||||
For context, engines that use OCR/ML (docling, marker, mineru) score 0.83-0.88 overall but take 2-180 minutes on the same corpus.
|
||||
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.
|
||||
|
||||
**Where we do well:** Speed (fastest of all engines), reading order, table detection vs other direct-text tools.
|
||||
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.
|
||||
|
||||
**Where we lag:** Heading detection trails opendataloader — many PDFs use bold text at body font size for headings, or headings that are only slightly larger than body text. Table detection trails OCR-based engines that can see visual table structure.
|
||||
**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.
|
||||
|
||||
Use the [paired benchmark harness](docs/benchmarking.md) to compare two local builds against the exact same corpus and evaluator revision.
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -118,6 +122,9 @@ pdf2md document.pdf --items-json
|
||||
# Raw markdown only (no headers)
|
||||
pdf2md document.pdf --raw
|
||||
|
||||
# Token-efficient output (collapses long dot leaders and similar source padding)
|
||||
pdf2md document.pdf --compact
|
||||
|
||||
# Insert page break markers (<!-- Page N -->)
|
||||
pdf2md document.pdf --pages
|
||||
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# Benchmarking against OpenDataLoader
|
||||
|
||||
The paired harness runs two `pdf2md` binaries through the same local
|
||||
OpenDataLoader corpus, evaluates both outputs, and reports aggregate and
|
||||
per-document deltas. This avoids comparing results produced from different
|
||||
corpus revisions or evaluator versions.
|
||||
|
||||
Build a candidate and provide a released or worktree build as the baseline:
|
||||
|
||||
```bash
|
||||
cargo build --release
|
||||
python3 scripts/bench_opendataloader.py \
|
||||
--bench-dir ../opendataloader-bench \
|
||||
--baseline ../pdf-inspector-main/target/release/pdf2md \
|
||||
--candidate target/release/pdf2md \
|
||||
--max-document-regression 0.02 \
|
||||
--json-output /tmp/pdf-inspector-benchmark.json
|
||||
```
|
||||
|
||||
Pass `--reference-evaluation path/to/evaluation.json` to report the candidate
|
||||
delta against another evaluation, and add `--require-reference-lead` to make a
|
||||
negative reference delta fail the run. By default, the candidate must not
|
||||
regress the baseline overall score or introduce missing predictions. Use
|
||||
`--min-overall-delta` to require a specific aggregate gain.
|
||||
|
||||
The OpenDataLoader repository is external and keeps its normal
|
||||
`prediction/pdf-inspector` output. Paired evaluation copies each run into a
|
||||
temporary directory before evaluating it, so the baseline and candidate cannot
|
||||
overwrite one another.
|
||||
|
||||
## Published comparison protocol
|
||||
|
||||
The public benchmark table was refreshed on July 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.
|
||||
|
||||
## Optional backend evidence probe
|
||||
|
||||
The evidence probe compares positioned `pdf2md` items with MuPDF structured
|
||||
text on the same pages. It is intended to find deterministic extraction or
|
||||
layout evidence that could justify a future native implementation; it does not
|
||||
merge MuPDF output into Markdown, invoke OCR, or add a runtime dependency.
|
||||
|
||||
Install MuPDF's `mutool`, build `pdf2md`, then run:
|
||||
|
||||
```bash
|
||||
python3 scripts/probe_backend_evidence.py document.pdf \
|
||||
--pdf2md target/release/pdf2md \
|
||||
--json-output /tmp/backend-evidence.json
|
||||
```
|
||||
|
||||
The report flags pages when MuPDF exposes a material net token gain, repeated
|
||||
alignment anchors absent from local evidence, or additional image blocks. The
|
||||
JSON includes bounded token samples and page-level counts so promising cases
|
||||
can be inspected without treating backend disagreement as automatically
|
||||
correct. Thresholds are configurable with `--min-token-gain`,
|
||||
`--min-alternate-only-ratio`, and `--min-anchor-gain`.
|
||||
+75
-10
@@ -1,9 +1,39 @@
|
||||
# Python API
|
||||
# pdf-inspector
|
||||
|
||||
Python bindings via [PyO3](https://pyo3.rs). Requires Rust toolchain for building from source.
|
||||
Fast PDF classification and text extraction. Detects whether a PDF is text-based or scanned, extracts text with position awareness, and converts to clean Markdown — all without OCR. Python bindings via [PyO3](https://pyo3.rs) for the [pdf-inspector](https://github.com/firecrawl/pdf-inspector) Rust library.
|
||||
|
||||
Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in under 200ms, skipping expensive OCR services for the ~54% of PDFs that don't need them.
|
||||
|
||||
## Features
|
||||
|
||||
- **Smart classification** — `text_based` / `scanned` / `image_based` / `mixed` in ~10–50ms, with a confidence score and per-page OCR routing.
|
||||
- **Markdown conversion** — headings, lists, code blocks, bold/italic, URL linking, and dual-mode table detection (PDF drawing ops + text-alignment heuristics).
|
||||
- **Layout-aware extraction** — multi-column reading order, position and font info per text item, RTL support.
|
||||
- **Robust text decoding** — CID/Type0 fonts via ToUnicode CMaps, plus automatic flagging of broken encodings so callers can fall back to OCR.
|
||||
- **Lightweight** — native Rust core, no ML models, no external services; ships type stubs.
|
||||
|
||||
## Benchmark
|
||||
|
||||
[opendataloader-bench](https://github.com/opendataloader-project/opendataloader-bench) corpus (200 PDFs), local engines without model-based PDF parsing; OCR disabled. Scores 0–1, higher is better:
|
||||
|
||||
| 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 |
|
||||
|
||||
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).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
pip install pdf-inspector
|
||||
```
|
||||
|
||||
Prebuilt wheels cover CPython ≥3.8 on Linux (x86_64, aarch64), macOS (Intel, Apple Silicon), and Windows (x64). Other platforms build from source, which requires a Rust toolchain. For local development in a repo checkout:
|
||||
|
||||
```bash
|
||||
pip install maturin
|
||||
maturin develop --release
|
||||
@@ -73,16 +103,51 @@ result = pdf_inspector.extract_pages_markdown("document.pdf", pages=[0, 2])
|
||||
|
||||
## Types
|
||||
|
||||
**`PdfResult` fields:** `pdf_type`, `markdown`, `page_count`, `processing_time_ms`, `pages_needing_ocr`, `title`, `confidence`, `is_complex_layout`, `pages_with_tables`, `pages_with_columns`, `has_encoding_issues`
|
||||
Type stubs (`pdf_inspector.pyi`) ship with the package. Result types at a glance:
|
||||
|
||||
**`PdfClassification` fields:** `pdf_type`, `page_count`, `pages_needing_ocr` (0-indexed), `confidence`
|
||||
```python
|
||||
class PdfResult: # process_pdf / detect_pdf
|
||||
pdf_type: str # "text_based" | "scanned" | "image_based" | "mixed"
|
||||
markdown: str | None # extracted Markdown (None for detect_pdf)
|
||||
page_count: int
|
||||
processing_time_ms: int
|
||||
pages_needing_ocr: list[int]
|
||||
title: str | None
|
||||
confidence: float # 0.0 - 1.0
|
||||
is_complex_layout: bool
|
||||
pages_with_tables: list[int]
|
||||
pages_with_columns: list[int]
|
||||
has_encoding_issues: bool # broken font encodings — consider OCR fallback
|
||||
|
||||
**`TextItem` fields:** `text`, `x`, `y`, `width`, `height`, `font`, `font_size`, `page`, `is_bold`, `is_italic`, `item_type`
|
||||
class PdfClassification: # classify_pdf
|
||||
pdf_type: str
|
||||
page_count: int
|
||||
pages_needing_ocr: list[int] # 0-indexed
|
||||
confidence: float
|
||||
|
||||
**`RegionText` fields:** `text`, `needs_ocr`
|
||||
class TextItem: # extract_text_with_positions
|
||||
text: str
|
||||
x: float
|
||||
y: float
|
||||
width: float
|
||||
height: float
|
||||
font: str
|
||||
font_size: float
|
||||
page: int
|
||||
is_bold: bool
|
||||
is_italic: bool
|
||||
is_underline: bool
|
||||
is_strikeout: bool
|
||||
item_type: str
|
||||
|
||||
**`PageRegionTexts` fields:** `page` (0-indexed), `regions` (list of RegionText)
|
||||
class PageRegionTexts: # extract_text_in_regions
|
||||
page: int # 0-indexed
|
||||
regions: list[RegionText] # RegionText: text: str, needs_ocr: bool
|
||||
|
||||
**`PageMarkdown` fields:** `page` (0-indexed), `markdown`, `needs_ocr`
|
||||
|
||||
**`PagesExtractionResult` fields:** `pages` (list of PageMarkdown), `pages_with_tables` (1-indexed), `pages_with_columns` (1-indexed), `pages_needing_ocr` (1-indexed), `is_complex`
|
||||
class PagesExtractionResult: # extract_pages_markdown
|
||||
pages: list[PageMarkdown] # PageMarkdown: page (0-indexed), markdown, needs_ocr
|
||||
pages_with_tables: list[int] # 1-indexed
|
||||
pages_with_columns: list[int] # 1-indexed
|
||||
pages_needing_ocr: list[int] # 1-indexed
|
||||
is_complex: bool # any page has tables or multi-column layout
|
||||
```
|
||||
|
||||
+40
-2
@@ -1,12 +1,50 @@
|
||||
# Rust API
|
||||
# pdf-inspector
|
||||
|
||||
Add to your `Cargo.toml`:
|
||||
Fast PDF classification and text extraction. Detects whether a PDF is text-based or scanned, extracts text with position awareness, and converts to clean Markdown — all without OCR. Pure Rust, no ML models, no external services; the only PDF dependency is [lopdf](https://crates.io/crates/lopdf). Also available for [Python](https://pypi.org/project/pdf-inspector/) and [Node.js](https://www.npmjs.com/package/@firecrawl/pdf-inspector).
|
||||
|
||||
Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in under 200ms, skipping expensive OCR services for the ~54% of PDFs that don't need them.
|
||||
|
||||
## Features
|
||||
|
||||
- **Smart classification** — TextBased / Scanned / ImageBased / Mixed in ~10–50ms, with a confidence score and per-page OCR routing.
|
||||
- **Markdown conversion** — headings, lists, code blocks, bold/italic, URL linking, and dual-mode table detection (PDF drawing ops + text-alignment heuristics).
|
||||
- **Layout-aware extraction** — multi-column reading order, position and font info per text item, RTL support.
|
||||
- **Robust text decoding** — CID/Type0 fonts via ToUnicode CMaps, plus automatic flagging of broken encodings so callers can fall back to OCR.
|
||||
- **Lightweight** — pure Rust, no ML models, no external services; single PDF dependency ([lopdf](https://crates.io/crates/lopdf)).
|
||||
|
||||
## Benchmark
|
||||
|
||||
[opendataloader-bench](https://github.com/opendataloader-project/opendataloader-bench) corpus (200 PDFs), local engines without model-based PDF parsing; OCR disabled. Scores 0–1, higher is better:
|
||||
|
||||
| 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 |
|
||||
|
||||
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).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
cargo add pdf-inspector
|
||||
```
|
||||
|
||||
For the latest unreleased changes, use the git dependency instead:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
pdf-inspector = { git = "https://github.com/firecrawl/pdf-inspector" }
|
||||
```
|
||||
|
||||
The crate also ships CLI binaries — `pdf2md` (PDF → Markdown, with `--json`, `--pages`, `--select-pages`, and the opt-in token-saving `--compact` profile) and `detect-pdf` (classification, with `--analyze --json`):
|
||||
|
||||
```bash
|
||||
cargo install pdf-inspector
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
Detect and extract in one call:
|
||||
|
||||
Generated
+1
-1
@@ -830,7 +830,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "pdf-inspector"
|
||||
version = "0.1.4"
|
||||
version = "0.1.6"
|
||||
dependencies = [
|
||||
"env_logger",
|
||||
"log",
|
||||
|
||||
+30
-5
@@ -4,6 +4,28 @@ Fast PDF classification and region-based text extraction for Node.js/Bun. Native
|
||||
|
||||
Built by [Firecrawl](https://firecrawl.dev) for hybrid OCR pipelines — extract text from PDF structure where possible, fall back to OCR only when needed.
|
||||
|
||||
## Features
|
||||
|
||||
- **Smart classification** — text-based / scanned / image-based / mixed in ~10–50ms, with a confidence score and per-page OCR routing.
|
||||
- **Region-based extraction** — pull text from bounding boxes with per-region quality checks (`needsOcr`).
|
||||
- **Layout-aware** — multi-column reading order, position and font info per text item, RTL support.
|
||||
- **Robust text decoding** — CID/Type0 fonts via ToUnicode CMaps, plus automatic flagging of broken encodings so callers can fall back to OCR.
|
||||
- **Lightweight** — native Rust core via napi-rs, no ML models, no external services; ~5–6 MB platform binary, TypeScript definitions included.
|
||||
|
||||
## Benchmark
|
||||
|
||||
[opendataloader-bench](https://github.com/opendataloader-project/opendataloader-bench) corpus (200 PDFs), local engines without model-based PDF parsing; OCR disabled. Scores 0–1, higher is better:
|
||||
|
||||
| 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 |
|
||||
|
||||
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).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
@@ -12,7 +34,7 @@ npm install @firecrawl/pdf-inspector
|
||||
bun add @firecrawl/pdf-inspector
|
||||
```
|
||||
|
||||
Prebuilt binaries included for **linux-x64** and **macOS ARM64**. No Rust toolchain needed.
|
||||
Prebuilt binaries for **Linux x64**, **macOS ARM64**, and **Windows x64** — npm installs only the one matching your platform. No Rust toolchain needed.
|
||||
|
||||
## API
|
||||
|
||||
@@ -90,10 +112,13 @@ interface RegionText {
|
||||
|
||||
## Platforms
|
||||
|
||||
| Platform | Architecture | Supported |
|
||||
|----------|-------------|-----------|
|
||||
| Linux | x64 | Yes |
|
||||
| macOS | ARM64 | Yes |
|
||||
Prebuilt binaries ship as platform-specific packages installed automatically via `optionalDependencies`:
|
||||
|
||||
| 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` |
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -7,6 +7,11 @@
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
|
||||
+7
-6
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@firecrawl/pdf-inspector",
|
||||
"version": "1.10.0",
|
||||
"version": "1.11.1",
|
||||
"description": "Fast PDF classification and text extraction. Detect text-based vs scanned PDFs, extract text by region with quality checks. Native Rust performance via napi-rs.",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts",
|
||||
@@ -22,7 +22,6 @@
|
||||
"files": [
|
||||
"index.js",
|
||||
"index.d.ts",
|
||||
"*.node",
|
||||
"bin/",
|
||||
"README.md"
|
||||
],
|
||||
@@ -40,10 +39,7 @@
|
||||
"x86_64-unknown-linux-gnu",
|
||||
"aarch64-apple-darwin",
|
||||
"x86_64-pc-windows-msvc"
|
||||
],
|
||||
"package": {
|
||||
"name": "@firecrawl/pdf-inspector-js"
|
||||
}
|
||||
]
|
||||
},
|
||||
"scripts": {
|
||||
"build": "napi build --platform --release",
|
||||
@@ -51,5 +47,10 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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"
|
||||
}
|
||||
}
|
||||
|
||||
+9
-3
@@ -4,10 +4,11 @@ build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "pdf-inspector"
|
||||
# Version is sourced from Cargo.toml [package] version by maturin so the Python
|
||||
# artifact always tracks the crate release instead of drifting on its own.
|
||||
dynamic = ["version"]
|
||||
# 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"
|
||||
description = "Fast PDF inspection, classification, and text extraction with smart scanned vs text-based detection"
|
||||
readme = "docs/python.md"
|
||||
license = { text = "MIT" }
|
||||
requires-python = ">=3.8"
|
||||
classifiers = [
|
||||
@@ -19,5 +20,10 @@ classifiers = [
|
||||
"Topic :: Text Processing",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/firecrawl/pdf-inspector"
|
||||
Repository = "https://github.com/firecrawl/pdf-inspector"
|
||||
Documentation = "https://github.com/firecrawl/pdf-inspector/blob/main/docs/python.md"
|
||||
|
||||
[tool.maturin]
|
||||
features = ["python"]
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run a paired pdf-inspector OpenDataLoader benchmark and report deltas."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
SCORE_KEYS = (
|
||||
"overall_mean",
|
||||
"nid_mean",
|
||||
"nid_s_mean",
|
||||
"teds_mean",
|
||||
"teds_s_mean",
|
||||
"mhs_mean",
|
||||
"mhs_s_mean",
|
||||
)
|
||||
|
||||
|
||||
def _non_negative_int(value: str) -> int:
|
||||
parsed = int(value)
|
||||
if parsed < 0:
|
||||
raise argparse.ArgumentTypeError("must be non-negative")
|
||||
return parsed
|
||||
|
||||
|
||||
def _non_negative_float(value: str) -> float:
|
||||
parsed = float(value)
|
||||
if not math.isfinite(parsed) or parsed < 0.0:
|
||||
raise argparse.ArgumentTypeError("must be finite and non-negative")
|
||||
return parsed
|
||||
|
||||
|
||||
def _finite_float(value: str) -> float:
|
||||
parsed = float(value)
|
||||
if not math.isfinite(parsed):
|
||||
raise argparse.ArgumentTypeError("must be finite")
|
||||
return parsed
|
||||
|
||||
|
||||
def _scores(evaluation: dict[str, Any]) -> dict[str, float]:
|
||||
score = evaluation.get("metrics", {}).get("score", {})
|
||||
return {key: float(score[key]) for key in SCORE_KEYS if score.get(key) is not None}
|
||||
|
||||
|
||||
def _documents(evaluation: dict[str, Any]) -> dict[str, float]:
|
||||
documents: dict[str, float] = {}
|
||||
for document in evaluation.get("documents", []):
|
||||
overall = document.get("scores", {}).get("overall")
|
||||
if overall is not None:
|
||||
documents[str(document["document_id"])] = float(overall)
|
||||
return documents
|
||||
|
||||
|
||||
def compare_evaluations(
|
||||
baseline: dict[str, Any],
|
||||
candidate: dict[str, Any],
|
||||
reference: dict[str, Any] | None = None,
|
||||
*,
|
||||
top: int = 10,
|
||||
) -> dict[str, Any]:
|
||||
"""Build aggregate and per-document deltas from evaluator JSON payloads."""
|
||||
baseline_scores = _scores(baseline)
|
||||
candidate_scores = _scores(candidate)
|
||||
metric_deltas = {
|
||||
key: candidate_scores[key] - baseline_scores[key]
|
||||
for key in SCORE_KEYS
|
||||
if key in baseline_scores and key in candidate_scores
|
||||
}
|
||||
|
||||
baseline_documents = _documents(baseline)
|
||||
candidate_documents = _documents(candidate)
|
||||
shared = sorted(baseline_documents.keys() & candidate_documents.keys())
|
||||
document_deltas = [
|
||||
{
|
||||
"document_id": document_id,
|
||||
"baseline": baseline_documents[document_id],
|
||||
"candidate": candidate_documents[document_id],
|
||||
"delta": candidate_documents[document_id] - baseline_documents[document_id],
|
||||
}
|
||||
for document_id in shared
|
||||
]
|
||||
epsilon = 1e-12
|
||||
improvements = sorted(document_deltas, key=lambda item: item["delta"], reverse=True)
|
||||
regressions = sorted(document_deltas, key=lambda item: item["delta"])
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"baseline": baseline_scores,
|
||||
"candidate": candidate_scores,
|
||||
"deltas": metric_deltas,
|
||||
"missing_predictions": {
|
||||
"baseline": int(baseline.get("metrics", {}).get("missing_predictions", 0)),
|
||||
"candidate": int(candidate.get("metrics", {}).get("missing_predictions", 0)),
|
||||
},
|
||||
"documents": {
|
||||
"shared": len(shared),
|
||||
"improved": sum(item["delta"] > epsilon for item in document_deltas),
|
||||
"regressed": sum(item["delta"] < -epsilon for item in document_deltas),
|
||||
"unchanged": sum(abs(item["delta"]) <= epsilon for item in document_deltas),
|
||||
"largest_improvements": [
|
||||
item for item in improvements if item["delta"] > epsilon
|
||||
][:top],
|
||||
"largest_regressions": [
|
||||
item for item in regressions if item["delta"] < -epsilon
|
||||
][:top],
|
||||
"worst_regression": next(
|
||||
(item for item in regressions if item["delta"] < -epsilon), None
|
||||
),
|
||||
},
|
||||
}
|
||||
if reference is not None:
|
||||
reference_scores = _scores(reference)
|
||||
result["reference"] = reference_scores
|
||||
result["candidate_vs_reference"] = {
|
||||
key: candidate_scores[key] - reference_scores[key]
|
||||
for key in SCORE_KEYS
|
||||
if key in candidate_scores and key in reference_scores
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def evaluate_gates(
|
||||
comparison: dict[str, Any],
|
||||
*,
|
||||
min_overall_delta: float,
|
||||
max_document_regression: float | None,
|
||||
max_missing: int,
|
||||
require_reference_lead: bool,
|
||||
) -> list[str]:
|
||||
"""Return human-readable gate failures; an empty list means pass."""
|
||||
failures: list[str] = []
|
||||
overall_delta = comparison["deltas"].get("overall_mean")
|
||||
if overall_delta is None or overall_delta < min_overall_delta:
|
||||
failures.append(
|
||||
f"overall delta {overall_delta!r} is below {min_overall_delta:+.6f}"
|
||||
)
|
||||
candidate_missing = comparison["missing_predictions"]["candidate"]
|
||||
if candidate_missing > max_missing:
|
||||
failures.append(
|
||||
f"candidate has {candidate_missing} missing predictions (maximum {max_missing})"
|
||||
)
|
||||
if max_document_regression is not None:
|
||||
regression = comparison["documents"].get("worst_regression")
|
||||
if regression is not None and regression["delta"] < -max_document_regression:
|
||||
failures.append(
|
||||
"largest document regression "
|
||||
f"{regression['document_id']}={regression['delta']:+.6f} "
|
||||
f"exceeds {-max_document_regression:+.6f}"
|
||||
)
|
||||
if require_reference_lead:
|
||||
reference_delta = comparison.get("candidate_vs_reference", {}).get("overall_mean")
|
||||
if reference_delta is None:
|
||||
failures.append("reference overall score is unavailable")
|
||||
elif reference_delta < 0.0:
|
||||
failures.append(
|
||||
f"candidate trails reference overall by {reference_delta!r}"
|
||||
)
|
||||
return failures
|
||||
|
||||
|
||||
def _run(command: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> None:
|
||||
print("+", " ".join(command), flush=True)
|
||||
subprocess.run(command, cwd=cwd, env=env, check=True)
|
||||
|
||||
|
||||
def _run_engine(
|
||||
*,
|
||||
bench_dir: Path,
|
||||
python: Path,
|
||||
binary: Path,
|
||||
label: str,
|
||||
scratch_root: Path,
|
||||
) -> dict[str, Any]:
|
||||
env = os.environ.copy()
|
||||
env["PDF_INSPECTOR_BINARY"] = str(binary)
|
||||
source = bench_dir / "prediction" / "pdf-inspector"
|
||||
if source.exists():
|
||||
if source.is_dir():
|
||||
shutil.rmtree(source)
|
||||
else:
|
||||
source.unlink()
|
||||
_run(
|
||||
[
|
||||
str(python),
|
||||
"src/pdf_parser.py",
|
||||
"--engine",
|
||||
"pdf-inspector",
|
||||
"--log-level",
|
||||
"WARNING",
|
||||
],
|
||||
cwd=bench_dir,
|
||||
env=env,
|
||||
)
|
||||
|
||||
if not source.is_dir():
|
||||
raise RuntimeError(f"parser did not produce predictions: {source}")
|
||||
destination = scratch_root / label
|
||||
shutil.copytree(source, destination)
|
||||
_run(
|
||||
[
|
||||
str(python),
|
||||
"src/evaluator.py",
|
||||
"--prediction-root",
|
||||
str(scratch_root),
|
||||
"--engine",
|
||||
label,
|
||||
"--log-level",
|
||||
"WARNING",
|
||||
],
|
||||
cwd=bench_dir,
|
||||
)
|
||||
with (destination / "evaluation.json").open(encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
def _print_report(comparison: dict[str, Any]) -> None:
|
||||
print("\nMetric baseline candidate delta")
|
||||
print("-------------------- ---------- ---------- ----------")
|
||||
for key in SCORE_KEYS:
|
||||
if key not in comparison["deltas"]:
|
||||
continue
|
||||
print(
|
||||
f"{key:<20} {comparison['baseline'][key]:>10.6f} "
|
||||
f"{comparison['candidate'][key]:>10.6f} "
|
||||
f"{comparison['deltas'][key]:>+10.6f}"
|
||||
)
|
||||
if "reference" in comparison:
|
||||
delta = comparison["candidate_vs_reference"].get("overall_mean")
|
||||
reference = comparison["reference"].get("overall_mean")
|
||||
reference_display = f"{reference:.6f}" if reference is not None else "n/a"
|
||||
delta_display = f"{delta:+.6f}" if delta is not None else "n/a"
|
||||
print(f"\nReference overall: {reference_display}; candidate delta: {delta_display}")
|
||||
|
||||
documents = comparison["documents"]
|
||||
print(
|
||||
"\nDocuments: "
|
||||
f"{documents['improved']} improved, {documents['regressed']} regressed, "
|
||||
f"{documents['unchanged']} unchanged ({documents['shared']} shared)"
|
||||
)
|
||||
for heading, key in (
|
||||
("Largest improvements", "largest_improvements"),
|
||||
("Largest regressions", "largest_regressions"),
|
||||
):
|
||||
print(f"\n{heading}:")
|
||||
rows = documents[key]
|
||||
if not rows:
|
||||
print(" none")
|
||||
for row in rows:
|
||||
print(
|
||||
f" {row['document_id']}: {row['delta']:+.6f} "
|
||||
f"({row['baseline']:.6f} -> {row['candidate']:.6f})"
|
||||
)
|
||||
|
||||
|
||||
def _arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--bench-dir", type=Path, required=True)
|
||||
parser.add_argument("--baseline", type=Path, required=True)
|
||||
parser.add_argument("--candidate", type=Path, required=True)
|
||||
parser.add_argument("--python", type=Path)
|
||||
parser.add_argument("--reference-evaluation", type=Path)
|
||||
parser.add_argument("--json-output", type=Path)
|
||||
parser.add_argument("--top", type=_non_negative_int, default=10)
|
||||
parser.add_argument("--min-overall-delta", type=_finite_float, default=0.0)
|
||||
parser.add_argument("--max-document-regression", type=_non_negative_float)
|
||||
parser.add_argument("--max-missing", type=_non_negative_int, default=0)
|
||||
parser.add_argument("--require-reference-lead", action="store_true")
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _arguments(argv)
|
||||
bench_dir = args.bench_dir.resolve()
|
||||
baseline = args.baseline.resolve()
|
||||
candidate = args.candidate.resolve()
|
||||
# Keep the virtualenv launcher path intact. Resolving its symlink would
|
||||
# invoke the underlying system interpreter without the benchmark's site
|
||||
# packages.
|
||||
python = (args.python or bench_dir / ".venv" / "bin" / "python").absolute()
|
||||
for path, description in (
|
||||
(bench_dir / "src" / "pdf_parser.py", "OpenDataLoader parser"),
|
||||
(bench_dir / "src" / "evaluator.py", "OpenDataLoader evaluator"),
|
||||
(baseline, "baseline binary"),
|
||||
(candidate, "candidate binary"),
|
||||
(python, "Python interpreter"),
|
||||
):
|
||||
if not path.exists():
|
||||
raise SystemExit(f"{description} not found: {path}")
|
||||
|
||||
with tempfile.TemporaryDirectory(prefix="pdf-inspector-opendataloader-") as temporary:
|
||||
scratch_root = Path(temporary)
|
||||
baseline_evaluation = _run_engine(
|
||||
bench_dir=bench_dir,
|
||||
python=python,
|
||||
binary=baseline,
|
||||
label="baseline",
|
||||
scratch_root=scratch_root,
|
||||
)
|
||||
candidate_evaluation = _run_engine(
|
||||
bench_dir=bench_dir,
|
||||
python=python,
|
||||
binary=candidate,
|
||||
label="candidate",
|
||||
scratch_root=scratch_root,
|
||||
)
|
||||
|
||||
reference = None
|
||||
if args.reference_evaluation is not None:
|
||||
with args.reference_evaluation.resolve().open(encoding="utf-8") as handle:
|
||||
reference = json.load(handle)
|
||||
|
||||
comparison = compare_evaluations(
|
||||
baseline_evaluation,
|
||||
candidate_evaluation,
|
||||
reference,
|
||||
top=args.top,
|
||||
)
|
||||
|
||||
_print_report(comparison)
|
||||
if args.json_output is not None:
|
||||
args.json_output.resolve().write_text(
|
||||
json.dumps(comparison, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
failures = evaluate_gates(
|
||||
comparison,
|
||||
min_overall_delta=args.min_overall_delta,
|
||||
max_document_regression=args.max_document_regression,
|
||||
max_missing=args.max_missing,
|
||||
require_reference_lead=args.require_reference_lead,
|
||||
)
|
||||
if failures:
|
||||
print("\nBenchmark gate failed:", file=sys.stderr)
|
||||
for failure in failures:
|
||||
print(f" - {failure}", file=sys.stderr)
|
||||
return 1
|
||||
print("\nBenchmark gate passed.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,351 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Compare pdf-inspector evidence with optional MuPDF structured text.
|
||||
|
||||
This is an experiment and diagnostic tool, not an extraction fallback. It runs
|
||||
MuPDF's deterministic ``stext.json`` backend without OCR and highlights pages
|
||||
where that backend exposes materially different text or layout evidence.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from collections import Counter
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
TOKEN_PATTERN = re.compile(r"[^\W_]+(?:[\u2019'][^\W_]+)*", re.UNICODE)
|
||||
|
||||
|
||||
def _tokens(texts: Iterable[str]) -> Counter[str]:
|
||||
tokens: Counter[str] = Counter()
|
||||
for text in texts:
|
||||
for token in TOKEN_PATTERN.findall(text.casefold()):
|
||||
# Lone letters are frequently bullets, chart labels, or fragmented
|
||||
# glyphs. Digits remain useful even when they are one character.
|
||||
if len(token) > 1 or token.isdigit():
|
||||
tokens[token] += 1
|
||||
return tokens
|
||||
|
||||
|
||||
def _repeated_x_anchors(xs: Iterable[float], *, tolerance: float = 4.0) -> int:
|
||||
buckets = Counter(round(float(x) / tolerance) for x in xs)
|
||||
return sum(count >= 3 for count in buckets.values())
|
||||
|
||||
|
||||
def local_pages(payload: dict[str, Any]) -> dict[int, dict[str, Any]]:
|
||||
"""Summarize positioned ``pdf2md --items-json`` evidence by page."""
|
||||
pages: dict[int, dict[str, Any]] = {}
|
||||
for item in payload.get("items", []):
|
||||
page_number = int(item["page"])
|
||||
page = pages.setdefault(
|
||||
page_number,
|
||||
{"texts": [], "xs": [], "text_items": 0, "image_items": 0},
|
||||
)
|
||||
if item.get("item_type") == "image":
|
||||
page["image_items"] += 1
|
||||
continue
|
||||
text = str(item.get("text", ""))
|
||||
if text.strip():
|
||||
page["texts"].append(text)
|
||||
page["xs"].append(float(item.get("x", 0.0)))
|
||||
page["text_items"] += 1
|
||||
return pages
|
||||
|
||||
|
||||
def alternate_pages(
|
||||
payload: dict[str, Any] | list[dict[str, Any]],
|
||||
) -> dict[int, dict[str, Any]]:
|
||||
"""Summarize MuPDF ``stext.json`` evidence by page."""
|
||||
pages: dict[int, dict[str, Any]] = {}
|
||||
raw_pages = payload if isinstance(payload, list) else payload.get("pages", [])
|
||||
for index, raw_page in enumerate(raw_pages, start=1):
|
||||
page_number = int(raw_page.get("number", index))
|
||||
page = {
|
||||
"texts": [],
|
||||
"xs": [],
|
||||
"text_blocks": 0,
|
||||
"text_lines": 0,
|
||||
"image_blocks": 0,
|
||||
}
|
||||
for block in raw_page.get("blocks", []):
|
||||
if block.get("type") == "image":
|
||||
page["image_blocks"] += 1
|
||||
continue
|
||||
if block.get("type") != "text":
|
||||
continue
|
||||
page["text_blocks"] += 1
|
||||
for line in block.get("lines", []):
|
||||
text = str(line.get("text", ""))
|
||||
if text.strip():
|
||||
page["texts"].append(text)
|
||||
bbox = line.get("bbox", {})
|
||||
page["xs"].append(float(bbox.get("x", line.get("x", 0.0))))
|
||||
page["text_lines"] += 1
|
||||
pages[page_number] = page
|
||||
return pages
|
||||
|
||||
|
||||
def compare_page(
|
||||
local: dict[str, Any],
|
||||
alternate: dict[str, Any],
|
||||
*,
|
||||
min_token_gain: int,
|
||||
min_alternate_only_ratio: float,
|
||||
min_anchor_gain: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Compare semantic and coarse layout evidence for one page."""
|
||||
local_tokens = _tokens(local.get("texts", []))
|
||||
alternate_tokens = _tokens(alternate.get("texts", []))
|
||||
shared = local_tokens & alternate_tokens
|
||||
alternate_only = alternate_tokens - local_tokens
|
||||
local_only = local_tokens - alternate_tokens
|
||||
local_total = sum(local_tokens.values())
|
||||
alternate_total = sum(alternate_tokens.values())
|
||||
shared_total = sum(shared.values())
|
||||
alternate_only_total = sum(alternate_only.values())
|
||||
local_only_total = sum(local_only.values())
|
||||
net_token_gain = alternate_total - local_total
|
||||
alternate_only_ratio = alternate_only_total / max(alternate_total, 1)
|
||||
|
||||
local_anchors = _repeated_x_anchors(local.get("xs", []))
|
||||
alternate_anchors = _repeated_x_anchors(alternate.get("xs", []))
|
||||
anchor_gain = alternate_anchors - local_anchors
|
||||
image_gain = int(alternate.get("image_blocks", 0)) - int(
|
||||
local.get("image_items", 0)
|
||||
)
|
||||
|
||||
reasons: list[str] = []
|
||||
if local_total == 0 and alternate_total >= max(5, min_token_gain // 2):
|
||||
reasons.append("local_text_empty")
|
||||
elif (
|
||||
net_token_gain >= min_token_gain
|
||||
and alternate_only_ratio >= min_alternate_only_ratio
|
||||
):
|
||||
reasons.append("alternate_has_more_text")
|
||||
if anchor_gain >= min_anchor_gain:
|
||||
reasons.append("alternate_has_more_alignment_anchors")
|
||||
if image_gain > 0:
|
||||
reasons.append("alternate_has_more_image_blocks")
|
||||
|
||||
if reasons:
|
||||
classification = "investigate_alternate_evidence"
|
||||
elif local_total - alternate_total >= min_token_gain:
|
||||
classification = "local_has_more_text"
|
||||
elif alternate_only_total + local_only_total:
|
||||
classification = "different_segmentation_or_decoding"
|
||||
else:
|
||||
classification = "equivalent_text_evidence"
|
||||
|
||||
return {
|
||||
"classification": classification,
|
||||
"reasons": reasons,
|
||||
"tokens": {
|
||||
"local": local_total,
|
||||
"alternate": alternate_total,
|
||||
"shared": shared_total,
|
||||
"net_alternate_gain": net_token_gain,
|
||||
"alternate_only": alternate_only_total,
|
||||
"local_only": local_only_total,
|
||||
"alternate_only_ratio": alternate_only_ratio,
|
||||
"alternate_only_sample": sorted(alternate_only)[:12],
|
||||
"local_only_sample": sorted(local_only)[:12],
|
||||
},
|
||||
"layout": {
|
||||
"local_text_items": int(local.get("text_items", 0)),
|
||||
"local_image_items": int(local.get("image_items", 0)),
|
||||
"local_repeated_x_anchors": local_anchors,
|
||||
"alternate_text_blocks": int(alternate.get("text_blocks", 0)),
|
||||
"alternate_text_lines": int(alternate.get("text_lines", 0)),
|
||||
"alternate_image_blocks": int(alternate.get("image_blocks", 0)),
|
||||
"alternate_repeated_x_anchors": alternate_anchors,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def compare_documents(
|
||||
local_payload: dict[str, Any],
|
||||
alternate_payload: dict[str, Any] | list[dict[str, Any]],
|
||||
*,
|
||||
min_token_gain: int = 20,
|
||||
min_alternate_only_ratio: float = 0.15,
|
||||
min_anchor_gain: int = 2,
|
||||
) -> dict[str, Any]:
|
||||
"""Return a page-level evidence report for already extracted payloads."""
|
||||
local = local_pages(local_payload)
|
||||
alternate = alternate_pages(alternate_payload)
|
||||
page_numbers = sorted(local.keys() | alternate.keys())
|
||||
pages = []
|
||||
for page_number in page_numbers:
|
||||
result = compare_page(
|
||||
local.get(page_number, {}),
|
||||
alternate.get(page_number, {}),
|
||||
min_token_gain=min_token_gain,
|
||||
min_alternate_only_ratio=min_alternate_only_ratio,
|
||||
min_anchor_gain=min_anchor_gain,
|
||||
)
|
||||
result["page"] = page_number
|
||||
pages.append(result)
|
||||
|
||||
flagged = [
|
||||
page
|
||||
for page in pages
|
||||
if page["classification"] == "investigate_alternate_evidence"
|
||||
]
|
||||
return {
|
||||
"summary": {
|
||||
"pages": len(pages),
|
||||
"flagged_pages": len(flagged),
|
||||
"flagged_page_numbers": [page["page"] for page in flagged],
|
||||
"local_tokens": sum(page["tokens"]["local"] for page in pages),
|
||||
"alternate_tokens": sum(page["tokens"]["alternate"] for page in pages),
|
||||
"alternate_only_tokens": sum(
|
||||
page["tokens"]["alternate_only"] for page in pages
|
||||
),
|
||||
},
|
||||
"pages": pages,
|
||||
}
|
||||
|
||||
|
||||
def _json_command(command: list[str]) -> Any:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
check=True,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as error:
|
||||
detail = error.stderr.strip() or error.stdout.strip() or "no diagnostic output"
|
||||
raise RuntimeError(f"command failed: {' '.join(command)}\n{detail}") from error
|
||||
try:
|
||||
return json.loads(completed.stdout)
|
||||
except json.JSONDecodeError as error:
|
||||
raise RuntimeError(
|
||||
f"command did not return JSON: {' '.join(command)}: {error}"
|
||||
) from error
|
||||
|
||||
|
||||
def probe_pdf(
|
||||
pdf: Path,
|
||||
*,
|
||||
pdf2md: Path,
|
||||
mutool: Path,
|
||||
min_token_gain: int,
|
||||
min_alternate_only_ratio: float,
|
||||
min_anchor_gain: int,
|
||||
) -> dict[str, Any]:
|
||||
local_payload = _json_command([str(pdf2md), str(pdf), "--items-json"])
|
||||
# `stext.json` is MuPDF's native structured text output. The OCR formats
|
||||
# are intentionally not used so this remains a deterministic no-model
|
||||
# comparison.
|
||||
alternate_payload = _json_command(
|
||||
[str(mutool), "draw", "-q", "-F", "stext.json", "-o", "-", str(pdf)]
|
||||
)
|
||||
report = compare_documents(
|
||||
local_payload,
|
||||
alternate_payload,
|
||||
min_token_gain=min_token_gain,
|
||||
min_alternate_only_ratio=min_alternate_only_ratio,
|
||||
min_anchor_gain=min_anchor_gain,
|
||||
)
|
||||
report["pdf"] = str(pdf)
|
||||
return report
|
||||
|
||||
|
||||
def _arguments(argv: list[str] | None = None) -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("pdf", type=Path, nargs="+")
|
||||
parser.add_argument("--pdf2md", type=Path, default=Path("target/release/pdf2md"))
|
||||
parser.add_argument("--mutool", type=Path)
|
||||
parser.add_argument("--json-output", type=Path)
|
||||
parser.add_argument("--min-token-gain", type=int, default=20)
|
||||
parser.add_argument("--min-alternate-only-ratio", type=float, default=0.15)
|
||||
parser.add_argument("--min-anchor-gain", type=int, default=2)
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
def _print_report(result: dict[str, Any]) -> None:
|
||||
summary = result["summary"]
|
||||
print(f"\n{result['pdf']}")
|
||||
print(
|
||||
f" {summary['flagged_pages']}/{summary['pages']} pages flagged; "
|
||||
f"tokens local={summary['local_tokens']} alternate={summary['alternate_tokens']} "
|
||||
f"alternate-only={summary['alternate_only_tokens']}"
|
||||
)
|
||||
for page in result["pages"]:
|
||||
if page["classification"] != "investigate_alternate_evidence":
|
||||
continue
|
||||
reasons = ", ".join(page["reasons"])
|
||||
tokens = page["tokens"]
|
||||
print(
|
||||
f" page {page['page']}: {reasons}; "
|
||||
f"net tokens={tokens['net_alternate_gain']:+d}, "
|
||||
f"alternate-only={tokens['alternate_only']}"
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _arguments(argv)
|
||||
pdf2md = args.pdf2md.absolute()
|
||||
mutool = args.mutool or (Path(found) if (found := shutil.which("mutool")) else None)
|
||||
if not pdf2md.is_file():
|
||||
print(f"error: pdf2md binary not found: {pdf2md}", file=sys.stderr)
|
||||
return 2
|
||||
if mutool is None or not mutool.is_file():
|
||||
print("error: mutool not found; install MuPDF or pass --mutool", file=sys.stderr)
|
||||
return 2
|
||||
if (
|
||||
args.min_token_gain < 0
|
||||
or args.min_anchor_gain < 0
|
||||
or not 0.0 <= args.min_alternate_only_ratio <= 1.0
|
||||
):
|
||||
print("error: thresholds must be non-negative and ratio must be in [0, 1]", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
results = []
|
||||
for pdf in args.pdf:
|
||||
path = pdf.absolute()
|
||||
if not path.is_file():
|
||||
print(f"error: PDF not found: {path}", file=sys.stderr)
|
||||
return 2
|
||||
try:
|
||||
result = probe_pdf(
|
||||
path,
|
||||
pdf2md=pdf2md,
|
||||
mutool=mutool,
|
||||
min_token_gain=args.min_token_gain,
|
||||
min_alternate_only_ratio=args.min_alternate_only_ratio,
|
||||
min_anchor_gain=args.min_anchor_gain,
|
||||
)
|
||||
except RuntimeError as error:
|
||||
print(f"error: {error}", file=sys.stderr)
|
||||
return 1
|
||||
results.append(result)
|
||||
_print_report(result)
|
||||
|
||||
payload = {
|
||||
"schema_version": 1,
|
||||
"experiment": "optional_mupdf_stext_evidence",
|
||||
"ocr": False,
|
||||
"thresholds": {
|
||||
"min_token_gain": args.min_token_gain,
|
||||
"min_alternate_only_ratio": args.min_alternate_only_ratio,
|
||||
"min_anchor_gain": args.min_anchor_gain,
|
||||
},
|
||||
"documents": results,
|
||||
}
|
||||
if args.json_output:
|
||||
args.json_output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.json_output.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,203 @@
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from bench_opendataloader import (
|
||||
_arguments,
|
||||
_print_report,
|
||||
_run_engine,
|
||||
compare_evaluations,
|
||||
evaluate_gates,
|
||||
)
|
||||
|
||||
|
||||
def evaluation(overall, documents, *, missing=0):
|
||||
return {
|
||||
"metrics": {
|
||||
"score": {
|
||||
"overall_mean": overall,
|
||||
"nid_mean": overall + 0.01,
|
||||
},
|
||||
"missing_predictions": missing,
|
||||
},
|
||||
"documents": [
|
||||
{
|
||||
"document_id": document_id,
|
||||
"scores": {"overall": score},
|
||||
}
|
||||
for document_id, score in documents.items()
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class ComparisonTests(unittest.TestCase):
|
||||
def test_reports_metric_and_document_deltas(self):
|
||||
baseline = evaluation(0.80, {"a": 0.8, "b": 0.6, "c": 0.7})
|
||||
candidate = evaluation(0.82, {"a": 0.9, "b": 0.5, "c": 0.7})
|
||||
|
||||
result = compare_evaluations(baseline, candidate, top=1)
|
||||
|
||||
self.assertAlmostEqual(result["deltas"]["overall_mean"], 0.02)
|
||||
self.assertEqual(result["documents"]["improved"], 1)
|
||||
self.assertEqual(result["documents"]["regressed"], 1)
|
||||
self.assertEqual(result["documents"]["unchanged"], 1)
|
||||
self.assertEqual(
|
||||
result["documents"]["largest_improvements"][0]["document_id"], "a"
|
||||
)
|
||||
self.assertEqual(
|
||||
result["documents"]["largest_regressions"][0]["document_id"], "b"
|
||||
)
|
||||
|
||||
def test_reference_delta_is_reported(self):
|
||||
baseline = evaluation(0.80, {})
|
||||
candidate = evaluation(0.82, {})
|
||||
reference = evaluation(0.81, {})
|
||||
|
||||
result = compare_evaluations(baseline, candidate, reference)
|
||||
|
||||
self.assertAlmostEqual(
|
||||
result["candidate_vs_reference"]["overall_mean"], 0.01
|
||||
)
|
||||
|
||||
def test_gates_cover_aggregate_document_missing_and_reference(self):
|
||||
comparison = compare_evaluations(
|
||||
evaluation(0.80, {"a": 0.8}),
|
||||
evaluation(0.79, {"a": 0.7}, missing=1),
|
||||
evaluation(0.81, {}),
|
||||
)
|
||||
|
||||
failures = evaluate_gates(
|
||||
comparison,
|
||||
min_overall_delta=0.0,
|
||||
max_document_regression=0.05,
|
||||
max_missing=0,
|
||||
require_reference_lead=True,
|
||||
)
|
||||
|
||||
self.assertEqual(len(failures), 4)
|
||||
|
||||
def test_regression_gate_is_independent_of_report_limit(self):
|
||||
comparison = compare_evaluations(
|
||||
evaluation(0.80, {"a": 0.8}),
|
||||
evaluation(0.80, {"a": 0.7}),
|
||||
top=0,
|
||||
)
|
||||
|
||||
failures = evaluate_gates(
|
||||
comparison,
|
||||
min_overall_delta=0.0,
|
||||
max_document_regression=0.05,
|
||||
max_missing=0,
|
||||
require_reference_lead=False,
|
||||
)
|
||||
|
||||
self.assertEqual(len(failures), 1)
|
||||
self.assertIn("largest document regression", failures[0])
|
||||
|
||||
def test_report_handles_reference_without_overall_score(self):
|
||||
result = compare_evaluations(
|
||||
evaluation(0.80, {}),
|
||||
evaluation(0.82, {}),
|
||||
{"metrics": {"score": {"nid_mean": 0.81}}},
|
||||
)
|
||||
|
||||
output = io.StringIO()
|
||||
with redirect_stdout(output):
|
||||
_print_report(result)
|
||||
|
||||
self.assertIn("Reference overall: n/a; candidate delta: n/a", output.getvalue())
|
||||
|
||||
def test_reference_gate_reports_missing_score_as_unavailable(self):
|
||||
comparison = compare_evaluations(
|
||||
evaluation(0.80, {}),
|
||||
evaluation(0.82, {}),
|
||||
)
|
||||
|
||||
failures = evaluate_gates(
|
||||
comparison,
|
||||
min_overall_delta=0.0,
|
||||
max_document_regression=None,
|
||||
max_missing=0,
|
||||
require_reference_lead=True,
|
||||
)
|
||||
|
||||
self.assertEqual(failures, ["reference overall score is unavailable"])
|
||||
|
||||
def test_arguments_reject_negative_counts_and_allow_zero_top(self):
|
||||
required = [
|
||||
"--bench-dir",
|
||||
".",
|
||||
"--baseline",
|
||||
"baseline",
|
||||
"--candidate",
|
||||
"candidate",
|
||||
]
|
||||
self.assertEqual(_arguments(required + ["--top", "0"]).top, 0)
|
||||
for option in ("--top", "--max-document-regression", "--max-missing"):
|
||||
with self.subTest(option=option), redirect_stderr(io.StringIO()):
|
||||
with self.assertRaises(SystemExit):
|
||||
_arguments(required + [option, "-1"])
|
||||
|
||||
def test_arguments_reject_nonfinite_float_thresholds(self):
|
||||
required = [
|
||||
"--bench-dir",
|
||||
".",
|
||||
"--baseline",
|
||||
"baseline",
|
||||
"--candidate",
|
||||
"candidate",
|
||||
]
|
||||
for option in ("--min-overall-delta", "--max-document-regression"):
|
||||
for value in ("nan", "inf", "-inf"):
|
||||
with self.subTest(option=option, value=value), redirect_stderr(
|
||||
io.StringIO()
|
||||
):
|
||||
with self.assertRaises(SystemExit):
|
||||
_arguments(required + [option, value])
|
||||
|
||||
def test_run_engine_clears_stale_predictions_before_parser(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
bench_dir = root / "bench"
|
||||
source = bench_dir / "prediction" / "pdf-inspector"
|
||||
source.mkdir(parents=True)
|
||||
(source / "stale.md").write_text("stale", encoding="utf-8")
|
||||
scratch = root / "scratch"
|
||||
scratch.mkdir()
|
||||
|
||||
def fake_run(command, *, cwd, env=None):
|
||||
if any(part.endswith("pdf_parser.py") for part in command):
|
||||
self.assertFalse(source.exists())
|
||||
(source / "markdown").mkdir(parents=True)
|
||||
(source / "markdown" / "new.md").write_text(
|
||||
"new", encoding="utf-8"
|
||||
)
|
||||
else:
|
||||
destination = scratch / "candidate"
|
||||
(destination / "evaluation.json").write_text(
|
||||
json.dumps(evaluation(0.82, {})), encoding="utf-8"
|
||||
)
|
||||
|
||||
with patch("bench_opendataloader._run", side_effect=fake_run):
|
||||
result = _run_engine(
|
||||
bench_dir=bench_dir,
|
||||
python=Path("python"),
|
||||
binary=Path("pdf2md"),
|
||||
label="candidate",
|
||||
scratch_root=scratch,
|
||||
)
|
||||
|
||||
self.assertEqual(result["metrics"]["score"]["overall_mean"], 0.82)
|
||||
self.assertFalse((source / "stale.md").exists())
|
||||
self.assertFalse((scratch / "candidate" / "stale.md").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,103 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
|
||||
|
||||
from probe_backend_evidence import compare_documents
|
||||
|
||||
|
||||
def local_payload(items):
|
||||
return {"items": items}
|
||||
|
||||
|
||||
def item(page, text, x=10, item_type="text"):
|
||||
return {"page": page, "text": text, "x": x, "item_type": item_type}
|
||||
|
||||
|
||||
def alternate_payload(pages):
|
||||
return {"pages": pages}
|
||||
|
||||
|
||||
def page(lines, *, images=0):
|
||||
blocks = [
|
||||
{
|
||||
"type": "text",
|
||||
"lines": [
|
||||
{"text": text, "bbox": {"x": x, "y": index * 10, "w": 80, "h": 8}}
|
||||
for index, (text, x) in enumerate(lines)
|
||||
],
|
||||
}
|
||||
]
|
||||
blocks.extend({"type": "image"} for _ in range(images))
|
||||
return {"blocks": blocks}
|
||||
|
||||
|
||||
class EvidenceComparisonTests(unittest.TestCase):
|
||||
def test_accepts_real_top_level_page_array(self):
|
||||
local = local_payload([item(1, "alpha beta")])
|
||||
alternate = [page([("alpha beta gamma", 10)])]
|
||||
|
||||
result = compare_documents(local, alternate)["pages"][0]
|
||||
|
||||
self.assertEqual(result["tokens"]["alternate"], 3)
|
||||
self.assertEqual(result["tokens"]["net_alternate_gain"], 1)
|
||||
|
||||
def test_flags_material_alternate_text_gain(self):
|
||||
local = local_payload([item(1, "alpha beta")])
|
||||
alternate = alternate_payload(
|
||||
[page([("alpha beta gamma delta epsilon zeta", 10)])]
|
||||
)
|
||||
|
||||
report = compare_documents(
|
||||
local,
|
||||
alternate,
|
||||
min_token_gain=3,
|
||||
min_alternate_only_ratio=0.2,
|
||||
)
|
||||
|
||||
result = report["pages"][0]
|
||||
self.assertEqual(result["classification"], "investigate_alternate_evidence")
|
||||
self.assertIn("alternate_has_more_text", result["reasons"])
|
||||
self.assertEqual(result["tokens"]["net_alternate_gain"], 4)
|
||||
|
||||
def test_repeated_alignment_and_image_evidence_are_reported(self):
|
||||
local = local_payload([item(1, "one two", 10)])
|
||||
alternate = alternate_payload(
|
||||
[
|
||||
page(
|
||||
[
|
||||
("one two", 10),
|
||||
("row three", 100),
|
||||
("row four", 100),
|
||||
("row five", 100),
|
||||
],
|
||||
images=1,
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
result = compare_documents(
|
||||
local,
|
||||
alternate,
|
||||
min_token_gain=99,
|
||||
min_anchor_gain=1,
|
||||
)["pages"][0]
|
||||
|
||||
self.assertIn("alternate_has_more_alignment_anchors", result["reasons"])
|
||||
self.assertIn("alternate_has_more_image_blocks", result["reasons"])
|
||||
self.assertEqual(result["layout"]["alternate_repeated_x_anchors"], 1)
|
||||
|
||||
def test_token_segmentation_difference_does_not_imply_more_evidence(self):
|
||||
local = local_payload([item(1, "Revenue 2025")])
|
||||
alternate = alternate_payload([page([("Revenue 2024", 10)])])
|
||||
|
||||
result = compare_documents(local, alternate, min_token_gain=2)["pages"][0]
|
||||
|
||||
self.assertEqual(result["classification"], "different_segmentation_or_decoding")
|
||||
self.assertEqual(result["reasons"], [])
|
||||
self.assertEqual(result["tokens"]["alternate_only_sample"], ["2024"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,3 @@
|
||||
<svg width="200" height="284" viewBox="0 0 200 284" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M166.862 90.7716C155.812 94.0514 147.483 101.471 141.383 109.53C140.073 111.26 137.343 109.96 137.863 107.841C149.543 59.8136 134.113 19.896 86.0157 0.247269C83.5758 -0.752669 81.0359 1.43719 81.6759 3.99704C103.555 91.8416 11.5294 84.432 23.1588 184.016C23.3588 185.726 21.4389 186.896 20.039 185.896C15.6792 182.766 10.8095 176.236 7.46963 171.647C6.48968 170.297 4.36978 170.677 3.9198 172.287C1.25994 181.906 0 190.965 0 199.965C0 234.963 17.9891 265.771 45.2177 283.63C46.7777 284.65 48.7776 283.19 48.2476 281.4C46.8477 276.7 46.0577 271.74 45.9977 266.611C45.9977 263.461 46.1977 260.241 46.6877 257.241C47.8276 249.702 50.4475 242.522 54.8473 235.983C69.9365 213.334 100.185 191.455 95.3552 161.747C95.0453 159.867 97.2651 158.627 98.6651 159.917C119.974 179.386 124.194 205.575 120.694 229.063C120.394 231.103 122.954 232.193 124.244 230.593C127.504 226.513 131.483 222.933 135.813 220.244C136.893 219.574 138.333 220.084 138.743 221.284C141.153 228.293 144.733 234.873 148.113 241.452C152.152 249.362 154.302 258.391 153.962 267.951C153.792 272.6 153.022 277.1 151.732 281.38C151.182 283.19 153.162 284.7 154.752 283.66C182.001 265.801 200 234.993 200 199.975C200 187.806 197.87 175.876 193.84 164.697C185.391 141.248 163.952 123.64 169.372 93.0815C169.632 91.6216 168.282 90.3517 166.862 90.7716Z" fill="#FA5D19" style="fill:#FA5D19;fill:color(display-p3 0.9816 0.3634 0.0984);fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg width="172" height="40" viewBox="0 0 172 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M23.3606 12.8281C21.8137 13.2873 20.6476 14.3261 19.7936 15.4544C19.6102 15.6966 19.228 15.5146 19.3008 15.2178C20.936 8.49401 18.7759 2.90556 12.0422 0.154735C11.7006 0.0147436 11.345 0.321324 11.4346 0.679702C14.4977 12.9779 1.61412 11.9406 3.24224 25.8823C3.27024 26.1217 3.00145 26.2855 2.80546 26.1455C2.19509 25.7073 1.51332 24.7932 1.04575 24.1506C0.908555 23.9616 0.611769 24.0148 0.548773 24.2402C0.176391 25.5869 0 26.8553 0 28.1152C0 33.0149 2.51847 37.328 6.33048 39.8283C6.54887 39.9711 6.82886 39.7667 6.75466 39.5161C6.55867 38.8581 6.44808 38.1638 6.43968 37.4456C6.43968 37.0046 6.46768 36.5539 6.53627 36.1339C6.69587 35.0784 7.06265 34.0732 7.67862 33.1577C9.79111 29.9869 14.0259 26.9239 13.3497 22.7647C13.3063 22.5015 13.6171 22.328 13.8131 22.5085C16.7964 25.2342 17.3871 28.9005 16.8972 32.1889C16.8552 32.4745 17.2135 32.6271 17.3941 32.4031C17.8505 31.832 18.4077 31.3308 19.0138 30.9542C19.165 30.8604 19.3666 30.9318 19.424 31.0998C19.7614 32.0811 20.2626 33.0023 20.7358 33.9234C21.3013 35.0308 21.6023 36.2949 21.5547 37.6332C21.5309 38.2842 21.4231 38.9141 21.2425 39.5133C21.1655 39.7667 21.4427 39.9781 21.6653 39.8325C25.4801 37.3322 28 33.0191 28 28.1166C28 26.4129 27.7018 24.7428 27.1376 23.1777C25.9547 19.8949 22.9533 17.4297 23.712 13.1515C23.7484 12.9471 23.5594 12.7693 23.3606 12.8281Z" fill="#262626" style="fill:#262626;fill:color(display-p3 0.1500 0.1500 0.1500);fill-opacity:1;"/>
|
||||
<path d="M41 34.0521V10.9618H55.7586V14.3264H44.7969V21.0226H53.8436V24.2882H44.7969V34.0521H41Z" fill="#262626" style="fill:#262626;fill:color(display-p3 0.1500 0.1500 0.1500);fill-opacity:1;"/>
|
||||
<path d="M59.9569 14.7882C58.7352 14.7882 57.7777 13.8976 57.7777 12.6441C57.7777 11.3906 58.7352 10.5 59.9569 10.5C61.1785 10.5 62.136 11.3906 62.136 12.6441C62.136 13.8976 61.1785 14.7882 59.9569 14.7882ZM58.1409 34.0521V17.1632H61.7068V34.0521H58.1409Z" fill="#262626" style="fill:#262626;fill:color(display-p3 0.1500 0.1500 0.1500);fill-opacity:1;"/>
|
||||
<path d="M73.5885 17.1632H74.3809V20.4948H72.796C69.6264 20.4948 68.6029 22.9687 68.6029 25.5747V34.0521H65.0371V17.1632H68.2067L68.6029 19.7031C69.4613 18.2847 70.815 17.1632 73.5885 17.1632Z" fill="#262626" style="fill:#262626;fill:color(display-p3 0.1500 0.1500 0.1500);fill-opacity:1;"/>
|
||||
<path d="M83.632 34.25C78.3163 34.25 74.9816 30.8194 74.9816 25.6406C74.9816 20.4288 78.3163 16.9653 83.3019 16.9653C88.1884 16.9653 91.457 20.066 91.5561 25.0139C91.5561 25.4427 91.5231 25.9045 91.457 26.3663H78.7125V26.5972C78.8116 29.467 80.6275 31.3472 83.4339 31.3472C85.613 31.3472 87.1979 30.2587 87.6931 28.3785H91.2589C90.6646 31.7101 87.8252 34.25 83.632 34.25ZM78.8446 23.7604H87.8582C87.561 21.2535 85.8112 19.8351 83.3349 19.8351C81.0567 19.8351 79.1087 21.3524 78.8446 23.7604Z" fill="#262626" style="fill:#262626;fill:color(display-p3 0.1500 0.1500 0.1500);fill-opacity:1;"/>
|
||||
<path d="M102.033 34.25C96.9151 34.25 93.6465 30.9184 93.6465 25.6406C93.6465 20.4288 97.0142 16.9653 102.132 16.9653C106.49 16.9653 109.197 19.3733 109.891 23.1997H106.16C105.698 21.2205 104.278 20 102.066 20C99.1933 20 97.3113 22.309 97.3113 25.6406C97.3113 28.9392 99.1933 31.2153 102.066 31.2153C104.245 31.2153 105.698 29.9618 106.127 28.0156H109.891C109.23 31.842 106.358 34.25 102.033 34.25Z" fill="#262626" style="fill:#262626;fill:color(display-p3 0.1500 0.1500 0.1500);fill-opacity:1;"/>
|
||||
<path d="M121.006 17.1632H121.799V20.4948H120.214C117.044 20.4948 116.021 22.9687 116.021 25.5747V34.0521H112.455V17.1632H115.625L116.021 19.7031C116.879 18.2847 118.233 17.1632 121.006 17.1632Z" fill="#262626" style="fill:#262626;fill:color(display-p3 0.1500 0.1500 0.1500);fill-opacity:1;"/>
|
||||
<path d="M130.614 16.9653C135.104 16.9653 137.679 19.1094 137.679 23.1007V34.0521H134.576L134.279 31.6441C133.123 33.1615 131.505 34.25 128.831 34.25C125.133 34.25 122.657 32.4358 122.657 29.3021C122.657 25.8385 125.166 23.8924 129.92 23.8924H134.147V22.8698C134.147 20.9896 132.793 19.8351 130.449 19.8351C128.336 19.8351 126.916 20.8247 126.652 22.309H123.152C123.515 19.0104 126.355 16.9653 130.614 16.9653ZM129.425 31.4792C132.397 31.4792 134.114 29.7309 134.147 27.125V26.5312H129.722C127.51 26.5312 126.289 27.3559 126.289 29.0712C126.289 30.4896 127.477 31.4792 129.425 31.4792Z" fill="#262626" style="fill:#262626;fill:color(display-p3 0.1500 0.1500 0.1500);fill-opacity:1;"/>
|
||||
<path d="M144.653 34.0521L139.139 17.1632H142.903L146.766 30.0937L150.629 17.1632H153.897L157.595 30.0937L161.59 17.1632H165.222L159.609 34.0521H155.779L152.214 22.5729L148.516 34.0521H144.653Z" fill="#262626" style="fill:#262626;fill:color(display-p3 0.1500 0.1500 0.1500);fill-opacity:1;"/>
|
||||
<path d="M166.934 34.0521V10.9618H170.5V34.0521H166.934Z" fill="#262626" style="fill:#262626;fill:color(display-p3 0.1500 0.1500 0.1500);fill-opacity:1;"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.8 KiB |
+427
@@ -0,0 +1,427 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>pdf-inspector — PDF classification & text extraction, no OCR</title>
|
||||
<meta name="description" content="Fast Rust library that classifies PDFs (text-based vs scanned) and extracts clean Markdown — no OCR, no ML models. Bindings for Rust, Python, and Node.js.">
|
||||
<meta property="og:title" content="pdf-inspector">
|
||||
<meta property="og:description" content="Classify PDFs and extract clean Markdown in milliseconds. No OCR. No ML. Pure Rust.">
|
||||
<meta property="og:type" content="website">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='.9em' font-size='90'%3E%F0%9F%93%84%3C/text%3E%3C/svg%3E">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;12..96,600;12..96,800&family=Hanken+Grotesk:wght@400;500;600&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--paper: #f4efe4;
|
||||
--paper-2: #eee7d8;
|
||||
--ink: #1b1712;
|
||||
--ink-soft: #4a433a;
|
||||
--muted: #8b8375;
|
||||
--line: #d9cfbb;
|
||||
--accent: #dd3f22;
|
||||
--accent-deep: #b32d15;
|
||||
--card: #faf6ec;
|
||||
--display: "Bricolage Grotesque", serif;
|
||||
--body: "Hanken Grotesk", sans-serif;
|
||||
--mono: "JetBrains Mono", monospace;
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
background: var(--paper);
|
||||
color: var(--ink);
|
||||
font-family: var(--body);
|
||||
font-size: 17px;
|
||||
line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
overflow-x: hidden;
|
||||
background-image:
|
||||
radial-gradient(circle at 1px 1px, rgba(27,23,18,0.05) 1px, transparent 0);
|
||||
background-size: 22px 22px;
|
||||
}
|
||||
::selection { background: var(--accent); color: var(--paper); }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
|
||||
.wrap { max-width: 1120px; margin: 0 auto; padding: 0 28px; }
|
||||
|
||||
/* ── nav ── */
|
||||
nav {
|
||||
position: sticky; top: 0; z-index: 50;
|
||||
background: rgba(244,239,228,0.82);
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.nav-in { display: flex; align-items: center; gap: 22px; height: 60px; }
|
||||
.brand { font-family: var(--mono); font-weight: 700; font-size: 15px; letter-spacing: -0.02em; display: flex; align-items: center; gap: 9px; }
|
||||
.brand .dot { width: 9px; height: 9px; background: var(--accent); border-radius: 50%; box-shadow: 0 0 0 3px rgba(221,63,34,0.18); }
|
||||
.nav-links { margin-left: auto; display: flex; gap: 24px; align-items: center; font-size: 14.5px; font-weight: 500; }
|
||||
.nav-links a { color: var(--ink-soft); transition: color .15s; }
|
||||
.nav-links a:hover { color: var(--accent); }
|
||||
.nav-gh { border: 1px solid var(--ink); border-radius: 999px; padding: 6px 15px; color: var(--ink) !important; transition: all .15s; }
|
||||
.nav-gh:hover { background: var(--ink); color: var(--paper) !important; }
|
||||
@media (max-width: 680px) { .nav-links .hide-sm { display: none; } }
|
||||
|
||||
/* ── hero ── */
|
||||
header { padding: 74px 0 40px; position: relative; }
|
||||
.eyebrow { font-family: var(--mono); font-size: 12.5px; letter-spacing: 0.16em; text-transform: uppercase; color: var(--accent-deep); margin-bottom: 22px; }
|
||||
h1 {
|
||||
font-family: var(--display);
|
||||
font-weight: 800;
|
||||
font-size: clamp(2.9rem, 8vw, 6.1rem);
|
||||
line-height: 0.96;
|
||||
letter-spacing: -0.035em;
|
||||
max-width: 15ch;
|
||||
}
|
||||
h1 .em { color: var(--accent); font-style: normal; position: relative; }
|
||||
h1 .strike { position: relative; white-space: nowrap; }
|
||||
h1 .strike::after { content: ""; position: absolute; left: -2%; right: -2%; top: 54%; height: 0.09em; background: var(--accent); transform: rotate(-3deg); }
|
||||
.lede { margin-top: 28px; font-size: clamp(1.05rem, 2.2vw, 1.32rem); color: var(--ink-soft); max-width: 46ch; line-height: 1.5; }
|
||||
.lede b { color: var(--ink); font-weight: 600; }
|
||||
|
||||
.hero-grid { display: grid; grid-template-columns: 1.15fr 0.85fr; gap: 48px; align-items: end; }
|
||||
@media (max-width: 880px) { .hero-grid { grid-template-columns: 1fr; gap: 40px; } }
|
||||
|
||||
/* readout card */
|
||||
.readout {
|
||||
background: var(--ink); color: var(--paper);
|
||||
border-radius: 14px; padding: 22px 22px 20px;
|
||||
font-family: var(--mono); font-size: 13px;
|
||||
box-shadow: 14px 14px 0 rgba(27,23,18,0.09);
|
||||
position: relative;
|
||||
}
|
||||
.readout .rlabel { color: #b8ad98; font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; margin-bottom: 16px; display: flex; justify-content: space-between; }
|
||||
.readout .rrow { display: flex; justify-content: space-between; align-items: center; padding: 9px 0; border-top: 1px solid rgba(255,255,255,0.09); }
|
||||
.readout .rrow:first-of-type { border-top: none; }
|
||||
.readout .k { color: #cfc6b3; }
|
||||
.readout .v { font-weight: 700; }
|
||||
.readout .v.hot { color: var(--accent); }
|
||||
.bar { height: 6px; background: rgba(255,255,255,0.1); border-radius: 3px; overflow: hidden; margin-top: 3px; width: 96px; }
|
||||
.bar > i { display: block; height: 100%; background: var(--accent); border-radius: 3px; }
|
||||
|
||||
/* ── install row ── */
|
||||
.installs { display: grid; grid-template-columns: repeat(3,1fr); gap: 14px; margin-top: 54px; }
|
||||
@media (max-width: 720px) { .installs { grid-template-columns: 1fr; } }
|
||||
.inst {
|
||||
background: var(--card); border: 1px solid var(--line); border-radius: 11px;
|
||||
padding: 15px 17px; transition: transform .16s, border-color .16s, box-shadow .16s;
|
||||
cursor: pointer;
|
||||
}
|
||||
.inst:hover { transform: translateY(-3px); border-color: var(--accent); box-shadow: 0 8px 22px rgba(27,23,18,0.07); }
|
||||
.inst .reg { font-family: var(--mono); font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); margin-bottom: 8px; display: flex; justify-content: space-between; }
|
||||
.inst code { font-family: var(--mono); font-size: 14px; color: var(--ink); font-weight: 500; }
|
||||
.inst .arrow { color: var(--accent); opacity: 0; transition: opacity .16s; }
|
||||
.inst:hover .arrow { opacity: 1; }
|
||||
|
||||
/* ── section scaffold ── */
|
||||
section { padding: 66px 0; border-top: 1px solid var(--line); }
|
||||
.sec-head { display: flex; align-items: baseline; gap: 16px; margin-bottom: 40px; flex-wrap: wrap; }
|
||||
.sec-num { font-family: var(--mono); font-size: 13px; color: var(--accent); font-weight: 700; }
|
||||
.sec-title { font-family: var(--display); font-weight: 600; font-size: clamp(1.7rem, 4vw, 2.6rem); letter-spacing: -0.025em; }
|
||||
.sec-sub { color: var(--ink-soft); max-width: 52ch; font-size: 1.02rem; }
|
||||
|
||||
/* ── features ── */
|
||||
.feat-grid { display: grid; grid-template-columns: repeat(4,1fr); gap: 1px; background: var(--line); border: 1px solid var(--line); border-radius: 14px; overflow: hidden; }
|
||||
@media (max-width: 900px) { .feat-grid { grid-template-columns: repeat(2,1fr); } }
|
||||
@media (max-width: 560px) { .feat-grid { grid-template-columns: 1fr; } }
|
||||
.feat { background: var(--card); padding: 24px 22px; transition: background .16s; }
|
||||
.feat:hover { background: #fff; }
|
||||
.feat .fn { font-family: var(--mono); font-size: 12px; color: var(--accent); font-weight: 700; }
|
||||
.feat h3 { font-family: var(--display); font-weight: 600; font-size: 1.16rem; margin: 12px 0 8px; letter-spacing: -0.01em; }
|
||||
.feat p { font-size: 14.5px; color: var(--ink-soft); line-height: 1.5; }
|
||||
|
||||
/* ── benchmark ── */
|
||||
.bench {
|
||||
border: 1px solid var(--line); border-radius: 14px; overflow: hidden;
|
||||
background: var(--card);
|
||||
}
|
||||
table { width: 100%; border-collapse: collapse; font-size: 15px; }
|
||||
thead th { font-family: var(--mono); font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); text-align: right; padding: 15px 18px; background: var(--paper-2); border-bottom: 1px solid var(--line); font-weight: 500; }
|
||||
thead th:first-child { text-align: left; }
|
||||
tbody td { padding: 14px 18px; text-align: right; font-family: var(--mono); border-bottom: 1px solid var(--line); }
|
||||
tbody td:first-child { text-align: left; font-family: var(--body); font-weight: 500; }
|
||||
tbody tr:last-child td { border-bottom: none; }
|
||||
tbody tr.us { background: rgba(221,63,34,0.06); }
|
||||
tbody tr.us td:first-child { color: var(--accent-deep); font-weight: 700; }
|
||||
tbody tr.us td:first-child::before { content: "▸ "; color: var(--accent); }
|
||||
.bench-foot { padding: 15px 18px; font-size: 13.5px; color: var(--ink-soft); background: var(--paper-2); border-top: 1px solid var(--line); }
|
||||
.bench-wrap { overflow-x: auto; }
|
||||
.callouts { display: grid; grid-template-columns: 1fr; gap: 16px; margin-top: 22px; }
|
||||
.callout { border-left: 3px solid var(--accent); padding: 4px 0 4px 16px; }
|
||||
.callout .ct { font-family: var(--mono); font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--accent-deep); margin-bottom: 5px; }
|
||||
.callout p { font-size: 14.5px; color: var(--ink-soft); }
|
||||
|
||||
/* ── quickstart tabs ── */
|
||||
.tabs input { position: absolute; opacity: 0; pointer-events: none; }
|
||||
.tablist { display: flex; gap: 6px; margin-bottom: 0; }
|
||||
.tablist label {
|
||||
font-family: var(--mono); font-size: 13px; font-weight: 500;
|
||||
padding: 10px 18px; cursor: pointer; color: var(--muted);
|
||||
border: 1px solid var(--line); border-bottom: none;
|
||||
border-radius: 9px 9px 0 0; background: var(--paper-2); transition: all .15s;
|
||||
}
|
||||
.tablist label:hover { color: var(--ink); }
|
||||
.panel { display: none; }
|
||||
.code {
|
||||
background: var(--ink); border-radius: 0 12px 12px 12px;
|
||||
padding: 22px 24px; overflow-x: auto;
|
||||
font-family: var(--mono); font-size: 13.5px; line-height: 1.7;
|
||||
color: #e9e2d3;
|
||||
box-shadow: 12px 12px 0 rgba(27,23,18,0.07);
|
||||
}
|
||||
.code .cm { color: #8a8069; }
|
||||
.code .kw { color: #ff9f7a; }
|
||||
.code .st { color: #cbb78a; }
|
||||
.code .fn { color: #f4efe4; font-weight: 700; }
|
||||
#t-rust:checked ~ .tablist label[for=t-rust],
|
||||
#t-py:checked ~ .tablist label[for=t-py],
|
||||
#t-node:checked ~ .tablist label[for=t-node],
|
||||
#t-cli:checked ~ .tablist label[for=t-cli] {
|
||||
background: var(--ink); color: var(--paper); border-color: var(--ink);
|
||||
}
|
||||
#t-rust:checked ~ .panels #p-rust,
|
||||
#t-py:checked ~ .panels #p-py,
|
||||
#t-node:checked ~ .panels #p-node,
|
||||
#t-cli:checked ~ .panels #p-cli { display: block; }
|
||||
.code a.ref { color: #ff9f7a; border-bottom: 1px dotted #ff9f7a; }
|
||||
|
||||
/* ── closing split (OSS vs hosted) ── */
|
||||
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||
@media (max-width: 780px) { .split { grid-template-columns: 1fr; } }
|
||||
.path { border: 1px solid var(--line); border-radius: 16px; padding: 32px 30px; background: var(--card); display: flex; flex-direction: column; }
|
||||
.path .ptag { font-family: var(--mono); font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--muted); margin-bottom: 15px; }
|
||||
.path h3 { font-family: var(--display); font-weight: 600; font-size: 1.5rem; letter-spacing: -0.02em; line-height: 1.05; margin-bottom: 12px; }
|
||||
.path p { color: var(--ink-soft); font-size: 15px; line-height: 1.5; flex: 1; margin-bottom: 24px; }
|
||||
.pbtns { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
.path-pro { background: var(--ink); border-color: var(--ink); box-shadow: 14px 14px 0 rgba(27,23,18,0.09); }
|
||||
.path-pro .ptag { color: #b8ad98; }
|
||||
.path-pro .ptag b { color: var(--accent); font-weight: 700; }
|
||||
.path-pro h3 { color: var(--paper); }
|
||||
.path-pro p { color: #cfc6b3; }
|
||||
.btn { font-family: var(--mono); font-size: 14px; font-weight: 500; padding: 13px 24px; border-radius: 999px; transition: all .15s; border: 1px solid var(--ink); }
|
||||
.btn-p { background: var(--accent); border-color: var(--accent); color: var(--paper); }
|
||||
.btn-p:hover { background: var(--accent-deep); border-color: var(--accent-deep); }
|
||||
.btn-s:hover { background: var(--ink); color: var(--paper); }
|
||||
.btn-pro { background: var(--accent); border-color: var(--accent); color: var(--paper); }
|
||||
.btn-pro:hover { background: var(--accent-deep); border-color: var(--accent-deep); }
|
||||
.btn-ghost { color: var(--paper); border-color: rgba(255,255,255,0.3); }
|
||||
.btn-ghost:hover { border-color: var(--paper); background: rgba(255,255,255,0.08); }
|
||||
.fc-mark { height: 34px; width: auto; display: block; margin-bottom: 20px; }
|
||||
.fc-wordmark { height: 15px; width: auto; vertical-align: -2px; transition: opacity .15s; }
|
||||
.fc-wordmark:hover { opacity: 0.65; }
|
||||
footer { border-top: 1px solid var(--line); padding: 34px 0; font-size: 14px; color: var(--muted); }
|
||||
.foot-in { display: flex; justify-content: space-between; align-items: center; gap: 18px; flex-wrap: wrap; }
|
||||
.foot-in a { color: var(--ink-soft); }
|
||||
.foot-in a:hover { color: var(--accent); }
|
||||
.foot-links { display: flex; gap: 20px; font-family: var(--mono); font-size: 13px; }
|
||||
|
||||
/* ── load animation ── */
|
||||
.reveal { opacity: 0; transform: translateY(14px); animation: rise .7s cubic-bezier(.2,.7,.3,1) forwards; }
|
||||
@keyframes rise { to { opacity: 1; transform: none; } }
|
||||
.d1 { animation-delay: .05s; } .d2 { animation-delay: .15s; } .d3 { animation-delay: .25s; }
|
||||
.d4 { animation-delay: .35s; } .d5 { animation-delay: .45s; } .d6 { animation-delay: .55s; }
|
||||
@media (prefers-reduced-motion: reduce) { .reveal { animation: none; opacity: 1; transform: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<nav>
|
||||
<div class="wrap nav-in">
|
||||
<a href="#top" class="brand"><span class="dot"></span>pdf-inspector</a>
|
||||
<div class="nav-links">
|
||||
<a href="#features" class="hide-sm">Features</a>
|
||||
<a href="#benchmark" class="hide-sm">Benchmark</a>
|
||||
<a href="#start">Quick start</a>
|
||||
<a class="nav-gh" href="https://github.com/firecrawl/pdf-inspector">GitHub ↗</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<header id="top">
|
||||
<div class="wrap hero-grid">
|
||||
<div>
|
||||
<div class="eyebrow reveal d1">Rust · Python · Node · CLI</div>
|
||||
<h1 class="reveal d2">Classify PDFs. Extract Markdown. <span class="strike">No OCR.</span></h1>
|
||||
<p class="lede reveal d3">A fast Rust library that tells text-based PDFs from scanned ones, then extracts position-aware text and clean Markdown — <b>locally, in milliseconds</b>. Skip the OCR bill for the ~54% of PDFs that never needed it.</p>
|
||||
</div>
|
||||
<div class="readout reveal d4" aria-hidden="true">
|
||||
<div class="rlabel"><span>classify_pdf()</span><span>~12ms</span></div>
|
||||
<div class="rrow"><span class="k">type</span><span class="v hot">TextBased</span></div>
|
||||
<div class="rrow"><span class="k">confidence</span><span class="v">0.98</span></div>
|
||||
<div class="rrow"><span class="k">needs_ocr</span><span class="v">false</span></div>
|
||||
<div class="rrow"><span class="k">route</span><span class="v">local → md</span></div>
|
||||
<div class="rrow" style="border-top:1px solid rgba(255,255,255,.09);padding-top:13px">
|
||||
<span class="k">signal</span>
|
||||
<span class="bar"><i style="width:98%"></i></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="installs">
|
||||
<a class="inst reveal d4" href="https://crates.io/crates/pdf-inspector">
|
||||
<div class="reg"><span>crates.io</span><span class="arrow">↗</span></div>
|
||||
<code>cargo add pdf-inspector</code>
|
||||
</a>
|
||||
<a class="inst reveal d5" href="https://pypi.org/project/pdf-inspector/">
|
||||
<div class="reg"><span>PyPI</span><span class="arrow">↗</span></div>
|
||||
<code>pip install pdf-inspector</code>
|
||||
</a>
|
||||
<a class="inst reveal d6" href="https://www.npmjs.com/package/@firecrawl/pdf-inspector">
|
||||
<div class="reg"><span>npm</span><span class="arrow">↗</span></div>
|
||||
<code>npm i @firecrawl/pdf-inspector</code>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section id="features">
|
||||
<div class="wrap">
|
||||
<div class="sec-head">
|
||||
<span class="sec-num">01</span>
|
||||
<h2 class="sec-title">Built for routing, not just reading</h2>
|
||||
</div>
|
||||
<div class="feat-grid">
|
||||
<div class="feat"><div class="fn">01</div><h3>Smart classification</h3><p>TextBased, Scanned, ImageBased, or Mixed in ~10–50ms by sampling content streams. Returns a confidence score and per-page OCR routing.</p></div>
|
||||
<div class="feat"><div class="fn">02</div><h3>Position-aware text</h3><p>Extraction with font info, X/Y coordinates, and automatic multi-column reading order.</p></div>
|
||||
<div class="feat"><div class="fn">03</div><h3>Markdown conversion</h3><p>Headings, bullet/numbered lists, code blocks, tables, bold/italic, URL linking, and page breaks.</p></div>
|
||||
<div class="feat"><div class="fn">04</div><h3>Table detection</h3><p>Rectangle-based detection from drawing ops plus heuristic alignment detection. Financial tables, footnotes, and cross-page continuations.</p></div>
|
||||
<div class="feat"><div class="fn">05</div><h3>CID font support</h3><p>ToUnicode CMap decoding for Type0/Identity-H fonts, with UTF-16BE, UTF-8, and Latin-1 encodings.</p></div>
|
||||
<div class="feat"><div class="fn">06</div><h3>Multi-column layout</h3><p>Newspaper-style column detection, sequential reading order, and right-to-left text support.</p></div>
|
||||
<div class="feat"><div class="fn">07</div><h3>Encoding checks</h3><p>Flags broken font encodings automatically so callers can fall back to OCR only when it's actually needed.</p></div>
|
||||
<div class="feat"><div class="fn">08</div><h3>Lightweight</h3><p>Pure Rust. No ML models, no external services. A single parse shared between detection and extraction.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="benchmark">
|
||||
<div class="wrap">
|
||||
<div class="sec-head">
|
||||
<span class="sec-num">02</span>
|
||||
<h2 class="sec-title">Fastest of the direct-text engines</h2>
|
||||
<p class="sec-sub">Evaluated on the <a href="https://github.com/opendataloader-project/opendataloader-bench" style="color:var(--accent-deep);border-bottom:1px solid var(--line)">opendataloader-bench</a> corpus (200 PDFs). Local engines without model-based PDF parsing; OCR disabled. Higher is better.</p>
|
||||
</div>
|
||||
<div class="bench">
|
||||
<div class="bench-wrap">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Engine</th><th>Overall</th><th>Reading order</th><th>Tables</th><th>Headings</th><th>200 docs</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr class="us"><td>pdf-inspector</td><td>0.875</td><td>0.915</td><td>0.814</td><td>0.788</td><td>2.8s</td></tr>
|
||||
<tr><td>liteparse</td><td>0.870</td><td>0.908</td><td>0.693</td><td>0.811</td><td>13.9s</td></tr>
|
||||
<tr><td>opendataloader</td><td>0.843</td><td>0.912</td><td>0.489</td><td>0.760</td><td>9.8s</td></tr>
|
||||
<tr><td>pymupdf4llm</td><td>0.735</td><td>0.886</td><td>0.401</td><td>0.424</td><td>15.5s</td></tr>
|
||||
<tr><td>markitdown</td><td>0.583</td><td>0.879</td><td>0.000</td><td>0.000</td><td>6.7s</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="bench-foot">Refreshed July 16, 2026, on Apple M4 Pro. Speed is the median of three complete corpus runs.</div>
|
||||
</div>
|
||||
<div class="callouts">
|
||||
<div class="callout"><div class="ct">Best fit</div><p>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.</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="start">
|
||||
<div class="wrap">
|
||||
<div class="sec-head">
|
||||
<span class="sec-num">03</span>
|
||||
<h2 class="sec-title">Three lines to Markdown</h2>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<input type="radio" name="tab" id="t-rust" checked>
|
||||
<input type="radio" name="tab" id="t-py">
|
||||
<input type="radio" name="tab" id="t-node">
|
||||
<input type="radio" name="tab" id="t-cli">
|
||||
<div class="tablist">
|
||||
<label for="t-rust">Rust</label>
|
||||
<label for="t-py">Python</label>
|
||||
<label for="t-node">Node.js</label>
|
||||
<label for="t-cli">CLI</label>
|
||||
</div>
|
||||
<div class="panels">
|
||||
<div class="panel" id="p-rust"><pre class="code"><span class="kw">use</span> pdf_inspector::process_pdf;
|
||||
|
||||
<span class="kw">let</span> result = <span class="fn">process_pdf</span>(<span class="st">"document.pdf"</span>)?;
|
||||
<span class="fn">println!</span>(<span class="st">"Type: {:?}"</span>, result.pdf_type);
|
||||
<span class="kw">if let</span> <span class="kw">Some</span>(markdown) = &result.markdown {
|
||||
<span class="fn">println!</span>(<span class="st">"{}"</span>, markdown);
|
||||
}
|
||||
<span class="cm">// full reference → </span><a class="ref" href="https://github.com/firecrawl/pdf-inspector/blob/main/docs/rust-api.md">docs/rust-api.md</a></pre></div>
|
||||
<div class="panel" id="p-py"><pre class="code"><span class="kw">import</span> pdf_inspector
|
||||
|
||||
result = pdf_inspector.<span class="fn">process_pdf</span>(<span class="st">"document.pdf"</span>)
|
||||
<span class="fn">print</span>(result.pdf_type) <span class="cm"># "text_based" | "scanned" | "image_based" | "mixed"</span>
|
||||
<span class="fn">print</span>(result.markdown) <span class="cm"># Markdown string or None</span>
|
||||
<span class="cm"># full reference → </span><a class="ref" href="https://github.com/firecrawl/pdf-inspector/blob/main/docs/python.md">docs/python.md</a></pre></div>
|
||||
<div class="panel" id="p-node"><pre class="code"><span class="kw">import</span> { readFileSync } <span class="kw">from</span> <span class="st">'fs'</span>;
|
||||
<span class="kw">import</span> { processPdf } <span class="kw">from</span> <span class="st">'@firecrawl/pdf-inspector'</span>;
|
||||
|
||||
<span class="kw">const</span> result = <span class="fn">processPdf</span>(<span class="fn">readFileSync</span>(<span class="st">'document.pdf'</span>));
|
||||
console.<span class="fn">log</span>(result.pdfType); <span class="cm">// "TextBased" | "Scanned" | ...</span>
|
||||
console.<span class="fn">log</span>(result.markdown); <span class="cm">// Markdown string or null</span>
|
||||
<span class="cm">// full reference → </span><a class="ref" href="https://github.com/firecrawl/pdf-inspector/blob/main/napi/README.md">napi/README.md</a></pre></div>
|
||||
<div class="panel" id="p-cli"><pre class="code"><span class="cm"># install the CLI tools</span>
|
||||
cargo <span class="fn">install</span> pdf-inspector
|
||||
|
||||
<span class="cm"># convert a PDF to Markdown</span>
|
||||
<span class="fn">pdf2md</span> document.pdf
|
||||
|
||||
<span class="cm"># classify only — is it scanned?</span>
|
||||
<span class="fn">detect-pdf</span> document.pdf --analyze --json
|
||||
|
||||
<span class="cm"># structured output for pipelines</span>
|
||||
<span class="fn">pdf2md</span> document.pdf --json</pre></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="close">
|
||||
<div class="wrap">
|
||||
<div class="sec-head">
|
||||
<span class="sec-num">04</span>
|
||||
<h2 class="sec-title">Two ways to parse</h2>
|
||||
<p class="sec-sub">Run the classifier locally for text-based PDFs; hand the scanned, OCR, and at-scale work to Firecrawl.</p>
|
||||
</div>
|
||||
<div class="split">
|
||||
<div class="path">
|
||||
<div class="ptag">Open source · runs local</div>
|
||||
<h3>Use pdf-inspector yourself</h3>
|
||||
<p>Pure-Rust library and CLI. Classify and extract text-based PDFs on your own machine in milliseconds — no external calls, no OCR bill, MIT licensed.</p>
|
||||
<div class="pbtns">
|
||||
<a class="btn btn-p" href="https://github.com/firecrawl/pdf-inspector">Get started</a>
|
||||
<a class="btn btn-s" href="https://crates.io/crates/pdf-inspector">crates.io</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="path path-pro">
|
||||
<img class="fc-mark" src="assets/firecrawl-mark.svg" alt="Firecrawl" width="24" height="34">
|
||||
<div class="ptag"><b>Firecrawl Parse</b> · hosted API</div>
|
||||
<h3>Or let Firecrawl handle the hard ones</h3>
|
||||
<p>Scanned documents, OCR, DOCX / XLSX / HTML, and parsing at scale — clean, LLM-ready Markdown from one API call. The downstream route for everything local parsing can't reach.</p>
|
||||
<div class="pbtns">
|
||||
<a class="btn btn-pro" href="https://docs.firecrawl.dev/api-reference/endpoint/parse">Firecrawl Parse ↗</a>
|
||||
<a class="btn btn-ghost" href="https://firecrawl.dev">firecrawl.dev</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<div class="wrap foot-in">
|
||||
<div style="display:flex;align-items:center;gap:7px">Built by <a href="https://firecrawl.dev"><img class="fc-wordmark" src="assets/firecrawl-wordmark.svg" alt="Firecrawl"></a> · MIT licensed</div>
|
||||
<div class="foot-links">
|
||||
<a href="https://github.com/firecrawl/pdf-inspector">GitHub</a>
|
||||
<a href="https://crates.io/crates/pdf-inspector">crates.io</a>
|
||||
<a href="https://pypi.org/project/pdf-inspector/">PyPI</a>
|
||||
<a href="https://www.npmjs.com/package/@firecrawl/pdf-inspector">npm</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
+43
-2
@@ -11,6 +11,37 @@ use std::process;
|
||||
use std::time::Instant;
|
||||
|
||||
/// Escape a string for embedding in a JSON string value.
|
||||
fn format_detector_ocr_reasons(reasons: &std::collections::BTreeMap<u32, Vec<String>>) -> String {
|
||||
reasons
|
||||
.iter()
|
||||
.map(|(page, page_reasons)| {
|
||||
let reasons_json = page_reasons
|
||||
.iter()
|
||||
.map(|reason| format!(r#""{}""#, json_escape(reason)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
format!(r#"{{"page":{},"reasons":[{}]}}"#, page, reasons_json)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
|
||||
fn format_ocr_reasons_by_page(reasons: &[pdf_inspector::PageOcrReasons]) -> String {
|
||||
reasons
|
||||
.iter()
|
||||
.map(|entry| {
|
||||
let reasons_json = entry
|
||||
.reasons
|
||||
.iter()
|
||||
.map(|reason| format!(r#""{}""#, json_escape(reason)))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
format!(r#"{{"page":{},"reasons":[{}]}}"#, entry.page, reasons_json)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
}
|
||||
|
||||
fn json_escape(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len() + 16);
|
||||
for ch in s.chars() {
|
||||
@@ -117,11 +148,13 @@ fn run_analyze(pdf_path: &str, json_output: bool, start: Instant) {
|
||||
.iter()
|
||||
.map(|p| p.to_string())
|
||||
.collect();
|
||||
let ocr_reasons = format_ocr_reasons_by_page(&result.ocr_reasons_by_page);
|
||||
println!(
|
||||
r#"{{"pdf_type":"{}","page_count":{},"pages_needing_ocr":[{}],"is_complex":{},"pages_with_tables":[{}],"pages_with_columns":[{}],"detection_time_ms":{}}}"#,
|
||||
r#"{{"pdf_type":"{}","page_count":{},"pages_needing_ocr":[{}],"ocr_reasons_by_page":[{}],"is_complex":{},"pages_with_tables":[{}],"pages_with_columns":[{}],"detection_time_ms":{}}}"#,
|
||||
pdf_type_str(&result.pdf_type),
|
||||
result.page_count,
|
||||
ocr_pages.join(","),
|
||||
ocr_reasons,
|
||||
result.layout.is_complex,
|
||||
table_pages.join(","),
|
||||
col_pages.join(","),
|
||||
@@ -144,6 +177,9 @@ fn run_analyze(pdf_path: &str, json_output: bool, start: Instant) {
|
||||
println!("Page count: {}", result.page_count);
|
||||
if !result.pages_needing_ocr.is_empty() {
|
||||
println!("Pages needing OCR: {:?}", result.pages_needing_ocr);
|
||||
for entry in &result.ocr_reasons_by_page {
|
||||
println!(" page {}: {}", entry.page, entry.reasons.join(", "));
|
||||
}
|
||||
}
|
||||
println!();
|
||||
if result.layout.is_complex {
|
||||
@@ -184,8 +220,9 @@ fn run_detect_only(pdf_path: &str, json_output: bool, start: Instant) {
|
||||
.iter()
|
||||
.map(|p| p.to_string())
|
||||
.collect();
|
||||
let ocr_reasons = format_detector_ocr_reasons(&result.ocr_reasons_by_page);
|
||||
println!(
|
||||
r#"{{"pdf_type":"{}","page_count":{},"pages_sampled":{},"pages_with_text":{},"confidence":{:.2},"title":{},"ocr_recommended":{},"pages_needing_ocr":[{}],"detection_time_ms":{}}}"#,
|
||||
r#"{{"pdf_type":"{}","page_count":{},"pages_sampled":{},"pages_with_text":{},"confidence":{:.2},"title":{},"ocr_recommended":{},"pages_needing_ocr":[{}],"ocr_reasons_by_page":[{}],"detection_time_ms":{}}}"#,
|
||||
pdf_type_str(&result.pdf_type),
|
||||
result.page_count,
|
||||
result.pages_sampled,
|
||||
@@ -198,6 +235,7 @@ fn run_detect_only(pdf_path: &str, json_output: bool, start: Instant) {
|
||||
.unwrap_or_else(|| "null".to_string()),
|
||||
result.ocr_recommended,
|
||||
ocr_pages.join(","),
|
||||
ocr_reasons,
|
||||
elapsed.as_millis()
|
||||
);
|
||||
} else {
|
||||
@@ -232,6 +270,9 @@ fn run_detect_only(pdf_path: &str, json_output: bool, start: Instant) {
|
||||
result.pages_needing_ocr, result.page_count
|
||||
);
|
||||
}
|
||||
for (page, reasons) in &result.ocr_reasons_by_page {
|
||||
println!(" page {}: {}", page, reasons.join(", "));
|
||||
}
|
||||
}
|
||||
if let Some(title) = &result.title {
|
||||
println!("Title: {}", title);
|
||||
|
||||
@@ -206,8 +206,12 @@ fn main() {
|
||||
eprintln!(" --json Output result as JSON");
|
||||
eprintln!(" --items-json Output positioned TextItem JSON");
|
||||
eprintln!(" --raw Output only markdown (no headers)");
|
||||
eprintln!(
|
||||
" --compact Collapse token-heavy source formatting such as dot leaders"
|
||||
);
|
||||
eprintln!(" --pages Insert page break markers (<!-- Page N -->)");
|
||||
eprintln!(" --select-pages N Only process specified pages (e.g. 1,3,5-10)");
|
||||
eprintln!(" --password PW Password for an encrypted PDF");
|
||||
eprintln!(" --detect-only Only detect PDF type (no extraction)");
|
||||
eprintln!(" --analyze Detect + extract + layout analysis (no markdown)");
|
||||
process::exit(1);
|
||||
@@ -217,10 +221,21 @@ fn main() {
|
||||
let json_output = args.iter().any(|a| a == "--json");
|
||||
let items_json_output = args.iter().any(|a| a == "--items-json");
|
||||
let raw_output = args.iter().any(|a| a == "--raw");
|
||||
let compact_output = args.iter().any(|a| a == "--compact");
|
||||
let page_numbers = args.iter().any(|a| a == "--pages");
|
||||
let detect_only = args.iter().any(|a| a == "--detect-only");
|
||||
let analyze = args.iter().any(|a| a == "--analyze");
|
||||
|
||||
// Parse --password value
|
||||
let password = args.iter().position(|a| a == "--password").map(|i| {
|
||||
args.get(i + 1)
|
||||
.unwrap_or_else(|| {
|
||||
eprintln!("Error: --password requires a value");
|
||||
process::exit(1);
|
||||
})
|
||||
.clone()
|
||||
});
|
||||
|
||||
// Parse --select-pages value
|
||||
let page_filter = args
|
||||
.iter()
|
||||
@@ -265,10 +280,14 @@ fn main() {
|
||||
};
|
||||
|
||||
let mut options = PdfOptions::new().mode(process_mode);
|
||||
if compact_output {
|
||||
options.markdown.profile = pdf_inspector::MarkdownProfile::Compact;
|
||||
}
|
||||
options.markdown.include_page_numbers = page_numbers;
|
||||
if let Some(pages) = page_filter {
|
||||
options.page_filter = Some(pages);
|
||||
}
|
||||
options.password = password;
|
||||
|
||||
match process_pdf_with_options(pdf_path, options) {
|
||||
Ok(result) => {
|
||||
|
||||
+111
-2
@@ -60,6 +60,10 @@ pub struct PdfTypeResult {
|
||||
/// 1-indexed page numbers that need OCR (image-only or insufficient text).
|
||||
/// Empty for TextBased. All pages for Scanned/ImageBased. Specific pages for Mixed.
|
||||
pub pages_needing_ocr: Vec<u32>,
|
||||
/// Per-page explanation for `pages_needing_ocr`: 1-indexed page → reason
|
||||
/// codes (`scanned`, `no_text`, `vector_text`, `suspected_garbled_text`).
|
||||
/// Only contains pages that need OCR.
|
||||
pub ocr_reasons_by_page: std::collections::BTreeMap<u32, Vec<String>>,
|
||||
}
|
||||
|
||||
/// Configuration for PDF type detection
|
||||
@@ -382,7 +386,12 @@ pub(crate) fn detect_from_document(
|
||||
let analysis = if let Some(cached) = analysis_cache.get(&page_num) {
|
||||
cached.clone()
|
||||
} else if let Some(&page_id) = pages.get(&page_num) {
|
||||
analyze_page_content(doc, page_id)
|
||||
// Cache the fresh analysis so the reason-classification pass
|
||||
// below sees the real signals (vector_text, etc.) instead of
|
||||
// defaulting to "scanned".
|
||||
let a = analyze_page_content(doc, page_id);
|
||||
analysis_cache.insert(page_num, a.clone());
|
||||
a
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
@@ -429,6 +438,9 @@ pub(crate) fn detect_from_document(
|
||||
let analysis = analyze_page_content(doc, page_id);
|
||||
if analysis.has_identity_h_no_tounicode || analysis.has_only_type3_fonts {
|
||||
pages_needing_ocr.push(page_num);
|
||||
// Cache so the reason pass reports suspected_garbled_text
|
||||
// rather than defaulting to "scanned".
|
||||
analysis_cache.insert(page_num, analysis);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -436,6 +448,19 @@ pub(crate) fn detect_from_document(
|
||||
pages_needing_ocr.sort();
|
||||
pages_needing_ocr.dedup();
|
||||
|
||||
// Explain each OCR-flagged page. Pages we analyzed get a signal-derived
|
||||
// reason; pages flagged only by whole-document classification (unsampled
|
||||
// pages of a Scanned/ImageBased doc) default to `scanned`.
|
||||
let mut ocr_reasons_by_page: std::collections::BTreeMap<u32, Vec<String>> =
|
||||
std::collections::BTreeMap::new();
|
||||
for &page_num in &pages_needing_ocr {
|
||||
let reasons = match analysis_cache.get(&page_num) {
|
||||
Some(analysis) => page_ocr_reasons(analysis),
|
||||
None => vec![crate::OCR_REASON_SCANNED],
|
||||
};
|
||||
ocr_reasons_by_page.insert(page_num, reasons.into_iter().map(String::from).collect());
|
||||
}
|
||||
|
||||
// Try to get title from metadata
|
||||
let title = get_document_title(doc);
|
||||
|
||||
@@ -448,6 +473,7 @@ pub(crate) fn detect_from_document(
|
||||
title,
|
||||
ocr_recommended,
|
||||
pages_needing_ocr,
|
||||
ocr_reasons_by_page,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -487,7 +513,7 @@ fn distribute_pages(n: u32, total: u32) -> Vec<u32> {
|
||||
}
|
||||
|
||||
/// Page content analysis result
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Default)]
|
||||
struct PageAnalysis {
|
||||
text_operator_count: u32,
|
||||
has_images: bool,
|
||||
@@ -523,6 +549,31 @@ struct PageAnalysis {
|
||||
has_decodable_text_fonts: bool,
|
||||
}
|
||||
|
||||
/// Explain *why* a page needs OCR, from its content analysis. Priority:
|
||||
/// undecodable fonts (`suspected_garbled_text`) and vector-outlined text
|
||||
/// (`vector_text`) come first because they persist even when a text layer is
|
||||
/// present; otherwise a page with no extractable text is `scanned` when an
|
||||
/// image backs it or `no_text` when nothing does.
|
||||
fn page_ocr_reasons(a: &PageAnalysis) -> Vec<&'static str> {
|
||||
let mut reasons = Vec::new();
|
||||
if a.has_identity_h_no_tounicode || a.has_only_type3_fonts {
|
||||
reasons.push(crate::OCR_REASON_SUSPECTED_GARBLED_TEXT);
|
||||
}
|
||||
if a.has_vector_text {
|
||||
reasons.push(crate::OCR_REASON_VECTOR_TEXT);
|
||||
}
|
||||
if reasons.is_empty() {
|
||||
let has_extractable_text = a.text_operator_count > 0 && a.unique_text_chars > 0;
|
||||
if !has_extractable_text && !a.has_images && !a.has_template_image {
|
||||
reasons.push(crate::OCR_REASON_NO_TEXT);
|
||||
} else {
|
||||
// Image-backed with no usable text, or too little text to trust.
|
||||
reasons.push(crate::OCR_REASON_SCANNED);
|
||||
}
|
||||
}
|
||||
reasons
|
||||
}
|
||||
|
||||
/// Extracted font information from a Resource dictionary entry.
|
||||
/// Stores the properties needed for decodability/identity-h checks
|
||||
/// without holding a reference to the document.
|
||||
@@ -1809,6 +1860,64 @@ fn get_document_title(doc: &Document) -> Option<String> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn page_ocr_reasons_classify() {
|
||||
// Scanned: no text, full-page image.
|
||||
let scanned = PageAnalysis {
|
||||
has_template_image: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(page_ocr_reasons(&scanned), vec![crate::OCR_REASON_SCANNED]);
|
||||
|
||||
// Image-only page (no template flag, but has an image).
|
||||
let image_only = PageAnalysis {
|
||||
has_images: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
page_ocr_reasons(&image_only),
|
||||
vec![crate::OCR_REASON_SCANNED]
|
||||
);
|
||||
|
||||
// No text, no image → no_text.
|
||||
let blank = PageAnalysis::default();
|
||||
assert_eq!(page_ocr_reasons(&blank), vec![crate::OCR_REASON_NO_TEXT]);
|
||||
|
||||
// Vector-outlined text.
|
||||
let vector = PageAnalysis {
|
||||
has_vector_text: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
page_ocr_reasons(&vector),
|
||||
vec![crate::OCR_REASON_VECTOR_TEXT]
|
||||
);
|
||||
|
||||
// Undecodable fonts → garbled, and it wins over the fall-through.
|
||||
let garbled = PageAnalysis {
|
||||
has_identity_h_no_tounicode: true,
|
||||
has_images: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
page_ocr_reasons(&garbled),
|
||||
vec![crate::OCR_REASON_SUSPECTED_GARBLED_TEXT]
|
||||
);
|
||||
|
||||
// A page with real extractable text and an image is not flagged here
|
||||
// as scanned/no_text (only reached for pages already needing OCR).
|
||||
let text_with_image = PageAnalysis {
|
||||
text_operator_count: 40,
|
||||
unique_text_chars: 120,
|
||||
has_images: true,
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
page_ocr_reasons(&text_with_image),
|
||||
vec![crate::OCR_REASON_SCANNED]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_content_operators() {
|
||||
let mut uchars = HashSet::new();
|
||||
|
||||
@@ -1187,10 +1187,37 @@ pub(crate) fn extract_text_from_operand(
|
||||
})();
|
||||
result.map(|text| {
|
||||
let text = clean_symbol_pua(text);
|
||||
let text = remap_texcm_math_symbols(text, base_font_name);
|
||||
normalize_cp1252_controls(text, use_cp1252_fallback)
|
||||
})
|
||||
}
|
||||
|
||||
/// Fix a known producer bug in "TeXCMMathsSymbols" subset fonts (IntechOpen
|
||||
/// and sibling academic pipelines): the Computer Modern symbol glyphs are
|
||||
/// misnamed after Latin lookalikes (equal → /onequarter, plus → /thorn, …)
|
||||
/// and the generated ToUnicode faithfully propagates the wrong names. The
|
||||
/// remap applies only to text decoded from that font, keyed on the glyphs'
|
||||
/// observed misnames.
|
||||
fn remap_texcm_math_symbols(text: String, base_font_name: Option<&str>) -> String {
|
||||
let is_texcm = base_font_name.is_some_and(|n| {
|
||||
let n = n.rsplit_once('+').map_or(n, |(_, s)| s);
|
||||
n.eq_ignore_ascii_case("TeXCMMathsSymbols")
|
||||
});
|
||||
if !is_texcm {
|
||||
return text;
|
||||
}
|
||||
text.chars()
|
||||
.map(|c| match c {
|
||||
'¼' => '=',
|
||||
'½' => '-',
|
||||
'þ' => '+',
|
||||
'ð' => '(',
|
||||
'Þ' => ')',
|
||||
_ => c,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn decode_single_byte_fallback(bytes: &[u8], use_cp1252_fallback: bool) -> String {
|
||||
bytes
|
||||
.iter()
|
||||
@@ -1409,6 +1436,21 @@ fn score_text(text: &str) -> i32 {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn texcm_math_symbols_remap() {
|
||||
assert_eq!(
|
||||
super::remap_texcm_math_symbols("S ¼ kB þ 1".into(), Some("EEKVNO+TeXCMMathsSymbols")),
|
||||
"S = kB + 1"
|
||||
);
|
||||
// Other fonts keep their genuine fractions/thorns.
|
||||
assert_eq!(
|
||||
super::remap_texcm_math_symbols("¼ cup þorn".into(), Some("Times-Roman")),
|
||||
"¼ cup þorn"
|
||||
);
|
||||
assert_eq!(super::remap_texcm_math_symbols("¼".into(), None), "¼");
|
||||
}
|
||||
|
||||
use super::*;
|
||||
use lopdf::dictionary;
|
||||
|
||||
|
||||
+199
-5
@@ -674,9 +674,24 @@ fn validate_and_build_columns(
|
||||
page: u32,
|
||||
center_assign: bool,
|
||||
) -> Vec<ColumnRegion> {
|
||||
// Compute Y range of the page
|
||||
let y_min = page_items.iter().map(|i| i.y).fold(f32::INFINITY, f32::min);
|
||||
let y_max = page_items
|
||||
// Compute the Y range from column-eligible items only — the same items
|
||||
// the histogram counted. Spanning items (full-width captions, titles)
|
||||
// are excluded from the projection, so letting them stretch the page's
|
||||
// vertical extent here would sink the overlap ratio for column regions
|
||||
// that legitimately occupy only part of the page (e.g. two-column text
|
||||
// below a figure).
|
||||
let x_span = page_items
|
||||
.iter()
|
||||
.map(|i| i.x + effective_width(i))
|
||||
.fold(f32::NEG_INFINITY, f32::max)
|
||||
- page_items.iter().map(|i| i.x).fold(f32::INFINITY, f32::min);
|
||||
let narrow: Vec<&&TextItem> = page_items
|
||||
.iter()
|
||||
.filter(|i| effective_width(i) <= x_span * 0.6)
|
||||
.collect();
|
||||
let span_items: &[&&TextItem] = if narrow.is_empty() { &[] } else { &narrow };
|
||||
let y_min = span_items.iter().map(|i| i.y).fold(f32::INFINITY, f32::min);
|
||||
let y_max = span_items
|
||||
.iter()
|
||||
.map(|i| i.y)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
@@ -722,6 +737,10 @@ fn validate_and_build_columns(
|
||||
(right_items.len(), left_items.len())
|
||||
};
|
||||
if larger < min_items || smaller < 3 {
|
||||
debug!(
|
||||
" valley rejected: counts smaller={} larger={}",
|
||||
smaller, larger
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -735,6 +754,7 @@ fn validate_and_build_columns(
|
||||
&right_items
|
||||
};
|
||||
if is_list_marker_column(smaller_items) {
|
||||
debug!(" valley rejected: list-marker column");
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -759,6 +779,13 @@ fn validate_and_build_columns(
|
||||
let overlap = (overlap_max - overlap_min).max(0.0);
|
||||
|
||||
if overlap / y_range < min_vertical_span {
|
||||
debug!(
|
||||
" valley rejected: overlap {:.0}/{:.0} = {:.2} < {:.2}",
|
||||
overlap,
|
||||
y_range,
|
||||
overlap / y_range,
|
||||
min_vertical_span
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
@@ -1133,6 +1160,41 @@ pub(crate) fn group_into_lines_with_thresholds(
|
||||
items: Vec<TextItem>,
|
||||
page_thresholds: &HashMap<u32, f32>,
|
||||
table_pages: &HashSet<u32>,
|
||||
) -> Vec<TextLine> {
|
||||
group_into_lines_with_thresholds_and_charts(
|
||||
items,
|
||||
page_thresholds,
|
||||
table_pages,
|
||||
&HashMap::new(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Like `group_into_lines_with_thresholds`, but items inside chart regions
|
||||
/// are excluded from column detection: chart text scattered across the page
|
||||
/// fills the gutter in the projection histogram, so two-column pages read as
|
||||
/// one column and same-baseline items from both columns fuse into one line
|
||||
/// (headings absorbed into the neighboring column's body text).
|
||||
pub(crate) fn group_into_lines_with_thresholds_and_charts(
|
||||
items: Vec<TextItem>,
|
||||
page_thresholds: &HashMap<u32, f32>,
|
||||
table_pages: &HashSet<u32>,
|
||||
chart_regions: &HashMap<u32, Vec<(f32, f32, f32, f32)>>,
|
||||
) -> Vec<TextLine> {
|
||||
group_into_lines_with_thresholds_and_regions(
|
||||
items,
|
||||
page_thresholds,
|
||||
table_pages,
|
||||
chart_regions,
|
||||
&HashMap::new(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn group_into_lines_with_thresholds_and_regions(
|
||||
items: Vec<TextItem>,
|
||||
page_thresholds: &HashMap<u32, f32>,
|
||||
table_pages: &HashSet<u32>,
|
||||
chart_regions: &HashMap<u32, Vec<(f32, f32, f32, f32)>>,
|
||||
image_regions: &HashMap<u32, Vec<super::reading_order::ImageRegion>>,
|
||||
) -> Vec<TextLine> {
|
||||
if items.is_empty() {
|
||||
return Vec::new();
|
||||
@@ -1159,8 +1221,67 @@ pub(crate) fn group_into_lines_with_thresholds(
|
||||
// Non-Canva pages use the default 0.10 threshold.
|
||||
let adaptive_threshold = page_thresholds.get(&page).copied().unwrap_or(0.10);
|
||||
|
||||
// Detect columns for this page
|
||||
let columns = detect_columns(&page_items, page, table_pages.contains(&page));
|
||||
// Image-backed region graphs recover local/asymmetric column flows
|
||||
// that a whole-page projection cannot represent. Charts already have
|
||||
// their own positioned-region ordering and therefore stay on that path.
|
||||
if !chart_regions.contains_key(&page) {
|
||||
let preliminary_columns =
|
||||
detect_columns(&page_items, page, table_pages.contains(&page));
|
||||
let detected_split =
|
||||
(preliminary_columns.len() == 2).then_some(preliminary_columns[0].x_max);
|
||||
if let Some(band) = image_regions.get(&page).and_then(|regions| {
|
||||
super::reading_order::infer_image_anchored_flow(
|
||||
&page_items,
|
||||
regions,
|
||||
detected_split,
|
||||
)
|
||||
}) {
|
||||
debug!(
|
||||
"page {}: image-anchored region graph split={:.1} y=[{:.1}..{:.1}]",
|
||||
page, band.split_x, band.y_bottom, band.y_top
|
||||
);
|
||||
for node in super::reading_order::build_region_graph(page_items, band) {
|
||||
debug!(
|
||||
"page {}: region node {:?} items={}",
|
||||
page,
|
||||
node.kind,
|
||||
node.items.len()
|
||||
);
|
||||
all_lines.extend(group_single_column(node.items, adaptive_threshold));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Detect columns for this page, blind to chart text.
|
||||
debug!(
|
||||
"page {}: grouping chart-aware={} regions={:?}",
|
||||
page,
|
||||
chart_regions.contains_key(&page),
|
||||
chart_regions.get(&page).map(|v| v
|
||||
.iter()
|
||||
.map(|&(a, b, c, d)| (a as i32, b as i32, c as i32, d as i32))
|
||||
.collect::<Vec<_>>())
|
||||
);
|
||||
let columns = match chart_regions.get(&page).filter(|r| !r.is_empty()) {
|
||||
Some(regions) => {
|
||||
let col_input: Vec<TextItem> = page_items
|
||||
.iter()
|
||||
.filter(|it| {
|
||||
let cx = it.x + it.width / 2.0;
|
||||
// Tight bounds: this only blinds the histogram to
|
||||
// chart-internal text; rows adjacent to the chart
|
||||
// belong to the column layout.
|
||||
!regions.iter().any(|&(x0, y0, x1, y1)| {
|
||||
cx >= x0 - 2.0 && cx <= x1 + 2.0 && it.y >= y0 - 2.0 && it.y <= y1 + 2.0
|
||||
})
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
detect_columns(&col_input, page, table_pages.contains(&page))
|
||||
}
|
||||
None => detect_columns(&page_items, page, table_pages.contains(&page)),
|
||||
};
|
||||
|
||||
if columns.len() <= 1 {
|
||||
// Single column - use simple sorting
|
||||
@@ -1446,6 +1567,56 @@ fn group_single_column(items: Vec<TextItem>, adaptive_threshold: f32) -> Vec<Tex
|
||||
}
|
||||
}
|
||||
}
|
||||
// Same baseline, but separated by a wide void, with the incoming
|
||||
// run starting alphabetic: the neighboring column's body text
|
||||
// sharing a y with this line, in gutters too narrow for column
|
||||
// detection. Both sides must be multi-word prose — TOC page
|
||||
// numbers, dot leaders, and outline-numbered table cells (which
|
||||
// start with digits) stay joined.
|
||||
if let Some(last_item) = last_line.items.last() {
|
||||
let gap = item.x - (last_item.x + last_item.width);
|
||||
if gap > (item.font_size.max(last_item.font_size) * 3.0).max(30.0)
|
||||
&& item
|
||||
.text
|
||||
.trim()
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|c| c.is_alphabetic())
|
||||
{
|
||||
// The incoming run must be substantial prose; the line
|
||||
// side may be short (a wrapped heading's last words).
|
||||
let incoming_wordy = {
|
||||
let t = item.text.trim();
|
||||
t.split_whitespace().count() >= 3
|
||||
&& t.chars().filter(|c| c.is_alphabetic()).count() >= 10
|
||||
};
|
||||
let line_text = last_line
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| i.text.trim())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let line_wordy = line_text.split_whitespace().count() >= 2
|
||||
&& line_text.chars().filter(|c| c.is_alphabetic()).count() >= 8;
|
||||
// Lowercase starts are mid-sentence continuations and
|
||||
// split on prose signals alone. Uppercase starts also
|
||||
// need a bold-style mismatch between the runs — a bold
|
||||
// heading beside regular body text — otherwise same-style
|
||||
// label rows (feature tiles, legends) would shatter.
|
||||
let starts_lower = item
|
||||
.text
|
||||
.trim()
|
||||
.chars()
|
||||
.next()
|
||||
.is_some_and(|c| c.is_lowercase());
|
||||
// The whole line must be bold (a heading), not merely
|
||||
// its last run — mixed bold-label/value rows stay joined.
|
||||
let style_mismatch = last_line.items.iter().all(|i| i.is_bold) && !item.is_bold;
|
||||
if line_wordy && incoming_wordy && (starts_lower || style_mismatch) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
true
|
||||
});
|
||||
|
||||
@@ -1519,6 +1690,29 @@ mod tests {
|
||||
items
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_baseline_wide_gap_lowercase_continuation_splits() {
|
||||
// Heading in the left column, mid-sentence body text from the right
|
||||
// column at the same y, separated by a wide void: two lines.
|
||||
let items = vec![
|
||||
make_item(1, 94.0, 242.0, "6.2. Expectations for Re-Hiring Staff"),
|
||||
make_item(1, 380.0, 242.0, "they had no plans to re-hire and more"),
|
||||
];
|
||||
let lines = group_single_column(items, 0.10);
|
||||
assert_eq!(lines.len(), 2, "independent column runs must not fuse");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_baseline_wide_gap_table_label_stays_joined() {
|
||||
// Outline-numbered cell content to the right: table-ish, keep joined.
|
||||
let items = vec![
|
||||
make_item(1, 94.0, 242.0, "2. Embracing complexity in"),
|
||||
make_item(1, 380.0, 242.0, "2.1 Systems thinking and practice"),
|
||||
];
|
||||
let lines = group_single_column(items, 0.10);
|
||||
assert_eq!(lines.len(), 1, "numbered table cells stay on one line");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn three_zone_layout_detected() {
|
||||
// Left months (x=15..330), right months (x=345..660), sidebar (x=675..800)
|
||||
|
||||
+428
-18
@@ -6,10 +6,11 @@ pub(crate) mod content_stream;
|
||||
mod fonts;
|
||||
mod layout;
|
||||
mod links;
|
||||
mod reading_order;
|
||||
pub(crate) mod underline;
|
||||
mod xobjects;
|
||||
|
||||
use crate::text_utils::is_rtl_text;
|
||||
use crate::text_utils::{is_cjk_char, is_rtl_text};
|
||||
use crate::tounicode::FontCMaps;
|
||||
use crate::types::{PageExtraction, PdfLine, PdfRect, TextItem};
|
||||
use crate::PdfError;
|
||||
@@ -28,6 +29,8 @@ pub(crate) use fonts::FontStyleCache;
|
||||
pub(crate) use layout::detect_columns;
|
||||
pub use layout::group_into_lines;
|
||||
pub(crate) use layout::group_into_lines_with_thresholds;
|
||||
pub(crate) use layout::group_into_lines_with_thresholds_and_charts;
|
||||
pub(crate) use layout::group_into_lines_with_thresholds_and_regions;
|
||||
pub(crate) use layout::is_newspaper_layout;
|
||||
pub(crate) use layout::ColumnRegion;
|
||||
|
||||
@@ -176,14 +179,89 @@ fn extract_positioned_text_impl(
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let ((mut items, rects, lines), has_gid_fonts, _coords_rotated) = extract_page_text_items(
|
||||
doc,
|
||||
page_id,
|
||||
*page_num,
|
||||
font_cmaps,
|
||||
include_invisible,
|
||||
&mut style_cache,
|
||||
)?;
|
||||
let ((mut items, mut rects, mut lines), has_gid_fonts, coords_rotated) =
|
||||
extract_page_text_items(
|
||||
doc,
|
||||
page_id,
|
||||
*page_num,
|
||||
font_cmaps,
|
||||
include_invisible,
|
||||
&mut style_cache,
|
||||
)?;
|
||||
// Clip to the visible page box: single-page extracts and imposed
|
||||
// spreads keep neighboring pages' content in the stream, positioned
|
||||
// outside the CropBox. Extracting it interleaves invisible text into
|
||||
// the page and poisons font statistics. Rotated pages are left alone
|
||||
// — their item coordinates are already transformed out of box space.
|
||||
let mut clipped_box: Option<(f32, f32, f32, f32)> = None;
|
||||
if !coords_rotated {
|
||||
if let Some((bx0, by0, bx1, by1)) = get_page_box(doc, page_id) {
|
||||
const TOL: f32 = 6.0;
|
||||
let outside = |it: &TextItem| {
|
||||
let cx = it.x + it.width / 2.0;
|
||||
!(cx >= bx0 - TOL && cx <= bx1 + TOL && it.y >= by0 - TOL && it.y <= by1 + TOL)
|
||||
};
|
||||
// Only clip when the off-page material reads as coherent text
|
||||
// (neighboring-page paragraphs). Curved/rotated display text
|
||||
// leaves short glyph fragments with artifact coordinates
|
||||
// outside the box, and those must stay.
|
||||
let off: Vec<&TextItem> = items.iter().filter(|it| outside(it)).collect();
|
||||
// Judge by character mass: paragraphs are dominated by long
|
||||
// word runs even when interleaved with short math fragments,
|
||||
// while glyph-confetti is short items through and through.
|
||||
let total_chars: usize = off.iter().map(|it| it.text.trim().chars().count()).sum();
|
||||
let wordy_chars: usize = off
|
||||
.iter()
|
||||
.map(|it| it.text.trim().chars().count())
|
||||
.filter(|&n| n >= 4)
|
||||
.sum();
|
||||
// Genuine neighboring-page content is cleanly separated from
|
||||
// on-page text. When an off-page item continues an on-page
|
||||
// line (same baseline, near-adjacent x), the coordinates are
|
||||
// artifacts of transforms we mis-model — don't clip those.
|
||||
let straddles = off.iter().any(|o| {
|
||||
items.iter().any(|i| {
|
||||
!outside(i)
|
||||
&& (i.y - o.y).abs() <= 2.0
|
||||
&& (o.x - (i.x + i.width)).abs() <= 10.0
|
||||
})
|
||||
});
|
||||
let coherent =
|
||||
off.len() >= 10 && wordy_chars * 2 >= total_chars.max(1) && !straddles;
|
||||
if bx1 - bx0 >= 72.0 && by1 - by0 >= 72.0 && coherent {
|
||||
let before = items.len();
|
||||
items.retain(|it| !outside(it));
|
||||
if items.len() < before {
|
||||
debug!(
|
||||
"page {}: clipped {} items outside page box ({:.0},{:.0})-({:.0},{:.0})",
|
||||
page_num,
|
||||
before - items.len(),
|
||||
bx0,
|
||||
by0,
|
||||
bx1,
|
||||
by1
|
||||
);
|
||||
// Only prune off-page geometry when off-page text
|
||||
// existed — same neighboring-page content.
|
||||
let overlaps = |x: f32, y: f32, w: f32, h: f32| {
|
||||
let (x0, x1) = if w < 0.0 { (x + w, x) } else { (x, x + w) };
|
||||
let (y0, y1) = if h < 0.0 { (y + h, y) } else { (y, y + h) };
|
||||
x0 < bx1 + TOL && x1 > bx0 - TOL && y0 < by1 + TOL && y1 > by0 - TOL
|
||||
};
|
||||
rects.retain(|r| overlaps(r.x, r.y, r.width, r.height));
|
||||
clipped_box = Some((bx0, by0, bx1, by1));
|
||||
lines.retain(|l| {
|
||||
overlaps(
|
||||
l.x1.min(l.x2),
|
||||
l.y1.min(l.y2),
|
||||
(l.x2 - l.x1).abs(),
|
||||
(l.y2 - l.y1).abs(),
|
||||
)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if has_gid_fonts {
|
||||
gid_encoded_pages.insert(*page_num);
|
||||
}
|
||||
@@ -223,7 +301,18 @@ fn extract_positioned_text_impl(
|
||||
all_lines.extend(lines);
|
||||
|
||||
// Extract hyperlinks from page annotations
|
||||
let links = extract_page_links(doc, page_id, *page_num);
|
||||
let mut links = extract_page_links(doc, page_id, *page_num);
|
||||
// Annotations from the neighboring page are off-box too.
|
||||
if let Some((bx0, by0, bx1, by1)) = clipped_box {
|
||||
links.retain(|it| {
|
||||
let cx = it.x + it.width / 2.0;
|
||||
// Center-y, not it.y: link items carry an annotation rect,
|
||||
// so y is a box edge — unlike text items, where y is a
|
||||
// baseline and testing it directly is the natural semantics.
|
||||
let cy = it.y + it.height / 2.0;
|
||||
cx >= bx0 - 6.0 && cx <= bx1 + 6.0 && cy >= by0 - 6.0 && cy <= by1 + 6.0
|
||||
});
|
||||
}
|
||||
all_items.extend(links);
|
||||
}
|
||||
|
||||
@@ -252,17 +341,47 @@ fn suppress_table_underlines(
|
||||
}
|
||||
|
||||
let mut table_item_indices: HashSet<usize> = HashSet::new();
|
||||
// A detected "table" that swallows nearly every text item on the page
|
||||
// is a detection artifact (prose pages with boxed callouts or stacked
|
||||
// underline rules read as one giant grid), not a real table — letting
|
||||
// it through here erased every legitimate underline on the page
|
||||
// (text_dense__underline: rect detection claimed 52/52 items). Real
|
||||
// ruled tables share the page with headings, captions, and body text.
|
||||
let plausible = |table: &crate::tables::Table| {
|
||||
// Content sanity gate: prose pages with boxed callouts and stacked
|
||||
// underline rules can detect as a structurally rich "table" that
|
||||
// swallows every item on the page (text_dense__underline: a 4x8
|
||||
// grid claiming 52/52 items, one "cell" holding 806 chars of body
|
||||
// text) — suppressing there erased every legitimate underline on
|
||||
// the page. Real data-table cells are short values; a cell with
|
||||
// hundreds of characters means the grid captured flowing prose.
|
||||
let lens: Vec<usize> = table
|
||||
.cells
|
||||
.iter()
|
||||
.flatten()
|
||||
.filter(|cell| !cell.trim().is_empty())
|
||||
.map(|cell| cell.chars().count())
|
||||
.collect();
|
||||
if lens.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let long = lens.iter().filter(|&&n| n > 100).count();
|
||||
(long as f32) < (lens.len() as f32) * 0.3
|
||||
};
|
||||
|
||||
if !rects.is_empty() {
|
||||
let (rect_tables, _) = crate::tables::detect_tables_from_rects(items, rects, page);
|
||||
for table in rect_tables {
|
||||
table_item_indices.extend(table.item_indices);
|
||||
for table in rect_tables.iter().filter(|t| plausible(t)) {
|
||||
table_item_indices.extend(table.item_indices.iter().copied());
|
||||
}
|
||||
}
|
||||
|
||||
if !lines.is_empty() {
|
||||
for table in crate::tables::detect_tables_from_lines(items, lines, page) {
|
||||
table_item_indices.extend(table.item_indices);
|
||||
for table in crate::tables::detect_tables_from_lines(items, lines, page)
|
||||
.iter()
|
||||
.filter(|t| plausible(t))
|
||||
{
|
||||
table_item_indices.extend(table.item_indices.iter().copied());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,6 +646,136 @@ fn should_preserve_overlapping_stream_order(group: &[&TextItem]) -> bool {
|
||||
saw_backtrack
|
||||
}
|
||||
|
||||
/// Detect a tracked (letter-spaced) run of single-glyph items and derive its
|
||||
/// run-local space floor.
|
||||
///
|
||||
/// Display type set with tracking renders one glyph per show op; the merge
|
||||
/// loop's fixed thresholds (0.08-0.13 em) then read every letter gap as a
|
||||
/// word boundary and emit "H O W" instead of "HOW". Within such a run the
|
||||
/// gaps carry the real signal: letter gaps cluster tightly just above the
|
||||
/// fixed threshold, word gaps sit clearly higher. Returns (run_end_index,
|
||||
/// space_floor) when the run starting at `start` is tracked — spaces are
|
||||
/// then inserted only at gaps above the floor (infinity = single word).
|
||||
/// Normal text (multi-char items, or single-char runs with sub-threshold
|
||||
/// gaps) returns None and keeps the existing behavior.
|
||||
/// Han/Kana scripts write without inter-word spaces. Hangul (Korean) DOES
|
||||
/// space between words and deliberately stays out of this set — a Korean
|
||||
/// tracked run keeps normal word-boundary handling.
|
||||
fn is_spaceless_cjk(c: char) -> bool {
|
||||
matches!(c,
|
||||
'\u{3000}'..='\u{303F}' // CJK Symbols and Punctuation
|
||||
| '\u{3040}'..='\u{309F}' // Hiragana
|
||||
| '\u{30A0}'..='\u{30FF}' // Katakana
|
||||
| '\u{4E00}'..='\u{9FFF}' // CJK Unified Ideographs
|
||||
| '\u{F900}'..='\u{FAFF}' // CJK Compatibility Ideographs
|
||||
| '\u{FF00}'..='\u{FFEF}' // Halfwidth and Fullwidth Forms
|
||||
)
|
||||
}
|
||||
|
||||
fn tracked_run_space_floor(group: &[&TextItem], start: usize) -> Option<(usize, f32)> {
|
||||
const MIN_GAPS: usize = 4;
|
||||
let first = group[start];
|
||||
if first.text.trim().chars().count() != 1 {
|
||||
return None;
|
||||
}
|
||||
let fs = first.font_size;
|
||||
if fs <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Walk the run under the SAME break conditions as the merge loop
|
||||
// (size band, style equality, mergeable gap) so indices stay aligned.
|
||||
let mut gaps: Vec<f32> = Vec::new();
|
||||
let mut end_x = first.x + effective_merge_width(first);
|
||||
let mut end = start;
|
||||
for (offset, next) in group[start + 1..].iter().enumerate() {
|
||||
if next.text.trim().chars().count() != 1 {
|
||||
break;
|
||||
}
|
||||
if (next.font_size - fs).abs() > fs * 0.20 {
|
||||
break;
|
||||
}
|
||||
if next.is_bold != first.is_bold
|
||||
|| next.is_italic != first.is_italic
|
||||
|| next.is_underline != first.is_underline
|
||||
|| next.is_strikeout != first.is_strikeout
|
||||
{
|
||||
break;
|
||||
}
|
||||
let gap = next.x - end_x;
|
||||
if gap > fs * 0.5 || gap < -fs * 0.5 {
|
||||
break;
|
||||
}
|
||||
gaps.push(gap / fs);
|
||||
end_x = next.x + effective_merge_width(next);
|
||||
end = start + 1 + offset;
|
||||
}
|
||||
if gaps.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Tracked signature: the run's TYPICAL gap clears the fixed space
|
||||
// threshold (0.08) — the merge loop would break almost every letter
|
||||
// pair into "words". Short runs (2-3 gaps: "H O W") demand a stricter
|
||||
// shape — clearly wide, uniform, ALL-CAPS — because a genuine spaced
|
||||
// sequence of single letters ("x y z" variables) has the same gap
|
||||
// count; display tracking is a caps convention.
|
||||
let mut sorted = gaps.clone();
|
||||
sorted.sort_by(|a, b| a.total_cmp(b));
|
||||
let median = sorted[sorted.len() / 2];
|
||||
// Typographic convention gate, both tiers: display tracking is an
|
||||
// all-caps convention, and Han/Kana never space between glyphs. Mixed-
|
||||
// or lowercase Latin runs keep their boundaries because geometry alone
|
||||
// cannot distinguish spaced singles ("A b c d e") from a tracked
|
||||
// title-case word ("B u f f a l o").
|
||||
let run_chars = || {
|
||||
group[start..=end]
|
||||
.iter()
|
||||
.flat_map(|it| it.text.trim().chars())
|
||||
};
|
||||
let spaceless_cjk = run_chars().all(|c| is_spaceless_cjk(c) || !c.is_alphanumeric())
|
||||
&& run_chars().any(is_spaceless_cjk);
|
||||
let all_caps = run_chars().all(|c| c.is_uppercase() || is_cjk_char(c) || !c.is_alphabetic());
|
||||
if !(spaceless_cjk || all_caps) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if gaps.len() >= MIN_GAPS {
|
||||
if median <= 0.075 {
|
||||
return None;
|
||||
}
|
||||
} else {
|
||||
let uniform = sorted[sorted.len() - 1] <= sorted[0].max(0.01) * 1.4;
|
||||
if median < 0.09 || !uniform {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
// Han/Kana: no inter-glyph spaces, period — a nonuniform gap
|
||||
// distribution (punctuation spacing, justification) must not
|
||||
// manufacture word boundaries.
|
||||
if spaceless_cjk {
|
||||
return Some((end, f32::INFINITY));
|
||||
}
|
||||
|
||||
// Word gaps, if present, form a second mode above the letter-gap
|
||||
// cluster: split at the largest relative jump. Unimodal → one word.
|
||||
let mut best_jump = 1.0f32;
|
||||
let mut floor = f32::INFINITY;
|
||||
for pair in sorted.windows(2) {
|
||||
let (lo, hi) = (pair[0].max(0.01), pair[1].max(0.01));
|
||||
let jump = hi / lo;
|
||||
if jump > best_jump {
|
||||
best_jump = jump;
|
||||
floor = (lo + hi) / 2.0;
|
||||
}
|
||||
}
|
||||
if best_jump < 1.4 {
|
||||
floor = f32::INFINITY;
|
||||
}
|
||||
Some((end, floor * fs))
|
||||
}
|
||||
|
||||
pub(crate) fn merge_text_items(items: Vec<TextItem>) -> Vec<TextItem> {
|
||||
if items.is_empty() {
|
||||
return items;
|
||||
@@ -574,6 +823,14 @@ pub(crate) fn merge_text_items(items: Vec<TextItem>) -> Vec<TextItem> {
|
||||
let mut text = first.text.clone();
|
||||
let mut end_x = first.x + effective_merge_width(first);
|
||||
|
||||
// Tracked display text: run-local space floor overrides the
|
||||
// fixed thresholds for this run's junctions (see helper).
|
||||
let tracked = if *preserve_stream_order {
|
||||
None
|
||||
} else {
|
||||
tracked_run_space_floor(group, i)
|
||||
};
|
||||
|
||||
let mut j = i + 1;
|
||||
while j < group.len() {
|
||||
let next = group[j];
|
||||
@@ -628,7 +885,11 @@ pub(crate) fn merge_text_items(items: Vec<TextItem>) -> Vec<TextItem> {
|
||||
let needs_bullet_space = *preserve_stream_order
|
||||
&& is_standalone_bullet_text(&text)
|
||||
&& !next.text.trim().is_empty();
|
||||
if needs_bullet_space || gap > threshold {
|
||||
let effective_threshold = match tracked {
|
||||
Some((run_end, floor)) if j <= run_end => floor,
|
||||
_ => threshold,
|
||||
};
|
||||
if needs_bullet_space || gap > effective_threshold {
|
||||
text.push(' ');
|
||||
}
|
||||
text.push_str(&next.text);
|
||||
@@ -731,9 +992,17 @@ pub(crate) fn merge_subscript_items(items: Vec<TextItem>) -> Vec<TextItem> {
|
||||
.chars()
|
||||
.last()
|
||||
.is_some_and(|c| c.is_alphabetic());
|
||||
let same_marks = parent.is_underline == item.is_underline
|
||||
&& parent.is_strikeout == item.is_strikeout;
|
||||
if parent.font_size >= sub_threshold && ends_with_letter && same_marks {
|
||||
// Strikeout boundaries block the merge (a struck word
|
||||
// must not extend its strike over a live footnote digit,
|
||||
// and a struck digit must not lose its own mark). An
|
||||
// underlined parent with an unmarked digit DOES merge:
|
||||
// the drawn rule easily misses the tiny digit's overlap
|
||||
// window, and refusing costs the whole subscript token
|
||||
// ("b"+"2" staying split). Visually the rule spans both.
|
||||
let marks_ok = parent.is_strikeout == item.is_strikeout
|
||||
&& (parent.is_underline == item.is_underline
|
||||
|| (parent.is_underline && !item.is_underline));
|
||||
if parent.font_size >= sub_threshold && ends_with_letter && marks_ok {
|
||||
let parent_right = parent.x + parent.width;
|
||||
let gap = item.x - parent_right;
|
||||
// Subscripts must be tightly adjacent (within ~1pt)
|
||||
@@ -787,6 +1056,46 @@ pub(crate) fn get_number(obj: &Object) -> Option<f32> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Visible page box: CropBox if present, else MediaBox, walking page-tree
|
||||
/// inheritance (both attributes are inheritable). Returns normalized
|
||||
/// (x0, y0, x1, y1) in PDF space.
|
||||
fn get_page_box(doc: &Document, page_id: ObjectId) -> Option<(f32, f32, f32, f32)> {
|
||||
fn find_box(doc: &Document, page_id: ObjectId, key: &[u8]) -> Option<Vec<f32>> {
|
||||
let mut id = page_id;
|
||||
for _ in 0..32 {
|
||||
let dict = doc.get_dictionary(id).ok()?;
|
||||
if let Ok(obj) = dict.get(key) {
|
||||
let arr = match obj {
|
||||
Object::Array(a) => Some(a.clone()),
|
||||
Object::Reference(r) => match doc.get_object(*r) {
|
||||
Ok(Object::Array(a)) => Some(a.clone()),
|
||||
_ => None,
|
||||
},
|
||||
_ => None,
|
||||
};
|
||||
if let Some(arr) = arr {
|
||||
let vals: Vec<f32> = arr.iter().filter_map(get_number).collect();
|
||||
if vals.len() >= 4 {
|
||||
return Some(vals);
|
||||
}
|
||||
}
|
||||
}
|
||||
match dict.get(b"Parent") {
|
||||
Ok(Object::Reference(p)) => id = *p,
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
let v = find_box(doc, page_id, b"CropBox").or_else(|| find_box(doc, page_id, b"MediaBox"))?;
|
||||
Some((
|
||||
v[0].min(v[2]),
|
||||
v[1].min(v[3]),
|
||||
v[0].max(v[2]),
|
||||
v[1].max(v[3]),
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -794,6 +1103,107 @@ mod tests {
|
||||
use crate::types::{ItemType, PdfLine, TextLine};
|
||||
use layout::{detect_columns, is_newspaper_layout, ColumnRegion};
|
||||
|
||||
/// Glyph-per-item run at `fs`=12 with the given inter-glyph gap (pt).
|
||||
fn glyph_run(chars: &str, start_x: f32, glyph_w: f32, gap: f32) -> Vec<TextItem> {
|
||||
let mut x = start_x;
|
||||
let mut out = Vec::new();
|
||||
for c in chars.chars() {
|
||||
out.push(make_merge_item(&c.to_string(), x, glyph_w));
|
||||
x += glyph_w + gap;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_caps_run_collapses_to_word() {
|
||||
// Display tracking: every letter gap (0.19 em) clears the fixed
|
||||
// space threshold — without the run-local floor this reads "H O W".
|
||||
let items = glyph_run("HOW", 100.0, 10.0, 2.3);
|
||||
let merged = merge_text_items(items);
|
||||
assert_eq!(merged.len(), 1);
|
||||
assert_eq!(merged[0].text, "HOW");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tracked_run_keeps_word_gaps_bimodal() {
|
||||
// Letters at 0.19 em, word gaps at 0.42 em (below the 0.5 em item
|
||||
// break): the split must land between the modes. Needs >=4 gaps to
|
||||
// enter the bimodal tier — short runs use the strict uniform gate.
|
||||
let mut items = glyph_run("ITISOK", 100.0, 8.0, 2.3);
|
||||
for i in 2..6 {
|
||||
items[i].x += 2.8; // word gap at T|I
|
||||
}
|
||||
for i in 4..6 {
|
||||
items[i].x += 2.8; // word gap at S|O
|
||||
}
|
||||
let merged = merge_text_items(items);
|
||||
assert_eq!(merged.len(), 1);
|
||||
assert_eq!(merged[0].text, "IT IS OK");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lowercase_spaced_singles_stay_words() {
|
||||
// "x y z" variables: same gap shape but lowercase — the short-run
|
||||
// caps requirement keeps genuine spaced singles apart.
|
||||
let items = glyph_run("xyz", 100.0, 6.0, 2.3);
|
||||
let merged = merge_text_items(items);
|
||||
assert_eq!(merged.len(), 1);
|
||||
assert_eq!(merged[0].text, "x y z");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kerned_singles_unaffected() {
|
||||
// Tiny kerning gaps never triggered spaces before and still don't.
|
||||
let items = glyph_run("WORD", 100.0, 8.0, 0.3);
|
||||
let merged = merge_text_items(items);
|
||||
assert_eq!(merged.len(), 1);
|
||||
assert_eq!(merged[0].text, "WORD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_lowercase_spaced_singles_keep_boundaries() {
|
||||
// Review: a 5+ single-letter lowercase list has the tracked gap
|
||||
// shape at any length — the convention gate must protect it in
|
||||
// the >=4-gap tier too.
|
||||
let items = glyph_run("abcde", 100.0, 6.0, 2.3);
|
||||
let merged = merge_text_items(items);
|
||||
assert_eq!(merged.len(), 1);
|
||||
assert_eq!(merged[0].text, "a b c d e");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn han_run_with_nonuniform_gaps_never_gains_spaces() {
|
||||
// Review: a bimodal gap distribution (justification, punctuation
|
||||
// spacing) must not manufacture word boundaries in Han text.
|
||||
let mut items = glyph_run("北京时事快报", 100.0, 12.0, 1.4);
|
||||
for item in items.iter_mut().skip(3) {
|
||||
item.x += 3.0; // wide gap after the third glyph
|
||||
}
|
||||
let merged = merge_text_items(items);
|
||||
assert_eq!(merged.len(), 1);
|
||||
assert_eq!(merged[0].text, "北京时事快报");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uppercase_leading_spaced_singles_keep_boundaries() {
|
||||
// "A b c d e" is indistinguishable from a title-case tracked word
|
||||
// without reliable tracking metadata, so preserve its boundaries.
|
||||
let items = glyph_run("Abcde", 100.0, 7.0, 2.3);
|
||||
let merged = merge_text_items(items);
|
||||
assert_eq!(merged.len(), 1);
|
||||
assert_eq!(merged[0].text, "A b c d e");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk_glyph_run_collapses_without_spaces() {
|
||||
// CJK sets one glyph per item with loose gaps; CJK uses no spaces,
|
||||
// and the non-alphabetic run passes the caps gate.
|
||||
let items = glyph_run("北京时事", 100.0, 12.0, 1.4);
|
||||
let merged = merge_text_items(items);
|
||||
assert_eq!(merged.len(), 1);
|
||||
assert_eq!(merged[0].text, "北京时事");
|
||||
}
|
||||
|
||||
fn make_merge_item(text: &str, x: f32, width: f32) -> TextItem {
|
||||
TextItem {
|
||||
text: text.into(),
|
||||
|
||||
@@ -0,0 +1,591 @@
|
||||
//! Region-graph evidence for page reading order.
|
||||
//!
|
||||
//! Whole-page column histograms fail when images or spanning captions occupy
|
||||
//! only part of a page. This module turns image geometry and repeated row
|
||||
//! gutters into a small directed acyclic graph: content above a local column
|
||||
//! band, the left flow, the right flow, and content below it. The graph is
|
||||
//! deliberately evidence-gated; ordinary pages keep the established layout
|
||||
//! path.
|
||||
|
||||
use crate::text_utils::{effective_width, is_cjk_char, is_rtl_text};
|
||||
use crate::types::TextItem;
|
||||
|
||||
const MIN_IMAGE_WIDTH: f32 = 60.0;
|
||||
const MIN_IMAGE_HEIGHT: f32 = 40.0;
|
||||
const MIN_ROW_GUTTER: f32 = 8.0;
|
||||
const SPLIT_CLUSTER_TOLERANCE: f32 = 20.0;
|
||||
const MIN_ALIGNED_ROWS: usize = 4;
|
||||
|
||||
pub(crate) type ImageRegion = (f32, f32, f32, f32);
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub(crate) struct ColumnFlowBand {
|
||||
pub(crate) split_x: f32,
|
||||
pub(crate) y_bottom: f32,
|
||||
pub(crate) y_top: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum RegionKind {
|
||||
FullWidth,
|
||||
Column,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct RegionNode {
|
||||
pub(crate) kind: RegionKind,
|
||||
pub(crate) items: Vec<TextItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct Row<'a> {
|
||||
y: f32,
|
||||
items: Vec<&'a TextItem>,
|
||||
}
|
||||
|
||||
fn page_x_bounds(items: &[TextItem], images: &[ImageRegion]) -> Option<(f32, f32)> {
|
||||
let text_min = items
|
||||
.iter()
|
||||
.map(|item| item.x)
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
let text_max = items
|
||||
.iter()
|
||||
.map(|item| item.x + effective_width(item))
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
let image_min = images
|
||||
.iter()
|
||||
.map(|region| region.0.min(region.2))
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
let image_max = images
|
||||
.iter()
|
||||
.map(|region| region.0.max(region.2))
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
let x_min = text_min.min(image_min);
|
||||
let x_max = text_max.max(image_max);
|
||||
(x_min.is_finite() && x_max.is_finite() && x_max > x_min).then_some((x_min, x_max))
|
||||
}
|
||||
|
||||
fn group_rows(items: &[TextItem]) -> Vec<Row<'_>> {
|
||||
const Y_TOLERANCE: f32 = 3.0;
|
||||
let mut sorted: Vec<&TextItem> = items.iter().collect();
|
||||
sorted.sort_by(|left, right| right.y.total_cmp(&left.y));
|
||||
let mut rows: Vec<Row<'_>> = Vec::new();
|
||||
for item in sorted {
|
||||
if let Some(row) = rows
|
||||
.last_mut()
|
||||
.filter(|row| (row.y - item.y).abs() <= Y_TOLERANCE)
|
||||
{
|
||||
row.items.push(item);
|
||||
row.y = row.items.iter().map(|member| member.y).sum::<f32>() / row.items.len() as f32;
|
||||
} else {
|
||||
rows.push(Row {
|
||||
y: item.y,
|
||||
items: vec![item],
|
||||
});
|
||||
}
|
||||
}
|
||||
for row in &mut rows {
|
||||
row.items.sort_by(|left, right| left.x.total_cmp(&right.x));
|
||||
}
|
||||
rows
|
||||
}
|
||||
|
||||
fn side_is_prose(items: &[&TextItem]) -> bool {
|
||||
let text = items
|
||||
.iter()
|
||||
.map(|item| item.text.trim())
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let alphabetic_count = text
|
||||
.chars()
|
||||
.filter(|character| character.is_alphabetic())
|
||||
.count();
|
||||
let cjk_count = text
|
||||
.chars()
|
||||
.filter(|character| is_cjk_char(*character))
|
||||
.count();
|
||||
(text.split_whitespace().count() >= 3 || cjk_count >= 10) && alphabetic_count >= 10
|
||||
}
|
||||
|
||||
fn aligned_row_split(row: &Row<'_>, x_min: f32, x_max: f32) -> Option<f32> {
|
||||
if row.items.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
let page_width = x_max - x_min;
|
||||
let center_low = x_min + page_width * 0.25;
|
||||
let center_high = x_min + page_width * 0.75;
|
||||
row.items
|
||||
.windows(2)
|
||||
.filter_map(|pair| {
|
||||
let left_end = pair[0].x + effective_width(pair[0]);
|
||||
let right_start = pair[1].x;
|
||||
let gap = right_start - left_end;
|
||||
let split_x = (left_end + right_start) / 2.0;
|
||||
if gap < MIN_ROW_GUTTER || split_x < center_low || split_x > center_high {
|
||||
return None;
|
||||
}
|
||||
let left: Vec<&TextItem> = row
|
||||
.items
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|item| item.x + effective_width(item) / 2.0 < split_x)
|
||||
.collect();
|
||||
let right: Vec<&TextItem> = row
|
||||
.items
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|item| item.x + effective_width(item) / 2.0 >= split_x)
|
||||
.collect();
|
||||
(side_is_prose(&left) && side_is_prose(&right)).then_some((split_x, gap))
|
||||
})
|
||||
.max_by(|left, right| left.1.total_cmp(&right.1))
|
||||
.map(|candidate| candidate.0)
|
||||
}
|
||||
|
||||
fn local_flow_below_full_width_image(
|
||||
items: &[TextItem],
|
||||
images: &[ImageRegion],
|
||||
x_min: f32,
|
||||
x_max: f32,
|
||||
) -> Option<ColumnFlowBand> {
|
||||
let page_width = x_max - x_min;
|
||||
let full_width_images: Vec<ImageRegion> = images
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&(x0, y0, x1, y1)| {
|
||||
let width = (x1 - x0).abs();
|
||||
let height = (y1 - y0).abs();
|
||||
width >= page_width * 0.65 && height >= 60.0
|
||||
})
|
||||
.collect();
|
||||
// A local column flow below an image is only unambiguous for a single,
|
||||
// nearly square hero/figure. Wide report banners and full-page artwork
|
||||
// frequently sit above unrelated page furniture whose aligned labels can
|
||||
// mimic prose columns.
|
||||
if full_width_images.len() != 1 {
|
||||
return None;
|
||||
}
|
||||
let (image_x0, _, image_x1, _) = full_width_images[0];
|
||||
let anchor_width = (image_x1 - image_x0).abs();
|
||||
let anchor_height = (full_width_images[0].3 - full_width_images[0].1).abs();
|
||||
if anchor_width < page_width * 0.85
|
||||
|| anchor_height < anchor_width * 0.85
|
||||
|| anchor_height > anchor_width * 1.2
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let image_bottom = full_width_images
|
||||
.iter()
|
||||
.map(|&(_, y0, _, y1)| y0.min(y1))
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
if !image_bottom.is_finite() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let below: Vec<TextItem> = items
|
||||
.iter()
|
||||
.filter(|item| item.y < image_bottom && item.y >= image_bottom - 220.0)
|
||||
.cloned()
|
||||
.collect();
|
||||
let candidates: Vec<(f32, f32)> = group_rows(&below)
|
||||
.into_iter()
|
||||
.filter_map(|row| aligned_row_split(&row, x_min, x_max).map(|split| (split, row.y)))
|
||||
.collect();
|
||||
if candidates.len() < MIN_ALIGNED_ROWS {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut clusters: Vec<Vec<(f32, f32)>> = Vec::new();
|
||||
for candidate in candidates {
|
||||
if let Some(cluster) = clusters.iter_mut().find(|cluster| {
|
||||
let mean = cluster.iter().map(|entry| entry.0).sum::<f32>() / cluster.len() as f32;
|
||||
(mean - candidate.0).abs() <= SPLIT_CLUSTER_TOLERANCE
|
||||
}) {
|
||||
cluster.push(candidate);
|
||||
} else {
|
||||
clusters.push(vec![candidate]);
|
||||
}
|
||||
}
|
||||
let dominant = clusters.into_iter().max_by_key(Vec::len)?;
|
||||
if dominant.len() < MIN_ALIGNED_ROWS {
|
||||
return None;
|
||||
}
|
||||
let split_x = dominant.iter().map(|entry| entry.0).sum::<f32>() / dominant.len() as f32;
|
||||
let y_top = dominant
|
||||
.iter()
|
||||
.map(|entry| entry.1)
|
||||
.fold(f32::NEG_INFINITY, f32::max)
|
||||
+ 3.0;
|
||||
let image_gap = image_bottom - y_top;
|
||||
if !(60.0..=120.0).contains(&image_gap) {
|
||||
return None;
|
||||
}
|
||||
let y_bottom = dominant
|
||||
.iter()
|
||||
.map(|entry| entry.1)
|
||||
.fold(f32::INFINITY, f32::min)
|
||||
- 3.0;
|
||||
if y_top - y_bottom > 130.0 {
|
||||
return None;
|
||||
}
|
||||
log::debug!(
|
||||
"page {}: full-width image flow images={} aligned_rows={} split={:.1} page=[{:.1}..{:.1}] image_bottom={:.1} y=[{:.1}..{:.1}] full_width={:?}",
|
||||
items.first().map_or(0, |item| item.page),
|
||||
images.len(),
|
||||
dominant.len(),
|
||||
split_x,
|
||||
x_min,
|
||||
x_max,
|
||||
image_bottom,
|
||||
y_bottom,
|
||||
y_top,
|
||||
full_width_images
|
||||
);
|
||||
Some(ColumnFlowBand {
|
||||
split_x,
|
||||
y_bottom,
|
||||
y_top,
|
||||
})
|
||||
}
|
||||
|
||||
fn paired_column_images(
|
||||
items: &[TextItem],
|
||||
images: &[ImageRegion],
|
||||
split_x: f32,
|
||||
x_min: f32,
|
||||
x_max: f32,
|
||||
) -> Option<ColumnFlowBand> {
|
||||
let page_width = x_max - x_min;
|
||||
if split_x < x_min + page_width * 0.4 || split_x > x_min + page_width * 0.6 {
|
||||
return None;
|
||||
}
|
||||
let qualifying: Vec<ImageRegion> = images
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|&(x0, y0, x1, y1)| {
|
||||
let image_left = x0.min(x1);
|
||||
let image_right = x0.max(x1);
|
||||
let confined_to_one_column = image_right <= split_x || image_left >= split_x;
|
||||
confined_to_one_column
|
||||
&& (x1 - x0).abs() >= MIN_IMAGE_WIDTH
|
||||
&& (y1 - y0).abs() >= MIN_IMAGE_HEIGHT
|
||||
})
|
||||
.collect();
|
||||
let wide_images: Vec<ImageRegion> = qualifying
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|(x0, _, x1, _)| (x1 - x0).abs() >= page_width * 0.35)
|
||||
.collect();
|
||||
if qualifying.len() < 3 || wide_images.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let has_left = qualifying
|
||||
.iter()
|
||||
.any(|&(x0, _, x1, _)| (x0 + x1) / 2.0 < split_x);
|
||||
let has_right = qualifying
|
||||
.iter()
|
||||
.any(|&(x0, _, x1, _)| (x0 + x1) / 2.0 >= split_x);
|
||||
if !has_left || !has_right {
|
||||
return None;
|
||||
}
|
||||
// A meaningful image-backed column flow spans multiple vertical panels.
|
||||
// Three same-row header/logo images can otherwise satisfy the image count
|
||||
// and send an ordinary asymmetric page through sequential column order.
|
||||
let image_y_min = wide_images
|
||||
.iter()
|
||||
.map(|region| region.1.min(region.3))
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
let image_y_max = wide_images
|
||||
.iter()
|
||||
.map(|region| region.1.max(region.3))
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
let has_vertical_stack = wide_images.iter().enumerate().any(|(index, left)| {
|
||||
wide_images.iter().skip(index + 1).any(|right| {
|
||||
let same_side =
|
||||
((left.0 + left.2) / 2.0 < split_x) == ((right.0 + right.2) / 2.0 < split_x);
|
||||
let left_center = (left.1 + left.3) / 2.0;
|
||||
let right_center = (right.1 + right.3) / 2.0;
|
||||
let left_height = (left.3 - left.1).abs();
|
||||
let right_height = (right.3 - right.1).abs();
|
||||
let vertical_gap = if left.1.max(left.3) < right.1.min(right.3) {
|
||||
right.1.min(right.3) - left.1.max(left.3)
|
||||
} else if right.1.max(right.3) < left.1.min(left.3) {
|
||||
left.1.min(left.3) - right.1.max(right.3)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
same_side
|
||||
&& (left_center - right_center).abs() >= left_height.min(right_height) * 0.5
|
||||
&& vertical_gap <= left_height.max(right_height) * 0.5
|
||||
})
|
||||
});
|
||||
if image_y_max - image_y_min < page_width * 0.45 || !has_vertical_stack {
|
||||
return None;
|
||||
}
|
||||
let y_top = qualifying
|
||||
.iter()
|
||||
.map(|region| region.1.max(region.3))
|
||||
.fold(f32::NEG_INFINITY, f32::max)
|
||||
+ 3.0;
|
||||
// Only column-confined text proves the lower extent of the flow. A
|
||||
// spanning heading or caption below the columns must become the trailing
|
||||
// full-width node rather than stretching the column band to the page foot.
|
||||
let y_bottom = items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
let item_right = item.x + effective_width(item);
|
||||
item.y <= y_top && (item_right <= split_x || item.x >= split_x)
|
||||
})
|
||||
.map(|item| item.y)
|
||||
.fold(f32::INFINITY, f32::min)
|
||||
- 3.0;
|
||||
if !y_bottom.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let distinct_rows = |right: bool| {
|
||||
let mut ys: Vec<f32> = items
|
||||
.iter()
|
||||
.filter(|item| {
|
||||
item.y <= y_top && (item.x + effective_width(item) / 2.0 >= split_x) == right
|
||||
})
|
||||
.map(|item| item.y)
|
||||
.collect();
|
||||
ys.sort_by(|left, right| left.total_cmp(right));
|
||||
ys.dedup_by(|left, right| (*left - *right).abs() <= 3.0);
|
||||
ys.len()
|
||||
};
|
||||
let left_rows = distinct_rows(false);
|
||||
let right_rows = distinct_rows(true);
|
||||
let line_balance = left_rows.min(right_rows) as f32 / left_rows.max(right_rows).max(1) as f32;
|
||||
(left_rows >= 5 && right_rows >= 5 && line_balance < 0.55).then(|| {
|
||||
log::debug!(
|
||||
"page {}: paired-image flow qualifying_images={} rows={}/{} split={:.1} page=[{:.1}..{:.1}] y=[{:.1}..{:.1}] images={:?}",
|
||||
items.first().map_or(0, |item| item.page),
|
||||
qualifying.len(),
|
||||
left_rows,
|
||||
right_rows,
|
||||
split_x,
|
||||
x_min,
|
||||
x_max,
|
||||
y_bottom,
|
||||
y_top,
|
||||
qualifying
|
||||
);
|
||||
ColumnFlowBand {
|
||||
split_x,
|
||||
y_bottom,
|
||||
y_top,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn infer_image_anchored_flow(
|
||||
items: &[TextItem],
|
||||
images: &[ImageRegion],
|
||||
detected_split: Option<f32>,
|
||||
) -> Option<ColumnFlowBand> {
|
||||
if items.is_empty() || images.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (x_min, x_max) = page_x_bounds(items, images)?;
|
||||
detected_split
|
||||
.and_then(|split_x| paired_column_images(items, images, split_x, x_min, x_max))
|
||||
.or_else(|| local_flow_below_full_width_image(items, images, x_min, x_max))
|
||||
}
|
||||
|
||||
/// Partition a page into the topological order `above -> left -> right -> below`.
|
||||
/// These edges encode the reading-order DAG; empty nodes are omitted.
|
||||
pub(crate) fn build_region_graph(items: Vec<TextItem>, band: ColumnFlowBand) -> Vec<RegionNode> {
|
||||
let mut above = Vec::new();
|
||||
let mut left = Vec::new();
|
||||
let mut right = Vec::new();
|
||||
let mut below = Vec::new();
|
||||
for item in items {
|
||||
if item.y > band.y_top {
|
||||
above.push(item);
|
||||
} else if item.y < band.y_bottom {
|
||||
below.push(item);
|
||||
} else if item.x + effective_width(&item) / 2.0 < band.split_x {
|
||||
left.push(item);
|
||||
} else {
|
||||
right.push(item);
|
||||
}
|
||||
}
|
||||
let rtl = is_rtl_text(left.iter().chain(right.iter()).map(|item| &item.text));
|
||||
let mut ordered = vec![(RegionKind::FullWidth, above)];
|
||||
if rtl {
|
||||
ordered.push((RegionKind::Column, right));
|
||||
ordered.push((RegionKind::Column, left));
|
||||
} else {
|
||||
ordered.push((RegionKind::Column, left));
|
||||
ordered.push((RegionKind::Column, right));
|
||||
}
|
||||
ordered.push((RegionKind::FullWidth, below));
|
||||
ordered
|
||||
.into_iter()
|
||||
.filter_map(|(kind, items)| (!items.is_empty()).then_some(RegionNode { kind, items }))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::ItemType;
|
||||
|
||||
fn item(text: &str, x: f32, y: f32, width: f32) -> TextItem {
|
||||
TextItem {
|
||||
text: text.into(),
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height: 11.0,
|
||||
font: "F1".into(),
|
||||
font_size: 11.0,
|
||||
page: 1,
|
||||
is_bold: false,
|
||||
is_italic: false,
|
||||
is_underline: false,
|
||||
is_strikeout: false,
|
||||
item_type: ItemType::Text,
|
||||
mcid: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_width_image_anchors_local_two_column_flow() {
|
||||
let mut items = vec![
|
||||
item("A full width caption", 55.0, 230.0, 430.0),
|
||||
item("A trailing full width heading", 55.0, 80.0, 430.0),
|
||||
];
|
||||
for index in 0..5 {
|
||||
let y = 170.0 - index as f32 * 14.0;
|
||||
items.push(item("left column prose words", 55.0, y, 210.0));
|
||||
items.push(item("right column prose words", 280.0, y, 210.0));
|
||||
}
|
||||
let images = vec![(55.0, 250.0, 490.0, 680.0)];
|
||||
let band = infer_image_anchored_flow(&items, &images, None).unwrap();
|
||||
assert!((band.split_x - 272.5).abs() < 2.0);
|
||||
let graph = build_region_graph(items, band);
|
||||
assert_eq!(graph.len(), 4);
|
||||
assert_eq!(graph[0].kind, RegionKind::FullWidth);
|
||||
assert_eq!(graph[1].kind, RegionKind::Column);
|
||||
assert_eq!(graph[2].kind, RegionKind::Column);
|
||||
assert_eq!(graph[3].kind, RegionKind::FullWidth);
|
||||
assert_eq!(graph[3].items[0].text, "A trailing full width heading");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn full_width_image_anchors_cjk_column_flow() {
|
||||
let mut items = Vec::new();
|
||||
for index in 0..5 {
|
||||
let y = 170.0 - index as f32 * 14.0;
|
||||
items.push(item("左栏这是没有空格的正文内容", 55.0, y, 210.0));
|
||||
items.push(item("右栏这是没有空格的正文内容", 280.0, y, 210.0));
|
||||
}
|
||||
let images = vec![(55.0, 250.0, 490.0, 680.0)];
|
||||
|
||||
assert!(infer_image_anchored_flow(&items, &images, None).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paired_images_anchor_unbalanced_column_flows() {
|
||||
let mut items = vec![
|
||||
item("running header", 55.0, 700.0, 430.0),
|
||||
item("trailing full width caption", 55.0, 300.0, 430.0),
|
||||
];
|
||||
for index in 0..5 {
|
||||
items.push(item(
|
||||
"left prose words",
|
||||
55.0,
|
||||
500.0 - index as f32 * 14.0,
|
||||
200.0,
|
||||
));
|
||||
items.push(item(
|
||||
"right prose words",
|
||||
280.0,
|
||||
520.0 - index as f32 * 14.0,
|
||||
200.0,
|
||||
));
|
||||
}
|
||||
for index in 5..12 {
|
||||
items.push(item(
|
||||
"right continuation prose words",
|
||||
280.0,
|
||||
520.0 - index as f32 * 14.0,
|
||||
200.0,
|
||||
));
|
||||
}
|
||||
let images = vec![
|
||||
(55.0, 530.0, 255.0, 680.0),
|
||||
(55.0, 380.0, 255.0, 530.0),
|
||||
(280.0, 560.0, 490.0, 680.0),
|
||||
];
|
||||
let band = infer_image_anchored_flow(&items, &images, Some(270.0)).unwrap();
|
||||
let graph = build_region_graph(items, band);
|
||||
assert_eq!(graph[0].kind, RegionKind::FullWidth);
|
||||
assert_eq!(graph[1].kind, RegionKind::Column);
|
||||
assert_eq!(graph[2].kind, RegionKind::Column);
|
||||
assert_eq!(graph[3].kind, RegionKind::FullWidth);
|
||||
assert_eq!(graph[3].items[0].text, "trailing full width caption");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rtl_region_graph_reads_right_column_first() {
|
||||
let items = vec![
|
||||
item("A long English report header", 55.0, 250.0, 430.0),
|
||||
item("نص العمود الأيسر", 55.0, 150.0, 180.0),
|
||||
item("نص العمود الأيمن", 300.0, 150.0, 180.0),
|
||||
];
|
||||
let graph = build_region_graph(
|
||||
items,
|
||||
ColumnFlowBand {
|
||||
split_x: 270.0,
|
||||
y_bottom: 100.0,
|
||||
y_top: 200.0,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(graph.len(), 3);
|
||||
assert_eq!(graph[0].kind, RegionKind::FullWidth);
|
||||
assert!(graph[1].items[0].x > graph[2].items[0].x);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paired_header_logos_do_not_anchor_page_columns() {
|
||||
let mut items = Vec::new();
|
||||
for index in 0..7 {
|
||||
items.push(item(
|
||||
"left prose words",
|
||||
55.0,
|
||||
700.0 - index as f32 * 14.0,
|
||||
200.0,
|
||||
));
|
||||
}
|
||||
for index in 0..30 {
|
||||
items.push(item(
|
||||
"right prose words",
|
||||
280.0,
|
||||
700.0 - index as f32 * 14.0,
|
||||
200.0,
|
||||
));
|
||||
}
|
||||
let images = vec![
|
||||
(55.0, 720.0, 205.0, 770.0),
|
||||
(60.0, 718.0, 210.0, 768.0),
|
||||
(280.0, 720.0, 450.0, 770.0),
|
||||
];
|
||||
assert!(infer_image_anchored_flow(&items, &images, Some(270.0)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_banner_does_not_anchor_local_columns() {
|
||||
let mut items = Vec::new();
|
||||
for index in 0..7 {
|
||||
let y = 270.0 - index as f32 * 14.0;
|
||||
items.push(item("left column prose words", 55.0, y, 210.0));
|
||||
items.push(item("right column prose words", 280.0, y, 210.0));
|
||||
}
|
||||
let images = vec![(55.0, 310.0, 490.0, 550.0)];
|
||||
assert!(infer_image_anchored_flow(&items, &images, None).is_none());
|
||||
}
|
||||
}
|
||||
+288
-7
@@ -115,7 +115,13 @@ fn rules_from_graphics(rects: &[PdfRect], lines: &[UnderlineLine], page: u32) ->
|
||||
rules
|
||||
}
|
||||
|
||||
fn discard_repeated_ruling_rules(rules: Vec<Rule>) -> Vec<Rule> {
|
||||
fn discard_repeated_ruling_rules(
|
||||
rules: Vec<Rule>,
|
||||
items: &[TextItem],
|
||||
rects: &[PdfRect],
|
||||
lines: &[UnderlineLine],
|
||||
page: u32,
|
||||
) -> Vec<Rule> {
|
||||
if rules.len() < MIN_REPEATED_RULE_LEVELS {
|
||||
return rules;
|
||||
}
|
||||
@@ -123,12 +129,142 @@ fn discard_repeated_ruling_rules(rules: Vec<Rule>) -> Vec<Rule> {
|
||||
rules
|
||||
.iter()
|
||||
.filter(|rule| {
|
||||
!is_repeated_ruling_rule(rule, &rules) && !is_segmented_row_ruling_rule(rule, &rules)
|
||||
// A rule snugly owned by one text line is an underline even when
|
||||
// span-similar rules repeat down the page — documents that
|
||||
// underline many full-width lines (dense CJK business docs) look
|
||||
// exactly like table rulings to the repetition check, which used
|
||||
// to discard every one of them. Table rulings fail snugness:
|
||||
// row separators extend past their cells' text (or have no text
|
||||
// on the baseline above), and multi-column matches are still
|
||||
// culled by the tabular filter afterwards.
|
||||
// Same-row segmented rules (column-header separators) are
|
||||
// always rulings — each segment snugly owns its column label,
|
||||
// so snugness must not override that check.
|
||||
!is_segmented_row_ruling_rule(rule, &rules)
|
||||
&& ((has_snug_text_owner(rule, items)
|
||||
&& !has_flanking_verticals(rule, rects, lines, page))
|
||||
|| !is_repeated_ruling_rule(rule, &rules))
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// True when a single text item both matches the rule vertically (baseline
|
||||
/// window) and horizontally contains it: the rule may not extend past the
|
||||
/// item's span by more than ~0.75em on either side. Underlines are drawn to
|
||||
/// the width of the text they decorate; table/form rulings span cells or
|
||||
/// full table width and overshoot any single item.
|
||||
/// A rule flanked by vertical strokes at its ends is a table/box border
|
||||
/// row edge, not an underline — underlined text lines have no vertical
|
||||
/// rules rising from their ends. Checked against raw stroked lines: a
|
||||
/// near-vertical segment whose x sits at either end of the rule and whose
|
||||
/// y-range covers the rule's row.
|
||||
fn has_flanking_verticals(
|
||||
rule: &Rule,
|
||||
rects: &[PdfRect],
|
||||
lines: &[UnderlineLine],
|
||||
page: u32,
|
||||
) -> bool {
|
||||
// A drawn rect that CONTAINS the rule vetoes rescue only with GRID
|
||||
// EVIDENCE: another drawn rect abutting it vertically (cell rows tile).
|
||||
// Height alone can't separate a table cell from a decorative callout
|
||||
// panel — genuine underlines live inside isolated filled panels, and
|
||||
// multiline table cells can be arbitrarily tall.
|
||||
let norm = |r: &PdfRect| {
|
||||
let (x_lo, x_hi) = if r.width >= 0.0 {
|
||||
(r.x, r.x + r.width)
|
||||
} else {
|
||||
(r.x + r.width, r.x)
|
||||
};
|
||||
let (y_lo, y_hi) = if r.height >= 0.0 {
|
||||
(r.y, r.y + r.height)
|
||||
} else {
|
||||
(r.y + r.height, r.y)
|
||||
};
|
||||
(x_lo, x_hi, y_lo, y_hi)
|
||||
};
|
||||
let page_rects: Vec<(f32, f32, f32, f32)> = rects
|
||||
.iter()
|
||||
.filter(|r| r.page == page && r.height.abs() > 6.0)
|
||||
.map(norm)
|
||||
.collect();
|
||||
let rect_flank = page_rects.iter().any(|&(x_lo, x_hi, y_lo, y_hi)| {
|
||||
let contains = x_lo <= rule.x1 + 2.0
|
||||
&& x_hi >= rule.x2 - 2.0
|
||||
&& y_lo <= rule.y + 2.0
|
||||
&& y_hi >= rule.y - 2.0;
|
||||
if !contains {
|
||||
return false;
|
||||
}
|
||||
// Grid evidence: a vertically abutting neighbor box with x-overlap.
|
||||
page_rects.iter().any(|&(nx_lo, nx_hi, ny_lo, ny_hi)| {
|
||||
let x_overlap = nx_hi.min(x_hi) - nx_lo.max(x_lo);
|
||||
if x_overlap <= 10.0 {
|
||||
return false;
|
||||
}
|
||||
(ny_lo - y_hi).abs() <= 3.0 || (y_lo - ny_hi).abs() <= 3.0
|
||||
})
|
||||
});
|
||||
if rect_flank {
|
||||
return true;
|
||||
}
|
||||
lines.iter().any(|l| {
|
||||
if l.page != page || (l.x1 - l.x2).abs() > 2.0 {
|
||||
return false;
|
||||
}
|
||||
let x = (l.x1 + l.x2) / 2.0;
|
||||
let near_end = (x - rule.x1).abs() <= 6.0 || (x - rule.x2).abs() <= 6.0;
|
||||
if !near_end {
|
||||
return false;
|
||||
}
|
||||
let (y_lo, y_hi) = if l.y1 <= l.y2 {
|
||||
(l.y1, l.y2)
|
||||
} else {
|
||||
(l.y2, l.y1)
|
||||
};
|
||||
y_lo <= rule.y + 2.0 && y_hi >= rule.y - 2.0
|
||||
})
|
||||
}
|
||||
|
||||
fn has_snug_text_owner(rule: &Rule, items: &[TextItem]) -> bool {
|
||||
// Underlines are drawn to the width of the text they decorate, but the
|
||||
// text may be split into several runs (CJK lines mix scripts and font
|
||||
// switches) — so ownership is judged against the UNION of the runs on
|
||||
// the rule's baseline row. Table/form rulings overshoot their row's
|
||||
// text (row separators span cell padding and empty columns), so they
|
||||
// fail either containment or coverage.
|
||||
let matched: Vec<&TextItem> = items
|
||||
.iter()
|
||||
.filter(|item| is_underline_candidate(item) && rule_matches_item(rule, item))
|
||||
.collect();
|
||||
if matched.is_empty() {
|
||||
return false;
|
||||
}
|
||||
let x1 = matched.iter().map(|i| i.x).fold(f32::INFINITY, f32::min);
|
||||
let x2 = matched
|
||||
.iter()
|
||||
.map(|i| i.x + i.width)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
let max_fs = matched.iter().map(|i| i.font_size).fold(0.0, f32::max);
|
||||
let pad = (max_fs * 0.75).max(4.0);
|
||||
if rule.x1 < x1 - pad || rule.x2 > x2 + pad {
|
||||
return false;
|
||||
}
|
||||
let covered: f32 = matched.iter().map(|i| i.width).sum();
|
||||
if covered < rule.width() * 0.6 {
|
||||
return false;
|
||||
}
|
||||
// A table row also unions to the rule's span — but its cells sit apart.
|
||||
// An underlined text line is contiguous runs with word-sized gaps; any
|
||||
// column-sized hole between matched runs means this is a row ruling.
|
||||
let mut sorted = matched;
|
||||
sorted.sort_by(|a, b| a.x.total_cmp(&b.x));
|
||||
sorted.windows(2).all(|pair| {
|
||||
let gap = pair[1].x - (pair[0].x + pair[0].width);
|
||||
gap <= (max_fs * 2.0).max(12.0)
|
||||
})
|
||||
}
|
||||
|
||||
fn is_repeated_ruling_rule(rule: &Rule, rules: &[Rule]) -> bool {
|
||||
let mut y_levels: Vec<f32> = rules
|
||||
.iter()
|
||||
@@ -215,9 +351,11 @@ fn is_underline_candidate(item: &TextItem) -> bool {
|
||||
|
||||
fn rule_matches_item(rule: &Rule, item: &TextItem) -> bool {
|
||||
// Vertical window: underlines sit at or slightly below the baseline.
|
||||
// Fonts draw them at roughly 5-15% of the em below; allow up to 35%
|
||||
// (min 3pt) below and 1pt above for rounding.
|
||||
let below = (item.font_size * 0.35).max(3.0);
|
||||
// Latin fonts draw them at roughly 5-15% of the em below; CJK layouts
|
||||
// put them under the full em box, measured up to ~0.67em below the
|
||||
// baseline (text_dense__underline). Allow 0.72em (min 3pt) below and
|
||||
// 1pt above for rounding.
|
||||
let below = (item.font_size * 0.72).max(3.0);
|
||||
let y_min = item.y - below;
|
||||
let y_max = item.y + 1.0;
|
||||
if rule.y < y_min || rule.y > y_max {
|
||||
@@ -260,12 +398,48 @@ pub(crate) fn mark_underlined_items(
|
||||
lines: &[UnderlineLine],
|
||||
page: u32,
|
||||
) {
|
||||
let rules = discard_repeated_ruling_rules(rules_from_graphics(rects, lines, page));
|
||||
let rules = discard_repeated_ruling_rules(
|
||||
rules_from_graphics(rects, lines, page),
|
||||
items,
|
||||
rects,
|
||||
lines,
|
||||
page,
|
||||
);
|
||||
if rules.is_empty() {
|
||||
return;
|
||||
}
|
||||
let tabular_rules = tabular_row_separator_rule_indices(&rules, items);
|
||||
|
||||
// Math fraction bars are short horizontal lines with the numerator just
|
||||
// above AND the denominator just below — underline geometry from above,
|
||||
// but no underline has text hanging directly beneath it at fraction
|
||||
// distance. Only narrow rules qualify: real underlines under short
|
||||
// labels have their next text line a full line-pitch away.
|
||||
let fraction_rules: HashSet<usize> = rules
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, rule)| {
|
||||
rule.width() <= 60.0
|
||||
&& items.iter().any(|item| {
|
||||
if !is_underline_candidate(item) {
|
||||
return false;
|
||||
}
|
||||
// A denominator HUGS the bar (fraction typesetting
|
||||
// leaves ~0.1-0.2em) and is bar-sized. Both bounds
|
||||
// matter: a short last-line of a paragraph at normal
|
||||
// leading sits further below, and a full next text
|
||||
// line is far wider than the rule.
|
||||
let dy = rule.y - (item.y + item.height);
|
||||
let overlap = rule.x2.min(item.x + item.width) - rule.x1.max(item.x);
|
||||
dy > 0.0
|
||||
&& dy <= item.font_size * 0.3
|
||||
&& overlap > rule.width() * 0.5
|
||||
&& item.width <= rule.width() * 1.5
|
||||
})
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
|
||||
for item in items.iter_mut() {
|
||||
if !is_underline_candidate(item) {
|
||||
continue;
|
||||
@@ -275,7 +449,10 @@ pub(crate) fn mark_underlined_items(
|
||||
if tabular_rules.contains(&rule_idx) {
|
||||
continue;
|
||||
}
|
||||
if rule_matches_item(rule, item) {
|
||||
// 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) {
|
||||
@@ -323,6 +500,16 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn cell_rect(x: f32, y: f32, width: f32, height: f32) -> PdfRect {
|
||||
PdfRect {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
page: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn thin_rect(x: f32, y: f32, width: f32) -> PdfRect {
|
||||
PdfRect {
|
||||
x,
|
||||
@@ -543,6 +730,100 @@ mod tests {
|
||||
assert!(items.iter().all(|item| !item.is_underline));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_snug_underlines_survive_ruling_filter() {
|
||||
// Dense docs underline many full-width lines: span-similar rules at
|
||||
// 3+ y-levels used to be discarded wholesale as table rulings.
|
||||
// Each rule here snugly matches one text line, so all must mark.
|
||||
let mut items = vec![
|
||||
item("first underlined line of text", 50.0, 700.0, 300.0, 11.0),
|
||||
item("second underlined line here", 50.0, 650.0, 300.0, 11.0),
|
||||
item("third underlined line as well", 50.0, 600.0, 300.0, 11.0),
|
||||
];
|
||||
let lines = vec![
|
||||
hline(50.0, 350.0, 697.0),
|
||||
hline(50.0, 350.0, 647.0),
|
||||
hline(50.0, 350.0, 597.0),
|
||||
];
|
||||
|
||||
mark_underlined_items(&mut items, &[], &lines, 1);
|
||||
|
||||
assert!(items.iter().all(|item| item.is_underline));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snug_rescue_spans_split_runs_on_one_line() {
|
||||
// A single underlined line is often split into several runs (script
|
||||
// or font switches). The union of touching runs owns the rule.
|
||||
let mut items = vec![
|
||||
item("run one", 50.0, 700.0, 100.0, 11.0),
|
||||
item("run two", 150.5, 700.0, 100.0, 11.0),
|
||||
item("run three", 251.0, 700.0, 99.0, 11.0),
|
||||
item("other a", 50.0, 650.0, 300.0, 11.0),
|
||||
item("other b", 50.0, 600.0, 300.0, 11.0),
|
||||
];
|
||||
let lines = vec![
|
||||
hline(50.0, 350.0, 697.0),
|
||||
hline(50.0, 350.0, 647.0),
|
||||
hline(50.0, 350.0, 597.0),
|
||||
];
|
||||
|
||||
mark_underlined_items(&mut items, &[], &lines, 1);
|
||||
|
||||
assert!(items[0].is_underline && items[1].is_underline && items[2].is_underline);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snug_rescue_denied_for_row_with_cell_gaps() {
|
||||
// A full-width rule whose baseline row is several items separated by
|
||||
// column-sized gaps is a table row separator, not an underline —
|
||||
// even when span-similar rules repeat down the page.
|
||||
let mut items = vec![
|
||||
item("cell a", 50.0, 700.0, 60.0, 11.0),
|
||||
item("cell b", 190.0, 700.0, 60.0, 11.0),
|
||||
item("cell c", 330.0, 700.0, 70.0, 11.0),
|
||||
item("cell d", 50.0, 650.0, 60.0, 11.0),
|
||||
item("cell e", 190.0, 650.0, 60.0, 11.0),
|
||||
item("cell f", 330.0, 650.0, 70.0, 11.0),
|
||||
];
|
||||
let lines = vec![
|
||||
hline(50.0, 400.0, 697.0),
|
||||
hline(50.0, 400.0, 647.0),
|
||||
hline(50.0, 400.0, 597.0),
|
||||
];
|
||||
|
||||
mark_underlined_items(&mut items, &[], &lines, 1);
|
||||
|
||||
assert!(items.iter().all(|item| !item.is_underline));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snug_rescue_denied_inside_cell_box() {
|
||||
// A rule snugly under one text line but enclosed by a drawn cell
|
||||
// box that TILES with vertical neighbors (grid evidence) is a row
|
||||
// ruling of a rect-grid table. Isolated boxes (callout panels) do
|
||||
// not veto — see repeated_snug_underlines_survive_ruling_filter.
|
||||
let mut items = vec![
|
||||
item("one wide cell row", 50.0, 700.0, 300.0, 11.0),
|
||||
item("second wide cell", 50.0, 650.0, 300.0, 11.0),
|
||||
item("third wide cell", 50.0, 600.0, 300.0, 11.0),
|
||||
];
|
||||
let lines = vec![
|
||||
hline(50.0, 350.0, 697.0),
|
||||
hline(50.0, 350.0, 647.0),
|
||||
hline(50.0, 350.0, 597.0),
|
||||
];
|
||||
let boxes = vec![
|
||||
cell_rect(45.0, 690.0, 320.0, 50.0),
|
||||
cell_rect(45.0, 640.0, 320.0, 50.0),
|
||||
cell_rect(45.0, 590.0, 320.0, 50.0),
|
||||
];
|
||||
|
||||
mark_underlined_items(&mut items, &boxes, &lines, 1);
|
||||
|
||||
assert!(items.iter().all(|item| !item.is_underline));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_row_spaced_rule_segments_do_not_mark_column_labels() {
|
||||
let mut items = vec![
|
||||
|
||||
+185
-531
@@ -39,6 +39,7 @@ pub mod markdown;
|
||||
pub mod process_mode;
|
||||
pub mod structure_tree;
|
||||
pub mod tables;
|
||||
mod text_quality;
|
||||
pub mod text_utils;
|
||||
pub mod tounicode;
|
||||
pub mod types;
|
||||
@@ -53,6 +54,7 @@ pub use extractor::{
|
||||
};
|
||||
pub use markdown::{
|
||||
to_markdown, to_markdown_from_items, to_markdown_from_items_with_rects, MarkdownOptions,
|
||||
MarkdownProfile,
|
||||
};
|
||||
pub use process_mode::ProcessMode;
|
||||
pub use types::{LayoutComplexity, PdfLine, PdfRect, TextItem};
|
||||
@@ -60,12 +62,28 @@ pub use types::{LayoutComplexity, PdfLine, PdfRect, TextItem};
|
||||
use lopdf::Document;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::path::Path;
|
||||
use text_quality::{
|
||||
analyze_text_quality, detect_encoding_issues, is_cid_garbage, is_garbage_text,
|
||||
region_items_have_decoding_issue,
|
||||
};
|
||||
use tounicode::FontCMaps;
|
||||
|
||||
/// OCR reason emitted when the extracted text layer appears garbled due to
|
||||
/// broken font decoding or mojibake.
|
||||
pub const OCR_REASON_SUSPECTED_GARBLED_TEXT: &str = "suspected_garbled_text";
|
||||
|
||||
/// OCR reason: the page is a scanned image (a full-page raster / image-only
|
||||
/// page) with no usable text layer.
|
||||
pub const OCR_REASON_SCANNED: &str = "scanned";
|
||||
|
||||
/// OCR reason: the page has no extractable text and no image to OCR — blank,
|
||||
/// or content the parser cannot reach.
|
||||
pub const OCR_REASON_NO_TEXT: &str = "no_text";
|
||||
|
||||
/// OCR reason: the page's text is drawn as vector outlines (path operators)
|
||||
/// rather than real text operators, so it cannot be extracted as characters.
|
||||
pub const OCR_REASON_VECTOR_TEXT: &str = "vector_text";
|
||||
|
||||
// =========================================================================
|
||||
// Result type
|
||||
// =========================================================================
|
||||
@@ -120,7 +138,7 @@ pub struct PdfProcessResult {
|
||||
/// .mode(ProcessMode::Analyze)
|
||||
/// .pages([1, 3, 5]);
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Clone)]
|
||||
pub struct PdfOptions {
|
||||
/// How far the pipeline should run (default: [`ProcessMode::Full`]).
|
||||
pub mode: ProcessMode,
|
||||
@@ -130,6 +148,23 @@ pub struct PdfOptions {
|
||||
pub markdown: MarkdownOptions,
|
||||
/// Optional set of 1-indexed pages to process. `None` = all pages.
|
||||
pub page_filter: Option<HashSet<u32>>,
|
||||
/// Password for decrypting an encrypted PDF. `None` falls back to the
|
||||
/// empty password (owner-only encryption).
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
// Manual `Debug` so the password is never leaked through debug logging or a
|
||||
// panic that formats the options; it renders as `Some("[REDACTED]")`.
|
||||
impl std::fmt::Debug for PdfOptions {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("PdfOptions")
|
||||
.field("mode", &self.mode)
|
||||
.field("detection", &self.detection)
|
||||
.field("markdown", &self.markdown)
|
||||
.field("page_filter", &self.page_filter)
|
||||
.field("password", &self.password.as_ref().map(|_| "[REDACTED]"))
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for PdfOptions {
|
||||
@@ -139,6 +174,7 @@ impl Default for PdfOptions {
|
||||
detection: DetectionConfig::default(),
|
||||
markdown: MarkdownOptions::default(),
|
||||
page_filter: None,
|
||||
password: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -180,6 +216,12 @@ impl PdfOptions {
|
||||
self.page_filter = Some(pages.into_iter().collect());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set the password used to decrypt an encrypted PDF.
|
||||
pub fn password(mut self, password: impl Into<String>) -> Self {
|
||||
self.password = Some(password.into());
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
@@ -212,7 +254,8 @@ pub fn process_pdf_with_options<P: AsRef<Path>>(
|
||||
validate_pdf_file(&path)?;
|
||||
|
||||
// Load the document once — shared by detection AND extraction.
|
||||
let (doc, page_count) = load_document_from_path(&path)?;
|
||||
let (doc, page_count) =
|
||||
load_document_from_path_with_password(&path, options.password.as_deref())?;
|
||||
|
||||
process_document(doc, page_count, options, start)
|
||||
}
|
||||
@@ -237,7 +280,8 @@ pub fn process_pdf_mem_with_options(
|
||||
let start = std::time::Instant::now();
|
||||
validate_pdf_bytes(buffer)?;
|
||||
|
||||
let (doc, page_count) = load_document_from_mem(buffer)?;
|
||||
let (doc, page_count) =
|
||||
load_document_from_mem_with_password(buffer, options.password.as_deref())?;
|
||||
|
||||
process_document(doc, page_count, options, start)
|
||||
}
|
||||
@@ -630,18 +674,51 @@ pub fn extract_text_in_regions_mem(
|
||||
|
||||
let mut page_results = Vec::with_capacity(regions.len());
|
||||
|
||||
for rect in regions {
|
||||
let [rx1, ry1, rx2, ry2] = *rect;
|
||||
// Exclusive item->region assignment: overlapping layout regions used
|
||||
// to extract shared items into EVERY region they touched (the
|
||||
// 1.5pt inclusion margin makes borders generous), duplicating whole
|
||||
// lines in the final markdown on 21% of bench docs — and downstream
|
||||
// duplicate-handling sometimes dropped the variant holding a
|
||||
// sentence tail, turning duplication into content LOSS. Each item
|
||||
// now belongs to the single region with the largest overlap area;
|
||||
// items are partitioned, never suppressed, so no content can vanish.
|
||||
let all_bounds: Vec<RegionBounds> = regions
|
||||
.iter()
|
||||
.map(|rect| {
|
||||
let [rx1, ry1, rx2, ry2] = *rect;
|
||||
region_bounds(rx1, ry1, rx2, ry2, page_h, coords)
|
||||
})
|
||||
.collect();
|
||||
// Single pass over items: assign each to the best-overlap region and
|
||||
// bucket the clone directly (review: avoid a second O(items x
|
||||
// regions) traversal). `had_candidates` marks regions that touched
|
||||
// at least one item even if every one was assigned elsewhere.
|
||||
let mut region_items: Vec<Vec<TextItem>> = vec![Vec::new(); regions.len()];
|
||||
let mut had_candidates: Vec<bool> = vec![false; regions.len()];
|
||||
if let Some(items) = items {
|
||||
for item in items {
|
||||
let mut best: Option<usize> = None;
|
||||
let mut best_area = 0.0_f32;
|
||||
for (ri, b) in all_bounds.iter().enumerate() {
|
||||
if !region_overlaps_item(item, *b) {
|
||||
continue;
|
||||
}
|
||||
had_candidates[ri] = true;
|
||||
let area = region_item_overlap_area(item, *b);
|
||||
if area > best_area {
|
||||
best_area = area;
|
||||
best = Some(ri);
|
||||
}
|
||||
}
|
||||
if let Some(ri) = best {
|
||||
region_items[ri].push(item.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let bounds = region_bounds(rx1, ry1, rx2, ry2, page_h, coords);
|
||||
let matched: Vec<TextItem> = match items {
|
||||
Some(items) => items
|
||||
.iter()
|
||||
.filter(|item| region_overlaps_item(item, bounds))
|
||||
.cloned()
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
};
|
||||
for (region_idx, _rect) in regions.iter().enumerate() {
|
||||
let matched: Vec<TextItem> = std::mem::take(&mut region_items[region_idx]);
|
||||
let assigned_count = matched.len();
|
||||
let has_text_quality_issue = region_items_have_decoding_issue(&matched);
|
||||
let text = collect_text_from_matched_items(matched, adaptive_threshold);
|
||||
let has_cid_issue = is_cid_garbage(&text);
|
||||
@@ -655,8 +732,21 @@ pub fn extract_text_in_regions_mem(
|
||||
// Check per-region text quality instead of blanket page-level
|
||||
// GID rejection. A GID font in a logo elsewhere on the page
|
||||
// shouldn't force GPU OCR for clean text regions.
|
||||
let needs_ocr =
|
||||
ocr_reason.is_some() || text.trim().is_empty() || is_garbage_text(&text);
|
||||
// A region whose ONLY overlapping items were assigned to a
|
||||
// better-overlapping neighbor must not fall back to OCR: the
|
||||
// pixels it would re-read belong to that neighbor, and OCR
|
||||
// would reintroduce the duplication exclusivity removed.
|
||||
// Before exclusive assignment these regions were non-empty
|
||||
// native (no OCR), so this preserves the old OCR load too.
|
||||
// Requires ZERO items assigned HERE: a region whose own
|
||||
// assigned items materialize to empty text (whitespace-only,
|
||||
// collector-filtered) keeps its OCR fallback.
|
||||
let lost_to_neighbor = text.trim().is_empty()
|
||||
&& ocr_reason.is_none()
|
||||
&& assigned_count == 0
|
||||
&& had_candidates[region_idx];
|
||||
let needs_ocr = !lost_to_neighbor
|
||||
&& (ocr_reason.is_some() || text.trim().is_empty() || is_garbage_text(&text));
|
||||
|
||||
page_results.push(RegionText {
|
||||
text,
|
||||
@@ -1084,7 +1174,7 @@ pub fn detect_vector_grid_in_region_mem(
|
||||
}
|
||||
|
||||
let line_tables =
|
||||
tables::detect_tables_from_lines(&items_in_region, &lines_in_region, page_1idx);
|
||||
tables::detect_vector_grid_tables_from_lines(&items_in_region, &lines_in_region, page_1idx);
|
||||
for table in line_tables {
|
||||
if let Some(result) = vector_grid_result_from_table(
|
||||
&table,
|
||||
@@ -1437,16 +1527,10 @@ mod vector_grid_tests {
|
||||
/// strip while body rows are drawn with `m`/`l` operators, so the rect
|
||||
/// cluster has only 2 Y-edges and `try_build_grid` rejects.
|
||||
///
|
||||
/// IGNORED: lifting this shape required the exact-duplicate early-dedup
|
||||
/// (PR #76 first iteration), which had broad collateral damage on
|
||||
/// SEC 10-K TOCs and similar docs that draw rule-rects above + below
|
||||
/// section dividers (production diff: 0001104659-25-093871 lost its
|
||||
/// TOC structure, perf-graph data table, and qualifications matrix).
|
||||
/// Re-enable once a more surgical lift exists in `try_build_grid` or
|
||||
/// `snap_edges` that handles cell-border + inner-fill + text-bg rect
|
||||
/// triplets without page-wide dedup.
|
||||
/// The page repeats a full-page background many times. Those fills must be
|
||||
/// removed from clustering before chart/table evidence is evaluated, or
|
||||
/// they swamp the real cell rectangles and make this table look chart-like.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn greencomp_competence_two_cols() {
|
||||
let tables = detect_rect_tables_in_fixture("tests/fixtures/greencomp_competence.pdf");
|
||||
assert!(
|
||||
@@ -3172,8 +3256,26 @@ fn region_bounds(
|
||||
}
|
||||
}
|
||||
|
||||
/// Inclusion margin shared by the region/item overlap predicates and the
|
||||
/// exclusive-assignment area score — these MUST stay in sync: an item that
|
||||
/// passes the boolean guard must always have positive overlap area.
|
||||
const REGION_MARGIN: f32 = 1.5;
|
||||
|
||||
/// Overlap area between an item and region bounds (same margin as the
|
||||
/// boolean test) — the exclusive-assignment score.
|
||||
fn region_item_overlap_area(item: &TextItem, bounds: RegionBounds) -> f32 {
|
||||
let item_x_max = item.x + text_utils::effective_width(item);
|
||||
let item_y_max = item.y + item.height;
|
||||
let x_overlap = (item_x_max.min(bounds.x_max + REGION_MARGIN)
|
||||
- item.x.max(bounds.x_min - REGION_MARGIN))
|
||||
.max(0.0);
|
||||
let y_overlap = (item_y_max.min(bounds.y_max + REGION_MARGIN)
|
||||
- item.y.max(bounds.y_min - REGION_MARGIN))
|
||||
.max(0.0);
|
||||
x_overlap * y_overlap
|
||||
}
|
||||
|
||||
fn region_overlaps_item(item: &TextItem, bounds: RegionBounds) -> bool {
|
||||
const REGION_MARGIN: f32 = 1.5;
|
||||
let item_x_min = item.x;
|
||||
let item_x_max = item.x + text_utils::effective_width(item);
|
||||
let item_y_min = item.y;
|
||||
@@ -3189,7 +3291,6 @@ fn region_overlaps_item(item: &TextItem, bounds: RegionBounds) -> bool {
|
||||
}
|
||||
|
||||
fn region_overlaps_rect(rect: &PdfRect, bounds: RegionBounds) -> bool {
|
||||
const REGION_MARGIN: f32 = 1.5;
|
||||
let (x_min, y_min, x_max, y_max) = normalized_rect_edges(rect);
|
||||
ranges_overlap(
|
||||
x_min,
|
||||
@@ -3205,7 +3306,6 @@ fn region_overlaps_rect(rect: &PdfRect, bounds: RegionBounds) -> bool {
|
||||
}
|
||||
|
||||
fn region_overlaps_line(line: &PdfLine, bounds: RegionBounds) -> bool {
|
||||
const REGION_MARGIN: f32 = 1.5;
|
||||
let x_min = line.x1.min(line.x2);
|
||||
let x_max = line.x1.max(line.x2);
|
||||
let y_min = line.y1.min(line.y2);
|
||||
@@ -3262,24 +3362,40 @@ fn tsr_region_contains_item(item: &TextItem, bounds: RegionBounds) -> bool {
|
||||
/// page count from it directly to avoid the metadata-only round-trip.
|
||||
pub(crate) fn load_document_from_path<P: AsRef<Path>>(
|
||||
path: P,
|
||||
) -> Result<(Document, u32), PdfError> {
|
||||
load_document_from_path_with_password(path, None)
|
||||
}
|
||||
|
||||
/// Load a PDF file, decrypting with `password` if the file is encrypted.
|
||||
pub(crate) fn load_document_from_path_with_password<P: AsRef<Path>>(
|
||||
path: P,
|
||||
password: Option<&str>,
|
||||
) -> Result<(Document, u32), PdfError> {
|
||||
let buffer = std::fs::read(&path)?;
|
||||
load_document_from_mem(&buffer)
|
||||
load_document_from_mem_with_password(&buffer, password)
|
||||
}
|
||||
|
||||
/// Load a PDF from a memory buffer.
|
||||
pub(crate) fn load_document_from_mem(buffer: &[u8]) -> Result<(Document, u32), PdfError> {
|
||||
load_document_from_mem_with_password(buffer, None)
|
||||
}
|
||||
|
||||
/// Load a PDF from a memory buffer, decrypting with `password` if encrypted.
|
||||
pub(crate) fn load_document_from_mem_with_password(
|
||||
buffer: &[u8],
|
||||
password: Option<&str>,
|
||||
) -> Result<(Document, u32), PdfError> {
|
||||
// Fix malformed struct element names before parsing. Some PDF generators
|
||||
// write bare names (/S Code) instead of proper PDF names (/S /Code), which
|
||||
// causes lopdf to silently drop the entire object.
|
||||
let fixed = structure_tree::fix_bare_struct_names(buffer);
|
||||
let buf = fixed.as_ref();
|
||||
|
||||
let doc = match load_document_bytes(buf) {
|
||||
let doc = match load_document_bytes(buf, password) {
|
||||
Ok(doc) => doc,
|
||||
Err(first_err) => {
|
||||
for repaired in repair_pdf_container_candidates(buf) {
|
||||
match load_document_bytes(&repaired) {
|
||||
match load_document_bytes(&repaired, password) {
|
||||
Ok(doc) => {
|
||||
log::debug!("loaded PDF after repairing malformed container bytes");
|
||||
let page_count = doc.get_pages().len() as u32;
|
||||
@@ -3299,16 +3415,34 @@ pub(crate) fn load_document_from_mem(buffer: &[u8]) -> Result<(Document, u32), P
|
||||
Ok((doc, page_count))
|
||||
}
|
||||
|
||||
fn load_document_bytes(buf: &[u8]) -> Result<Document, lopdf::Error> {
|
||||
fn load_document_bytes(buf: &[u8], password: Option<&str>) -> Result<Document, lopdf::Error> {
|
||||
match Document::load_mem(buf) {
|
||||
// Some encrypted PDFs load structurally but leave their streams
|
||||
// encrypted (`is_encrypted()` stays true); reading them yields garbage
|
||||
// until we re-load with a password. Others fail load_mem outright with
|
||||
// an encryption error. Handle both by re-loading with the password.
|
||||
Ok(doc) if doc.is_encrypted() => decrypt_document_bytes(buf, password),
|
||||
Ok(doc) => Ok(doc),
|
||||
Err(ref e) if is_encrypted_lopdf_error(e) => {
|
||||
Document::load_mem_with_options(buf, lopdf::LoadOptions::with_password(""))
|
||||
}
|
||||
Err(ref e) if is_encrypted_lopdf_error(e) => decrypt_document_bytes(buf, password),
|
||||
Err(e) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-load an encrypted PDF, decrypting with `password`. Falls back to the
|
||||
/// empty password (owner-only encryption, the common "protected" case) when a
|
||||
/// non-empty password was supplied but rejected.
|
||||
fn decrypt_document_bytes(buf: &[u8], password: Option<&str>) -> Result<Document, lopdf::Error> {
|
||||
let pw = password.unwrap_or("");
|
||||
match Document::load_mem_with_options(buf, lopdf::LoadOptions::with_password(pw)) {
|
||||
Ok(doc) => Ok(doc),
|
||||
Err(inner) if !pw.is_empty() => {
|
||||
Document::load_mem_with_options(buf, lopdf::LoadOptions::with_password(""))
|
||||
.map_err(|_| inner)
|
||||
}
|
||||
Err(inner) => Err(inner),
|
||||
}
|
||||
}
|
||||
|
||||
fn repair_pdf_container_candidates(buf: &[u8]) -> Vec<Vec<u8>> {
|
||||
let mut candidates = Vec::new();
|
||||
|
||||
@@ -3400,6 +3534,7 @@ fn process_document(
|
||||
let pages_needing_ocr = detection.pages_needing_ocr;
|
||||
let title = detection.title;
|
||||
let confidence = detection.confidence;
|
||||
let detection_ocr_reasons = detection.ocr_reasons_by_page;
|
||||
|
||||
// DetectOnly → return immediately
|
||||
if options.mode == ProcessMode::DetectOnly {
|
||||
@@ -3409,7 +3544,7 @@ fn process_document(
|
||||
page_count,
|
||||
processing_time_ms: start.elapsed().as_millis() as u64,
|
||||
pages_needing_ocr,
|
||||
ocr_reasons_by_page: Vec::new(),
|
||||
ocr_reasons_by_page: page_ocr_reasons_vec(detection_ocr_reasons),
|
||||
title,
|
||||
confidence,
|
||||
layout: LayoutComplexity::default(),
|
||||
@@ -3425,7 +3560,7 @@ fn process_document(
|
||||
page_count,
|
||||
processing_time_ms: start.elapsed().as_millis() as u64,
|
||||
pages_needing_ocr,
|
||||
ocr_reasons_by_page: Vec::new(),
|
||||
ocr_reasons_by_page: page_ocr_reasons_vec(detection_ocr_reasons),
|
||||
title,
|
||||
confidence,
|
||||
layout: LayoutComplexity::default(),
|
||||
@@ -3691,7 +3826,13 @@ fn process_document(
|
||||
page_count,
|
||||
processing_time_ms: start.elapsed().as_millis() as u64,
|
||||
pages_needing_ocr,
|
||||
ocr_reasons_by_page: page_ocr_reasons_vec(text_quality_reasons_by_page),
|
||||
ocr_reasons_by_page: {
|
||||
// Detector reasons (scanned / no_text / vector_text / garbled) merged
|
||||
// with the markdown-stage garbled detection, deduped per page.
|
||||
let mut merged = detection_ocr_reasons;
|
||||
merge_ocr_reasons(&mut merged, text_quality_reasons_by_page);
|
||||
page_ocr_reasons_vec(merged)
|
||||
},
|
||||
title,
|
||||
confidence,
|
||||
layout,
|
||||
@@ -3703,283 +3844,15 @@ fn process_document(
|
||||
// Internal helpers
|
||||
// =========================================================================
|
||||
|
||||
/// Detect broken font encodings in extracted markdown text.
|
||||
///
|
||||
/// Two heuristics:
|
||||
/// 1. **U+FFFD**: Any replacement character indicates decode failures.
|
||||
/// 2. **Dollar-as-space**: Pattern like `Word$Word$Word` where `$` is used as a
|
||||
/// word separator due to broken ToUnicode CMaps. Triggers when either:
|
||||
/// - More than 50% of `$` are between letters (clear substitution pattern), OR
|
||||
/// - More than 20 letter-dollar-letter occurrences (even if some `$` are also
|
||||
/// used as trailing/leading separators, 20+ is far beyond normal financial text).
|
||||
fn detect_encoding_issues(markdown: &str) -> bool {
|
||||
// Heuristic 1: U+FFFD replacement characters
|
||||
if markdown.contains('\u{FFFD}') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Heuristic 2: dollar-as-space pattern
|
||||
if has_dollar_as_space_pattern(markdown) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Heuristic 3: substitution-cipher letter statistics (broken ToUnicode)
|
||||
let mut stats = CipherGarbleStats::default();
|
||||
stats.add_text(markdown);
|
||||
stats.looks_garbled()
|
||||
}
|
||||
|
||||
fn has_dollar_as_space_pattern(markdown: &str) -> bool {
|
||||
let total_dollars = markdown.matches('$').count();
|
||||
if total_dollars > 10 {
|
||||
let bytes = markdown.as_bytes();
|
||||
let mut letter_dollar_letter = 0usize;
|
||||
for i in 1..bytes.len().saturating_sub(1) {
|
||||
if bytes[i] == b'$'
|
||||
&& bytes[i - 1].is_ascii_alphabetic()
|
||||
&& bytes[i + 1].is_ascii_alphabetic()
|
||||
{
|
||||
letter_dollar_letter += 1;
|
||||
}
|
||||
}
|
||||
if letter_dollar_letter > 20 || letter_dollar_letter * 2 > total_dollars {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// English letter frequencies (percent, a–z). Used as a natural-language
|
||||
/// reference: every Latin-script language in the eval corpus (Swedish,
|
||||
/// Finnish, Turkish, German, romaji) scores ≥ 0.80 cosine similarity against
|
||||
/// it, while substitution-cipher text scores ~0.53.
|
||||
const ENGLISH_LETTER_FREQ: [f64; 26] = [
|
||||
8.2, 1.5, 2.8, 4.3, 12.7, 2.2, 2.0, 6.1, 7.0, 0.15, 0.8, 4.0, 2.4, 6.7, 7.5, 1.9, 0.1, 6.0,
|
||||
6.3, 9.1, 2.8, 1.0, 2.4, 0.15, 2.0, 0.07,
|
||||
];
|
||||
|
||||
/// Letter statistics for detecting substitution-cipher garbling: broken
|
||||
/// ToUnicode CMaps that shift every character by a per-range constant (e.g.
|
||||
/// `Certificate` extracted as `8VceZWZTReV`). Such text is 100% printable
|
||||
/// ASCII with word-like token lengths, so it defeats `is_garbage_text` and
|
||||
/// produces no replacement characters — it needs its own discriminator.
|
||||
#[derive(Debug, Default)]
|
||||
struct CipherGarbleStats {
|
||||
/// Case-folded ASCII letter histogram.
|
||||
letter_counts: [u32; 26],
|
||||
ascii_letters: usize,
|
||||
ascii_vowels: usize,
|
||||
/// Accented Latin letters (Latin-1 Supplement through Latin Extended-B,
|
||||
/// plus Latin Extended Additional). Count toward Latin dominance only.
|
||||
latin_ext_letters: usize,
|
||||
non_latin_letters: usize,
|
||||
/// Adjacent ASCII-letter pairs, and how many of them switch from
|
||||
/// lowercase straight to uppercase mid-word.
|
||||
letter_bigrams: usize,
|
||||
case_shift_bigrams: usize,
|
||||
}
|
||||
|
||||
impl CipherGarbleStats {
|
||||
fn add_text(&mut self, text: &str) {
|
||||
let mut prev: Option<char> = None;
|
||||
for ch in text.chars() {
|
||||
if ch.is_ascii_alphabetic() {
|
||||
let idx = (ch.to_ascii_lowercase() as u8 - b'a') as usize;
|
||||
self.letter_counts[idx] += 1;
|
||||
self.ascii_letters += 1;
|
||||
if matches!(ch.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u') {
|
||||
self.ascii_vowels += 1;
|
||||
}
|
||||
if let Some(p) = prev {
|
||||
self.letter_bigrams += 1;
|
||||
if p.is_ascii_lowercase() && ch.is_ascii_uppercase() {
|
||||
self.case_shift_bigrams += 1;
|
||||
}
|
||||
}
|
||||
prev = Some(ch);
|
||||
} else {
|
||||
if ch.is_alphabetic() {
|
||||
if matches!(ch as u32, 0xC0..=0x24F | 0x1E00..=0x1EFF) {
|
||||
self.latin_ext_letters += 1;
|
||||
} else {
|
||||
self.non_latin_letters += 1;
|
||||
}
|
||||
}
|
||||
prev = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cosine similarity between the observed letter histogram and English
|
||||
/// letter frequencies. A shifted alphabet permutes the histogram, which
|
||||
/// destroys the similarity regardless of the shift amount.
|
||||
fn english_cosine(&self) -> f64 {
|
||||
if self.ascii_letters == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
let n = self.ascii_letters as f64;
|
||||
let mut dot = 0.0;
|
||||
let mut norm_obs = 0.0;
|
||||
for (count, freq) in self.letter_counts.iter().zip(ENGLISH_LETTER_FREQ) {
|
||||
let p = *count as f64 / n;
|
||||
dot += p * freq;
|
||||
norm_obs += p * p;
|
||||
}
|
||||
let norm_en = ENGLISH_LETTER_FREQ
|
||||
.iter()
|
||||
.map(|f| f * f)
|
||||
.sum::<f64>()
|
||||
.sqrt();
|
||||
dot / (norm_obs.sqrt() * norm_en)
|
||||
}
|
||||
|
||||
/// Cosine similarity between the observed histogram and English
|
||||
/// frequencies after sorting BOTH descending — i.e. comparing the *shape*
|
||||
/// of the frequency profile, ignoring which letter sits where. A
|
||||
/// substitution cipher is a bijection, so it preserves this shape exactly
|
||||
/// (att10k 0.97, arbitrary shifts 0.99) regardless of case or offset.
|
||||
/// Non-linguistic ASCII has a different profile: a small alphabet is far
|
||||
/// steeper (random DNA 0.74, hex dumps 0.81), so the shape diverges.
|
||||
fn english_shape_cosine(&self) -> f64 {
|
||||
if self.ascii_letters == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
let n = self.ascii_letters as f64;
|
||||
let mut obs: [f64; 26] = std::array::from_fn(|i| self.letter_counts[i] as f64 / n);
|
||||
obs.sort_unstable_by(|a, b| b.total_cmp(a));
|
||||
let mut en = ENGLISH_LETTER_FREQ;
|
||||
en.sort_unstable_by(|a, b| b.total_cmp(a));
|
||||
|
||||
let dot: f64 = obs.iter().zip(en).map(|(o, e)| o * e).sum();
|
||||
let norm_obs = obs.iter().map(|o| o * o).sum::<f64>().sqrt();
|
||||
let norm_en = en.iter().map(|e| e * e).sum::<f64>().sqrt();
|
||||
dot / (norm_obs * norm_en)
|
||||
}
|
||||
|
||||
/// Thresholds validated against the 380-document pdf-evals snapshot
|
||||
/// corpus (0 false positives) and the garbled ParseBench `att10k` page
|
||||
/// (vowel ratio 0.245, case-shift rate 0.225, cosine 0.532). Closest
|
||||
/// legitimate document on each axis: vowel ratio 0.264 (circuit
|
||||
/// schematic), case-shift rate 0.021, cosine 0.801.
|
||||
fn looks_garbled(&self) -> bool {
|
||||
// Need a statistically meaningful, Latin-dominant sample.
|
||||
if self.ascii_letters < 200
|
||||
|| self.non_latin_letters > self.ascii_letters + self.latin_ext_letters
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Real Latin-script text keeps vowels above ~30% of letters even in
|
||||
// acronym- and part-number-heavy documents; shifted text starves them.
|
||||
let vowel_ratio = self.ascii_vowels as f64 / self.ascii_letters as f64;
|
||||
if vowel_ratio > 0.30 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Signal 1: lowercase→uppercase transitions inside words. A shifted
|
||||
// lowercase alphabet straddles the ASCII uppercase block ('i'→'Z',
|
||||
// 't'→'e'), so garbled words flip case constantly. Real documents
|
||||
// stay ≤ 0.02 even with camelCase identifiers.
|
||||
let case_shifts = self.letter_bigrams >= 100
|
||||
&& self.case_shift_bigrams as f64 >= self.letter_bigrams as f64 * 0.10;
|
||||
|
||||
// Signal 2: the histogram is a permutation of natural language — an
|
||||
// English-like frequency SHAPE (sorted cosine high) but with letters
|
||||
// in the wrong POSITIONS (unsorted cosine low). This is the signature
|
||||
// of a substitution cipher and is case-independent, so it catches
|
||||
// all-lowercase and all-uppercase shifts as well as case-straddling
|
||||
// ones. Genuinely non-linguistic ASCII that is merely "unlike English"
|
||||
// fails one of the two halves: DNA/hex dumps have too steep a profile
|
||||
// (shape cosine < 0.90), while protein sequences, ticker symbols and
|
||||
// base64 are not sufficiently unlike English in position (unsorted
|
||||
// cosine ≥ 0.60) — so none of them are routed to OCR.
|
||||
let permuted_language = self.english_cosine() < 0.60 && self.english_shape_cosine() >= 0.90;
|
||||
|
||||
case_shifts || permuted_language
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct TextQualityReport {
|
||||
pages_needing_ocr: Vec<u32>,
|
||||
has_encoding_issues: bool,
|
||||
reasons_by_page: BTreeMap<u32, Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct PageTextQualityEvidence {
|
||||
chars: usize,
|
||||
replacement_chars: usize,
|
||||
replacement_spans: usize,
|
||||
longest_replacement_run: usize,
|
||||
cipher_garble: CipherGarbleStats,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum TextSpanIssueKind {
|
||||
Replacement,
|
||||
Strong,
|
||||
}
|
||||
|
||||
fn analyze_text_quality(items: &[TextItem]) -> TextQualityReport {
|
||||
let mut reasons_by_page = BTreeMap::new();
|
||||
let mut evidence_by_page = BTreeMap::<u32, PageTextQualityEvidence>::new();
|
||||
|
||||
for item in items {
|
||||
if !matches!(item.item_type, crate::types::ItemType::Text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let evidence = evidence_by_page.entry(item.page).or_default();
|
||||
evidence.chars += item.text.chars().filter(|ch| !ch.is_whitespace()).count();
|
||||
evidence.cipher_garble.add_text(&item.text);
|
||||
|
||||
match text_span_decoding_issue_kind(&item.text) {
|
||||
Some(TextSpanIssueKind::Strong) => {
|
||||
add_ocr_reason(
|
||||
&mut reasons_by_page,
|
||||
item.page,
|
||||
OCR_REASON_SUSPECTED_GARBLED_TEXT,
|
||||
);
|
||||
}
|
||||
Some(TextSpanIssueKind::Replacement) => {
|
||||
let stats = replacement_text_stats(&item.text);
|
||||
evidence.replacement_chars += stats.0;
|
||||
evidence.replacement_spans += 1;
|
||||
evidence.longest_replacement_run = evidence.longest_replacement_run.max(stats.1);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
for (page, evidence) in evidence_by_page {
|
||||
if reasons_by_page.contains_key(&page) {
|
||||
continue;
|
||||
}
|
||||
if page_replacement_evidence_needs_ocr(&evidence) || evidence.cipher_garble.looks_garbled()
|
||||
{
|
||||
add_ocr_reason(
|
||||
&mut reasons_by_page,
|
||||
page,
|
||||
OCR_REASON_SUSPECTED_GARBLED_TEXT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let pages_needing_ocr: Vec<u32> = reasons_by_page.keys().copied().collect();
|
||||
TextQualityReport {
|
||||
has_encoding_issues: !pages_needing_ocr.is_empty(),
|
||||
pages_needing_ocr,
|
||||
reasons_by_page,
|
||||
}
|
||||
}
|
||||
|
||||
fn suspected_garbled_reason() -> String {
|
||||
OCR_REASON_SUSPECTED_GARBLED_TEXT.to_string()
|
||||
}
|
||||
|
||||
fn add_ocr_reason(reasons_by_page: &mut BTreeMap<u32, Vec<String>>, page: u32, reason: &str) {
|
||||
pub(crate) fn add_ocr_reason(
|
||||
reasons_by_page: &mut BTreeMap<u32, Vec<String>>,
|
||||
page: u32,
|
||||
reason: &str,
|
||||
) {
|
||||
let reasons = reasons_by_page.entry(page).or_default();
|
||||
if !reasons.iter().any(|existing| existing == reason) {
|
||||
reasons.push(reason.to_string());
|
||||
@@ -4011,225 +3884,6 @@ fn page_ocr_reasons_vec(reasons_by_page: BTreeMap<u32, Vec<String>>) -> Vec<Page
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn region_items_have_decoding_issue(items: &[TextItem]) -> bool {
|
||||
items.iter().any(|item| {
|
||||
matches!(item.item_type, crate::types::ItemType::Text)
|
||||
&& text_span_has_decoding_issue(&item.text)
|
||||
})
|
||||
}
|
||||
|
||||
fn text_span_has_decoding_issue(text: &str) -> bool {
|
||||
text_span_decoding_issue_kind(text).is_some()
|
||||
}
|
||||
|
||||
fn text_span_decoding_issue_kind(text: &str) -> Option<TextSpanIssueKind> {
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if has_dollar_as_space_pattern(text)
|
||||
|| has_private_use_text_run(text)
|
||||
|| is_cid_garbage(text)
|
||||
|| has_cid_control_token(text)
|
||||
{
|
||||
return Some(TextSpanIssueKind::Strong);
|
||||
}
|
||||
|
||||
if has_replacement_text_run(text) {
|
||||
return Some(TextSpanIssueKind::Replacement);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn replacement_text_stats(text: &str) -> (usize, usize) {
|
||||
let mut replacement = 0usize;
|
||||
let mut current_run = 0usize;
|
||||
let mut longest_run = 0usize;
|
||||
|
||||
for ch in text.chars() {
|
||||
if ch == '\u{FFFD}' {
|
||||
replacement += 1;
|
||||
current_run += 1;
|
||||
longest_run = longest_run.max(current_run);
|
||||
} else {
|
||||
current_run = 0;
|
||||
}
|
||||
}
|
||||
|
||||
(replacement, longest_run)
|
||||
}
|
||||
|
||||
fn page_replacement_evidence_needs_ocr(evidence: &PageTextQualityEvidence) -> bool {
|
||||
if evidence.replacement_chars == 0 || evidence.chars == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the entire page is only a short broken text layer, even a short
|
||||
// replacement run is enough evidence. On otherwise text-heavy pages,
|
||||
// require density so math formulas do not force full-page OCR.
|
||||
if evidence.chars <= 80 && evidence.longest_replacement_run >= 2 {
|
||||
return true;
|
||||
}
|
||||
|
||||
let replacement_density_bps = evidence.replacement_chars * 10_000 / evidence.chars;
|
||||
let enough_bad_text = evidence.replacement_chars >= 12 && replacement_density_bps >= 500;
|
||||
let repeated_bad_spans = evidence.replacement_spans >= 3 && replacement_density_bps >= 250;
|
||||
let long_bad_run = evidence.longest_replacement_run >= 8 && replacement_density_bps >= 250;
|
||||
|
||||
enough_bad_text || repeated_bad_spans || long_bad_run
|
||||
}
|
||||
|
||||
fn has_replacement_text_run(text: &str) -> bool {
|
||||
let (replacement, longest_run) = replacement_text_stats(text);
|
||||
longest_run >= 2 || replacement >= 3
|
||||
}
|
||||
|
||||
fn has_private_use_text_run(text: &str) -> bool {
|
||||
let mut total = 0usize;
|
||||
let mut private_use = 0usize;
|
||||
let mut current_run = 0usize;
|
||||
let mut longest_run = 0usize;
|
||||
|
||||
for ch in text.chars() {
|
||||
if ch.is_whitespace() {
|
||||
current_run = 0;
|
||||
continue;
|
||||
}
|
||||
total += 1;
|
||||
if is_private_use_char(ch) {
|
||||
private_use += 1;
|
||||
current_run += 1;
|
||||
longest_run = longest_run.max(current_run);
|
||||
} else {
|
||||
current_run = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if private_use == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
longest_run >= 3 || (total >= 5 && private_use >= 2 && private_use * 2 >= total)
|
||||
}
|
||||
|
||||
fn has_cid_control_token(text: &str) -> bool {
|
||||
text.split_whitespace().any(token_has_cid_control)
|
||||
}
|
||||
|
||||
fn token_has_cid_control(token: &str) -> bool {
|
||||
let mut total = 0usize;
|
||||
let mut c1_control = 0usize;
|
||||
|
||||
for ch in token.chars() {
|
||||
total += 1;
|
||||
if ('\u{0080}'..='\u{009F}').contains(&ch) {
|
||||
c1_control += 1;
|
||||
}
|
||||
}
|
||||
|
||||
total >= 5 && c1_control >= 2 && c1_control * 20 >= total
|
||||
}
|
||||
|
||||
fn is_private_use_char(ch: char) -> bool {
|
||||
matches!(
|
||||
ch as u32,
|
||||
0xE000..=0xF8FF | 0xF0000..=0xFFFFD | 0x100000..=0x10FFFD
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if extracted text is predominantly garbage (non-alphanumeric).
|
||||
///
|
||||
/// Broken font encodings produce text like "----1-.-.-.___ --.-. .._ I_---."
|
||||
/// where most characters are punctuation/symbols. Real text in any language
|
||||
/// has >50% alphanumeric characters.
|
||||
fn is_garbage_text(markdown: &str) -> bool {
|
||||
let mut alphanum = 0usize;
|
||||
let mut non_alphanum = 0usize;
|
||||
|
||||
let chars: Vec<char> = markdown.chars().collect();
|
||||
let mut i = 0usize;
|
||||
while i < chars.len() {
|
||||
let ch = chars[i];
|
||||
let mut run_end = i + 1;
|
||||
while run_end < chars.len() && chars[run_end] == ch {
|
||||
run_end += 1;
|
||||
}
|
||||
|
||||
let is_decorative_leader = matches!(ch, '.' | '_' | '·') && run_end - i >= 3;
|
||||
if !is_decorative_leader {
|
||||
for &run_ch in &chars[i..run_end] {
|
||||
if run_ch.is_whitespace() {
|
||||
continue;
|
||||
}
|
||||
// Skip markdown syntax chars that we add (not from the PDF)
|
||||
if matches!(run_ch, '#' | '*' | '|' | '-' | '\n') {
|
||||
continue;
|
||||
}
|
||||
if run_ch.is_alphanumeric() {
|
||||
alphanum += 1;
|
||||
} else {
|
||||
non_alphanum += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
i = run_end;
|
||||
}
|
||||
|
||||
let total = alphanum + non_alphanum;
|
||||
total >= 50 && alphanum * 2 < total
|
||||
}
|
||||
|
||||
/// Detect garbage from failed CID-to-Unicode mapping on Identity-H fonts.
|
||||
///
|
||||
/// When CID values don't correspond to Unicode codepoints, the raw bytes often
|
||||
/// produce characters in the C1 control range (U+0080–U+009F) or Private Use
|
||||
/// Area, mixed with random Latin Extended characters. Valid text in any
|
||||
/// language almost never contains C1 controls. We also fall back to the
|
||||
/// general `is_garbage_text` check for non-alphanumeric-heavy patterns.
|
||||
fn is_cid_garbage(text: &str) -> bool {
|
||||
if is_garbage_text(text) {
|
||||
return true;
|
||||
}
|
||||
let mut total = 0usize;
|
||||
let mut c1_control = 0usize;
|
||||
let mut high_latin = 0usize;
|
||||
for ch in text.chars() {
|
||||
if ch.is_whitespace() {
|
||||
continue;
|
||||
}
|
||||
total += 1;
|
||||
// C1 control characters (U+0080–U+009F) — almost never in real text
|
||||
if ch == '·' {
|
||||
continue;
|
||||
}
|
||||
if ('\u{0080}'..='\u{009F}').contains(&ch) {
|
||||
c1_control += 1;
|
||||
}
|
||||
// High Latin-1 (U+00A0–U+00FF) — legitimate in Western European text
|
||||
// but when combined with ASCII in CID passthrough, indicates mojibake
|
||||
// from CID values being misinterpreted as Latin-1 characters.
|
||||
if ('\u{00A0}'..='\u{00FF}').contains(&ch) {
|
||||
high_latin += 1;
|
||||
}
|
||||
}
|
||||
if total < 5 {
|
||||
return false;
|
||||
}
|
||||
// If ≥5% of non-whitespace chars are C1 controls, it's garbage
|
||||
if c1_control >= 2 && c1_control * 20 >= total {
|
||||
return true;
|
||||
}
|
||||
// If ≥40% of non-whitespace chars are high Latin-1 AND the text has few
|
||||
// ASCII letters, it's likely CID-as-Latin-1 mojibake (Japanese/CJK PDFs
|
||||
// where CID values 0x80-0xFF become accented Latin characters). Keep a
|
||||
// minimum length so short math tokens like "2×()×" do not route a clean
|
||||
// page to OCR.
|
||||
let ascii_letters = text.chars().filter(|c| c.is_ascii_alphabetic()).count();
|
||||
total >= 20 && high_latin * 5 >= total * 2 && ascii_letters * 3 < total
|
||||
}
|
||||
|
||||
/// Detect markdown tables with suspicious structure that suggest the heuristic
|
||||
/// missed/mangled rows or columns. Returns true when the caller should treat
|
||||
/// the result as `needs_ocr` and fall back to GPU OCR.
|
||||
|
||||
@@ -130,6 +130,123 @@ pub(crate) fn has_dot_leaders(text: &str) -> bool {
|
||||
dot_groups >= 2
|
||||
}
|
||||
|
||||
/// Detect a table-of-contents entry: a line ending in a page number preceded by
|
||||
/// a dot-leader group (e.g. "Measurement Lab worksheet ... 3"). `has_dot_leaders`
|
||||
/// misses single-group leaders ("..."), but a trailing "<dots> <number>" is a
|
||||
/// strong TOC signal on its own. Such lines must never be promoted to headings.
|
||||
pub(crate) fn is_toc_entry_line(text: &str) -> bool {
|
||||
let trimmed = text.trim_end();
|
||||
let digits = trimmed
|
||||
.chars()
|
||||
.rev()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.count();
|
||||
if digits == 0 || digits > 4 {
|
||||
return false;
|
||||
}
|
||||
let before_number = trimmed[..trimmed.len() - digits].trim_end();
|
||||
let dots = before_number
|
||||
.chars()
|
||||
.rev()
|
||||
.take_while(|c| *c == '.')
|
||||
.count();
|
||||
dots >= 3
|
||||
}
|
||||
|
||||
/// A heading that announces a table of contents ("Contents", "Table of
|
||||
/// Contents"). Lines after it on the same page are ToC entries — section
|
||||
/// titles that look exactly like headings but must not be promoted.
|
||||
pub(crate) fn is_toc_marker_heading(text: &str) -> bool {
|
||||
let t = text.trim().trim_end_matches(':').trim().to_lowercase();
|
||||
matches!(t.as_str(), "contents" | "table of contents")
|
||||
}
|
||||
|
||||
/// Lines that resemble headings structurally but are display-math fragments:
|
||||
/// equations ending in an equation number ("S = kB ln W, (2)") or equation
|
||||
/// lead-ins ("Rearranging Equation (8) gives:"). Both carry an "(N)" equation
|
||||
/// reference — but a trailing "(N)" alone is not enough: real headings end
|
||||
/// with parenthesized numbers too ("Nicaea (325)", appendix numbering), so
|
||||
/// the suffix form additionally requires math evidence — an "=" in the line
|
||||
/// or a comma immediately before the number, both present in every display
|
||||
/// equation and absent from name-plus-number headings. A bare trailing colon
|
||||
/// is NOT a fragment signal either: real headings frequently end with colons
|
||||
/// ("Procedure:", "Steps for Using the Microscope:").
|
||||
pub(crate) fn is_heading_fragment(text: &str) -> bool {
|
||||
let t = text.trim_end();
|
||||
|
||||
// A lowercase-initial one-or-two-word "heading" is a mid-sentence
|
||||
// fragment beside display math ("or inversely", "and therefore") —
|
||||
// real headings that short start uppercase. Measured as spurious
|
||||
// headings on academic docs (fire-pdf ENG-5029 / opendataloader MHS).
|
||||
{
|
||||
let words: Vec<&str> = t.split_whitespace().collect();
|
||||
if words.len() <= 2 {
|
||||
if let Some(first_alpha) = t.chars().find(|c| c.is_alphabetic()) {
|
||||
if first_alpha.is_lowercase() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_equation_number(s: &str) -> bool {
|
||||
s.strip_prefix('(')
|
||||
.and_then(|r| r.strip_suffix(')'))
|
||||
.is_some_and(|inner| {
|
||||
!inner.is_empty() && inner.len() <= 3 && inner.chars().all(|c| c.is_ascii_digit())
|
||||
})
|
||||
}
|
||||
|
||||
// Equation-number suffix with math evidence: "S = kB ln W, (2)"
|
||||
let mut rev = t.rsplit(' ');
|
||||
let last = rev.next().unwrap_or("");
|
||||
if is_equation_number(last) {
|
||||
// Page-of-total running headers: "LIVSMEDELSVERKET PM 2 (10)"
|
||||
if let Some(prev_word) = t.rsplit(' ').nth(1) {
|
||||
if let (Ok(page), Some(total)) = (
|
||||
prev_word.parse::<u32>(),
|
||||
last.trim_start_matches('(')
|
||||
.trim_end_matches(')')
|
||||
.parse::<u32>()
|
||||
.ok(),
|
||||
) {
|
||||
if page <= total {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
let punct_before = rev
|
||||
.next()
|
||||
.is_some_and(|w| w.ends_with(',') || w.ends_with(':'));
|
||||
let has_math_op = t.chars().any(|c| {
|
||||
matches!(
|
||||
c,
|
||||
'=' | '<'
|
||||
| '>'
|
||||
| '≤'
|
||||
| '≥'
|
||||
| '≪'
|
||||
| '≫'
|
||||
| '≈'
|
||||
| '≠'
|
||||
| '±'
|
||||
| '∑'
|
||||
| '∫'
|
||||
| '√'
|
||||
| '∝'
|
||||
)
|
||||
});
|
||||
if punct_before || has_math_op {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Lead-in: ends with a colon AND references an equation number inline
|
||||
if t.ends_with(':') && t.split_whitespace().any(is_equation_number) {
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Compute the Y-gap threshold for paragraph break detection.
|
||||
///
|
||||
/// Instead of using a fixed multiple of base_size (which fails for double-spaced
|
||||
@@ -257,6 +374,15 @@ pub(crate) fn compute_heading_tiers(lines: &[TextLine], base_size: f32) -> Vec<f
|
||||
for line in lines {
|
||||
if let Some(first) = line.items.first() {
|
||||
if first.font_size / base_size >= 1.2 {
|
||||
// Digit-only lines (page numbers, issue numbers) must not
|
||||
// define heading tiers: a large bold folio claims tier 0 and
|
||||
// blocks the bold-size fallback for the document's real
|
||||
// same-size headings.
|
||||
let text = line.text();
|
||||
let t = text.trim();
|
||||
if !t.is_empty() && t.chars().all(|c| !c.is_alphabetic()) {
|
||||
continue;
|
||||
}
|
||||
heading_sizes.push(first.font_size);
|
||||
}
|
||||
}
|
||||
@@ -274,11 +400,45 @@ pub(crate) fn compute_heading_tiers(lines: &[TextLine], base_size: f32) -> Vec<f
|
||||
}
|
||||
}
|
||||
|
||||
// Books often set section headings barely above body size (e.g. 11pt
|
||||
// bold over 10pt text). When nothing clears the 1.2x ratio gate, fall
|
||||
// back to bold lines modestly larger than body so those documents still
|
||||
// get an H1 instead of every bold heading defaulting to H2.
|
||||
if tiers.is_empty() {
|
||||
let mut bold_sizes: Vec<f32> = lines
|
||||
.iter()
|
||||
.filter(|line| {
|
||||
let text = line.text();
|
||||
let t = text.trim();
|
||||
!t.is_empty() && t.chars().any(|c| c.is_alphabetic())
|
||||
})
|
||||
.filter_map(|line| line.items.first())
|
||||
.filter(|it| it.is_bold && it.font_size / base_size >= 1.05)
|
||||
.map(|it| it.font_size)
|
||||
.collect();
|
||||
bold_sizes.sort_by(|a, b| b.total_cmp(a));
|
||||
for size in bold_sizes {
|
||||
if !tiers.iter().any(|&t| (t - size).abs() < 0.5) {
|
||||
tiers.push(size);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cap at 4 tiers
|
||||
tiers.truncate(4);
|
||||
tiers
|
||||
}
|
||||
|
||||
/// Boldness of a line judged by character mass, so a heading with an
|
||||
/// unbold section-number prefix ("4. " + bold title) still counts as bold.
|
||||
pub(crate) fn line_is_mostly_bold(line: &TextLine) -> bool {
|
||||
let (bold, total) = line.items.iter().fold((0usize, 0usize), |(b, t), it| {
|
||||
let n = it.text.trim().chars().count();
|
||||
(b + if it.is_bold { n } else { 0 }, t + n)
|
||||
});
|
||||
total > 0 && bold * 2 >= total
|
||||
}
|
||||
|
||||
/// Detect header level from font size using document-specific heading tiers.
|
||||
/// When tiers are available, maps tier 0→H1, tier 1→H2, etc.
|
||||
/// Falls back to ratio-based thresholds when no tiers exist.
|
||||
@@ -286,9 +446,21 @@ pub(crate) fn detect_header_level(
|
||||
font_size: f32,
|
||||
base_size: f32,
|
||||
heading_tiers: &[f32],
|
||||
is_bold: bool,
|
||||
) -> Option<usize> {
|
||||
let ratio = font_size / base_size;
|
||||
|
||||
// Tier matches are trusted below the 1.2x gate (down to 1.05x) only for
|
||||
// bold lines: sub-gate tiers come from the bold fallback, and honoring
|
||||
// them for non-bold text at the same size would promote captions.
|
||||
if (1.05..1.2).contains(&ratio) && is_bold && !heading_tiers.is_empty() {
|
||||
for (i, &tier_size) in heading_tiers.iter().enumerate() {
|
||||
if (font_size - tier_size).abs() < 0.5 {
|
||||
return Some(i + 1); // tier 0 → H1, tier 1 → H2, etc.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ratio < 1.2 {
|
||||
return None; // Regular text
|
||||
}
|
||||
@@ -320,3 +492,138 @@ pub(crate) fn detect_header_level(
|
||||
Some(4)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn line_of(text: &str, font_size: f32, bold: bool, y: f32) -> crate::types::TextLine {
|
||||
let item = crate::types::TextItem {
|
||||
text: text.into(),
|
||||
x: 72.0,
|
||||
y,
|
||||
width: text.len() as f32 * font_size * 0.5,
|
||||
height: font_size,
|
||||
font: "Test".into(),
|
||||
font_size,
|
||||
page: 1,
|
||||
is_bold: bold,
|
||||
is_italic: false,
|
||||
is_underline: false,
|
||||
is_strikeout: false,
|
||||
item_type: crate::types::ItemType::Text,
|
||||
mcid: None,
|
||||
};
|
||||
crate::types::TextLine {
|
||||
items: vec![item],
|
||||
y,
|
||||
page: 1,
|
||||
adaptive_threshold: 0.10,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn digit_only_lines_do_not_define_tiers() {
|
||||
// A 14pt bold page number must not claim tier 0 — that both demotes
|
||||
// every real heading a level and blocks the bold-size fallback.
|
||||
let lines = vec![
|
||||
line_of("76", 14.0, true, 760.0),
|
||||
line_of("Replace", 11.0, true, 700.0),
|
||||
line_of("body text at eleven points", 11.0, false, 680.0),
|
||||
];
|
||||
let tiers = compute_heading_tiers(&lines, 11.0);
|
||||
assert!(tiers.is_empty(), "page number claimed a tier: {tiers:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold_fallback_tiers_when_nothing_clears_ratio_gate() {
|
||||
// 10pt body, 11pt bold section headings (book-style): no size clears
|
||||
// 1.2x, so bold sizes modestly above body form the tiers.
|
||||
let lines = vec![
|
||||
line_of("4. Entropy", 11.0, true, 700.0),
|
||||
line_of("body text about entropy", 10.0, false, 680.0),
|
||||
line_of("5. The dynamics", 11.0, true, 500.0),
|
||||
];
|
||||
let tiers = compute_heading_tiers(&lines, 10.0);
|
||||
assert_eq!(tiers, vec![11.0]);
|
||||
assert_eq!(detect_header_level(11.0, 10.0, &tiers, true), Some(1));
|
||||
// Non-bold text at the fallback size must not become a heading.
|
||||
assert_eq!(detect_header_level(11.0, 10.0, &tiers, false), None);
|
||||
// Non-tier body text stays regular.
|
||||
assert_eq!(detect_header_level(10.0, 10.0, &tiers, true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bold_fallback_skipped_when_real_tiers_exist() {
|
||||
let lines = vec![
|
||||
line_of("Chapter One", 18.0, false, 700.0),
|
||||
line_of("bold label", 11.0, true, 600.0),
|
||||
line_of("body", 10.0, false, 580.0),
|
||||
];
|
||||
let tiers = compute_heading_tiers(&lines, 10.0);
|
||||
assert_eq!(tiers, vec![18.0]);
|
||||
// The 11pt bold label does not match any tier and stays non-heading.
|
||||
assert_eq!(detect_header_level(11.0, 10.0, &tiers, true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_entry_with_single_dot_group() {
|
||||
assert!(is_toc_entry_line("Measurement Lab worksheet ... 3"));
|
||||
assert!(is_toc_entry_line("Results ........ 12"));
|
||||
assert!(is_toc_entry_line("Appendix B...42"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_toc_lines_pass() {
|
||||
assert!(!is_toc_entry_line(
|
||||
"6.2. Expectations for Re-Hiring Employees"
|
||||
));
|
||||
assert!(!is_toc_entry_line("What happened in 2020"));
|
||||
assert!(!is_toc_entry_line("IMPLEMENTATION"));
|
||||
// Ellipsis without a trailing page number
|
||||
assert!(!is_toc_entry_line("and so it goes ..."));
|
||||
// Long numbers are data, not page refs
|
||||
assert!(!is_toc_entry_line("ISBN ... 97814"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn toc_marker_headings() {
|
||||
assert!(is_toc_marker_heading("Contents"));
|
||||
assert!(is_toc_marker_heading("CONTENTS"));
|
||||
assert!(is_toc_marker_heading("Table of Contents"));
|
||||
assert!(is_toc_marker_heading("Table of contents:"));
|
||||
assert!(!is_toc_marker_heading("Contents of the Shipment"));
|
||||
assert!(!is_toc_marker_heading("Introduction"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_fragments() {
|
||||
// Equation lead-ins: colon ending + inline equation reference
|
||||
assert!(is_heading_fragment("or inversely"));
|
||||
assert!(is_heading_fragment("and therefore"));
|
||||
assert!(!is_heading_fragment("Introduction"));
|
||||
assert!(!is_heading_fragment("iPhone Sales Strategy Overview")); // 4 words, exempt
|
||||
assert!(is_heading_fragment("Rearranging Equation (8) gives:"));
|
||||
// Display-equation neighbours ending in an equation number
|
||||
assert!(is_heading_fragment("S = kB ln W, (2)"));
|
||||
assert!(is_heading_fragment("E = mc2 (12)"));
|
||||
assert!(is_heading_fragment("x + y = z, (3)"));
|
||||
// Page-of-total running headers
|
||||
assert!(is_heading_fragment("LIVSMEDELSVERKET PM 2 (10)"));
|
||||
// Comparison-operator evidence and colon-before-number
|
||||
assert!(is_heading_fragment(
|
||||
"PLL\u{fe} PHH\u{226a} PLH\u{fe} PHL: (12)"
|
||||
));
|
||||
// Real headings pass — including name-plus-number and colon-ended ones
|
||||
assert!(!is_heading_fragment("Nicaea (325)"));
|
||||
assert!(!is_heading_fragment(
|
||||
"\u{627}\u{644}\u{645}\u{644}\u{62d}\u{642} \u{631}\u{642}\u{645} (1)"
|
||||
));
|
||||
assert!(!is_heading_fragment("4. Entropy"));
|
||||
assert!(!is_heading_fragment("Procedure:"));
|
||||
assert!(!is_heading_fragment("Steps for Using the Microscope:"));
|
||||
assert!(!is_heading_fragment("Changing objectives:"));
|
||||
assert!(!is_heading_fragment("Sales by Region (2024)"));
|
||||
assert!(!is_heading_fragment("Results (preliminary)"));
|
||||
}
|
||||
}
|
||||
|
||||
+585
-94
@@ -1,5 +1,6 @@
|
||||
//! Core line-to-markdown conversion loop with table/image interleaving.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::structure_tree::StructRole;
|
||||
@@ -7,14 +8,155 @@ use crate::types::TextLine;
|
||||
|
||||
use super::analysis::{
|
||||
bold_heading_level, calculate_font_stats, compute_heading_tiers, compute_paragraph_threshold,
|
||||
detect_header_level, font_size_rarity, has_dot_leaders,
|
||||
detect_header_level, font_size_rarity, has_dot_leaders, is_heading_fragment, is_toc_entry_line,
|
||||
is_toc_marker_heading,
|
||||
};
|
||||
use super::classify::{
|
||||
format_list_item, is_caption_line, is_list_item, is_monospace_font, starts_with_bullet_marker,
|
||||
};
|
||||
use super::heading::classify_heading_sequences;
|
||||
use super::postprocess::clean_markdown;
|
||||
use super::preprocess::{merge_drop_caps, merge_heading_lines};
|
||||
use super::MarkdownOptions;
|
||||
use super::{item_is_in_chart_region, MarkdownOptions, CHART_SEPARATOR_PAD};
|
||||
|
||||
/// Logical stream geometry for a page where one full-width chart separates
|
||||
/// two prose columns. Positioned non-text blocks use this same ordering so a
|
||||
/// right-column table or image cannot jump ahead of left-column prose.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(super) struct ChartProseOrder {
|
||||
split_x: f32,
|
||||
chart_region: (f32, f32, f32, f32),
|
||||
}
|
||||
|
||||
impl ChartProseOrder {
|
||||
pub(super) fn new(split_x: f32, chart_region: (f32, f32, f32, f32)) -> Self {
|
||||
Self {
|
||||
split_x,
|
||||
chart_region,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Markdown block with its physical position and optional logical chart-page
|
||||
/// stream. Tables and images share this representation because both are
|
||||
/// removed before text-line grouping and reinserted during conversion.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct PositionedMarkdown {
|
||||
y: f32,
|
||||
x: f32,
|
||||
markdown: String,
|
||||
chart_order: Option<ChartProseOrder>,
|
||||
}
|
||||
|
||||
impl PositionedMarkdown {
|
||||
pub(super) fn new(
|
||||
y: f32,
|
||||
x: f32,
|
||||
markdown: String,
|
||||
chart_order: Option<ChartProseOrder>,
|
||||
) -> Self {
|
||||
Self {
|
||||
y,
|
||||
x,
|
||||
markdown,
|
||||
chart_order,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn chart_stream_position(
|
||||
y: f32,
|
||||
x: f32,
|
||||
claimed_by_chart: bool,
|
||||
order: ChartProseOrder,
|
||||
) -> (u8, u8) {
|
||||
let (_, y0, _, y1) = order.chart_region;
|
||||
let low = y0.min(y1) - CHART_SEPARATOR_PAD;
|
||||
let high = y0.max(y1) + CHART_SEPARATOR_PAD;
|
||||
let in_chart_zone = claimed_by_chart || (y >= low && y <= high);
|
||||
let zone = if in_chart_zone {
|
||||
1
|
||||
} else if y > high {
|
||||
0
|
||||
} else {
|
||||
2
|
||||
};
|
||||
let column = if in_chart_zone || x < order.split_x {
|
||||
0
|
||||
} else {
|
||||
1
|
||||
};
|
||||
(zone, column)
|
||||
}
|
||||
|
||||
fn positioned_block_precedes_line(block: &PositionedMarkdown, line: &TextLine) -> bool {
|
||||
let Some(order) = block.chart_order else {
|
||||
return block.y > line.y;
|
||||
};
|
||||
let line_x = line.items.first().map(|item| item.x).unwrap_or(0.0);
|
||||
let line_claimed_by_chart = line
|
||||
.items
|
||||
.iter()
|
||||
.any(|item| item_is_in_chart_region(item, &[order.chart_region]));
|
||||
let block_position = chart_stream_position(block.y, block.x, false, order);
|
||||
let line_position = chart_stream_position(line.y, line_x, line_claimed_by_chart, order);
|
||||
block_position < line_position || (block_position == line_position && block.y > line.y)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum PositionedBlockKind {
|
||||
Table,
|
||||
Image,
|
||||
}
|
||||
|
||||
type PositionedBlockRef<'a> = (PositionedBlockKind, usize, &'a PositionedMarkdown);
|
||||
|
||||
fn compare_positioned_blocks(
|
||||
(a_kind, a_idx, a): &PositionedBlockRef<'_>,
|
||||
(b_kind, b_idx, b): &PositionedBlockRef<'_>,
|
||||
) -> Ordering {
|
||||
if let (Some(a_order), Some(b_order)) = (a.chart_order, b.chart_order) {
|
||||
let a_position = chart_stream_position(a.y, a.x, false, a_order);
|
||||
let b_position = chart_stream_position(b.y, b.x, false, b_order);
|
||||
return a_position
|
||||
.cmp(&b_position)
|
||||
.then_with(|| b.y.total_cmp(&a.y))
|
||||
.then_with(|| a.x.total_cmp(&b.x))
|
||||
.then_with(|| a_kind.cmp(b_kind))
|
||||
.then_with(|| a_idx.cmp(b_idx));
|
||||
}
|
||||
|
||||
// Preserve the legacy ordering for ordinary pages: tables in detection
|
||||
// order, followed by images in input order. Chart pages give every block
|
||||
// a chart order and use the logical stream comparison above.
|
||||
a_kind.cmp(b_kind).then_with(|| a_idx.cmp(b_idx))
|
||||
}
|
||||
|
||||
fn positioned_blocks_for_page<'a>(
|
||||
page: u32,
|
||||
page_tables: &'a HashMap<u32, Vec<PositionedMarkdown>>,
|
||||
page_images: &'a HashMap<u32, Vec<PositionedMarkdown>>,
|
||||
) -> Vec<PositionedBlockRef<'a>> {
|
||||
let mut blocks = Vec::new();
|
||||
if let Some(tables) = page_tables.get(&page) {
|
||||
blocks.extend(
|
||||
tables
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, table)| (PositionedBlockKind::Table, idx, table)),
|
||||
);
|
||||
}
|
||||
if let Some(images) = page_images.get(&page) {
|
||||
blocks.extend(
|
||||
images
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(idx, image)| (PositionedBlockKind::Image, idx, image)),
|
||||
);
|
||||
}
|
||||
blocks.sort_by(compare_positioned_blocks);
|
||||
blocks
|
||||
}
|
||||
|
||||
/// Pre-scan struct heading tags to find levels that are overused — i.e., tagged on
|
||||
/// so many lines that they clearly represent body text, not real headings.
|
||||
@@ -140,8 +282,11 @@ fn find_isolated_lines(lines: &[TextLine], base_size: f32, para_threshold: f32)
|
||||
}
|
||||
}
|
||||
for (&page, &(total, isolated)) in &page_line_counts {
|
||||
if total > 0 && isolated as f32 / total as f32 > 0.25 {
|
||||
// Too many isolated lines on this page — remove them all
|
||||
// The ratio only means something on pages dense enough for a
|
||||
// multi-column misfire; on sparse pages (covers, ToC pages with a
|
||||
// lone title) one isolated line is 25%+ of the page and exactly the
|
||||
// line isolation exists to find.
|
||||
if total >= 10 && isolated as f32 / total as f32 > 0.25 {
|
||||
set.retain(|&i| lines[i].page != page);
|
||||
}
|
||||
}
|
||||
@@ -156,6 +301,112 @@ fn find_isolated_lines(lines: &[TextLine], base_size: f32, para_threshold: f32)
|
||||
/// wrapped visual line as "standalone" once the first line is misclassified,
|
||||
/// producing a stack of `##` headings. Multi-line body-size bold runs with a
|
||||
/// paragraph-sized word count should stay paragraph text.
|
||||
/// Merge 2-3 consecutive all-bold body-size lines into one line when the
|
||||
/// group is isolated (paragraph break before and after) and short enough to
|
||||
/// be a heading. Longer/wordier bold runs are wrapped bold paragraphs and
|
||||
/// are left for `find_wrapped_bold_paragraph_lines` to suppress.
|
||||
/// "9.5. ", "12.3.1. " — section-numbered heading prefix followed by a word.
|
||||
fn starts_with_section_number(t: &str) -> bool {
|
||||
let t = t.trim_start();
|
||||
let mut rest = t;
|
||||
let mut groups = 0;
|
||||
loop {
|
||||
let digits = rest.chars().take_while(|c| c.is_ascii_digit()).count();
|
||||
if digits == 0 || digits > 3 {
|
||||
break;
|
||||
}
|
||||
groups += 1;
|
||||
rest = &rest[digits..];
|
||||
if let Some(r) = rest.strip_prefix('.') {
|
||||
rest = r;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Two components minimum ("9.5. "): a single "1. " is an ordered list
|
||||
// item, and this prefix bypasses the isolation checks entirely.
|
||||
groups >= 2
|
||||
&& rest.starts_with(char::is_whitespace)
|
||||
&& rest.trim_start().starts_with(|c: char| c.is_alphabetic())
|
||||
}
|
||||
|
||||
fn merge_wrapped_bold_heading_groups(
|
||||
lines: Vec<TextLine>,
|
||||
base_size: f32,
|
||||
para_threshold: f32,
|
||||
) -> Vec<TextLine> {
|
||||
let mut out: Vec<TextLine> = Vec::with_capacity(lines.len());
|
||||
let mut i = 0usize;
|
||||
while i < lines.len() {
|
||||
if !is_body_size_all_bold_line(&lines[i], base_size) {
|
||||
out.push(lines[i].clone());
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let start = i;
|
||||
let mut end = i;
|
||||
let mut word_count = lines[i].text().split_whitespace().count();
|
||||
while end + 1 < lines.len()
|
||||
&& is_body_size_all_bold_line(&lines[end + 1], base_size)
|
||||
&& is_wrapped_same_style_line(&lines[end], &lines[end + 1], para_threshold)
|
||||
{
|
||||
end += 1;
|
||||
word_count += lines[end].text().split_whitespace().count();
|
||||
}
|
||||
let line_count = end - start + 1;
|
||||
// Column-local isolation: on interleaved multi-column pages the
|
||||
// vector neighbors may be the other column's lines, so judge the
|
||||
// break by x-overlapping lines only.
|
||||
let gx0 = lines[start..=end]
|
||||
.iter()
|
||||
.flat_map(|l| l.items.iter().map(|i| i.x))
|
||||
.fold(f32::INFINITY, f32::min);
|
||||
let gx1 = lines[start..=end]
|
||||
.iter()
|
||||
.flat_map(|l| l.items.iter().map(|i| i.x + i.width))
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
let overlaps_x = |l: &TextLine| {
|
||||
let lx0 = l.items.iter().map(|i| i.x).fold(f32::INFINITY, f32::min);
|
||||
let lx1 = l
|
||||
.items
|
||||
.iter()
|
||||
.map(|i| i.x + i.width)
|
||||
.fold(f32::NEG_INFINITY, f32::max);
|
||||
lx0 < gx1 && lx1 > gx0
|
||||
};
|
||||
let page = lines[start].page;
|
||||
let break_before = !lines.iter().any(|l| {
|
||||
l.page == page
|
||||
&& l.y > lines[start].y
|
||||
&& l.y - lines[start].y <= para_threshold
|
||||
&& overlaps_x(l)
|
||||
});
|
||||
let break_after = !lines.iter().any(|l| {
|
||||
l.page == page
|
||||
&& l.y < lines[end].y
|
||||
&& lines[end].y - l.y <= para_threshold
|
||||
&& overlaps_x(l)
|
||||
});
|
||||
let numbered = starts_with_section_number(&lines[start].text());
|
||||
if (2..=3).contains(&line_count)
|
||||
&& word_count <= 15
|
||||
&& ((break_before && break_after) || numbered)
|
||||
{
|
||||
let mut merged = lines[start].clone();
|
||||
for l in &lines[start + 1..=end] {
|
||||
merged.items.extend(l.items.iter().cloned());
|
||||
}
|
||||
out.push(merged);
|
||||
} else {
|
||||
for l in &lines[start..=end] {
|
||||
out.push(l.clone());
|
||||
}
|
||||
}
|
||||
i = end + 1;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn find_wrapped_bold_paragraph_lines(
|
||||
lines: &[TextLine],
|
||||
base_size: f32,
|
||||
@@ -273,7 +524,7 @@ fn struct_role_heading_level(role: &StructRole) -> Option<usize> {
|
||||
/// We strip their header+separator rows and append their data rows to the first page's
|
||||
/// table, then remove them from later pages.
|
||||
pub(super) fn merge_continuation_tables(
|
||||
page_tables: &mut std::collections::HashMap<u32, Vec<(f32, String)>>,
|
||||
page_tables: &mut std::collections::HashMap<u32, Vec<PositionedMarkdown>>,
|
||||
table_only_pages: &HashSet<u32>,
|
||||
) {
|
||||
let mut sorted_pages: Vec<u32> = page_tables.keys().copied().collect();
|
||||
@@ -301,7 +552,7 @@ pub(super) fn merge_continuation_tables(
|
||||
continue;
|
||||
}
|
||||
|
||||
let first_col_count = count_table_columns(&first_tables[0].1);
|
||||
let first_col_count = count_table_columns(&first_tables[0].markdown);
|
||||
if first_col_count == 0 {
|
||||
i += 1;
|
||||
continue;
|
||||
@@ -332,7 +583,7 @@ pub(super) fn merge_continuation_tables(
|
||||
_ => break,
|
||||
};
|
||||
|
||||
let next_col_count = count_table_columns(&next_tables[0].1);
|
||||
let next_col_count = count_table_columns(&next_tables[0].markdown);
|
||||
if next_col_count != first_col_count {
|
||||
break;
|
||||
}
|
||||
@@ -346,7 +597,7 @@ pub(super) fn merge_continuation_tables(
|
||||
let mut extra_rows = String::new();
|
||||
for &cont_page in &continuation_pages {
|
||||
if let Some(tables) = page_tables.get(&cont_page) {
|
||||
let table_md = &tables[0].1;
|
||||
let table_md = &tables[0].markdown;
|
||||
// Skip header row (line 1) and separator row (line 2), keep the rest
|
||||
for (line_idx, line) in table_md.lines().enumerate() {
|
||||
if line_idx >= 2 {
|
||||
@@ -359,7 +610,7 @@ pub(super) fn merge_continuation_tables(
|
||||
|
||||
// Append continuation rows to the first page's table
|
||||
if let Some(tables) = page_tables.get_mut(&first_page) {
|
||||
tables[0].1.push_str(&extra_rows);
|
||||
tables[0].markdown.push_str(&extra_rows);
|
||||
}
|
||||
|
||||
// Remove continuation pages from the map
|
||||
@@ -391,37 +642,35 @@ fn count_table_columns(table_md: &str) -> usize {
|
||||
/// Flush any remaining tables and images for a given page
|
||||
fn flush_page_tables_and_images(
|
||||
page: u32,
|
||||
page_tables: &std::collections::HashMap<u32, Vec<(f32, String)>>,
|
||||
page_images: &std::collections::HashMap<u32, Vec<(f32, String)>>,
|
||||
page_blocks: &HashMap<u32, Vec<PositionedBlockRef<'_>>>,
|
||||
inserted_tables: &mut HashSet<(u32, usize)>,
|
||||
inserted_images: &mut HashSet<(u32, usize)>,
|
||||
output: &mut String,
|
||||
in_paragraph: &mut bool,
|
||||
) {
|
||||
if let Some(tables) = page_tables.get(&page) {
|
||||
for (idx, (_, table_md)) in tables.iter().enumerate() {
|
||||
if !inserted_tables.contains(&(page, idx)) {
|
||||
if *in_paragraph {
|
||||
output.push_str("\n\n");
|
||||
*in_paragraph = false;
|
||||
}
|
||||
output.push('\n');
|
||||
output.push_str(table_md);
|
||||
output.push('\n');
|
||||
let Some(blocks) = page_blocks.get(&page) else {
|
||||
return;
|
||||
};
|
||||
for &(kind, idx, block) in blocks {
|
||||
let already_inserted = match kind {
|
||||
PositionedBlockKind::Table => inserted_tables.contains(&(page, idx)),
|
||||
PositionedBlockKind::Image => inserted_images.contains(&(page, idx)),
|
||||
};
|
||||
if already_inserted {
|
||||
continue;
|
||||
}
|
||||
if *in_paragraph {
|
||||
output.push_str("\n\n");
|
||||
*in_paragraph = false;
|
||||
}
|
||||
output.push('\n');
|
||||
output.push_str(&block.markdown);
|
||||
output.push('\n');
|
||||
match kind {
|
||||
PositionedBlockKind::Table => {
|
||||
inserted_tables.insert((page, idx));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(images) = page_images.get(&page) {
|
||||
for (idx, (_, image_md)) in images.iter().enumerate() {
|
||||
if !inserted_images.contains(&(page, idx)) {
|
||||
if *in_paragraph {
|
||||
output.push_str("\n\n");
|
||||
*in_paragraph = false;
|
||||
}
|
||||
output.push('\n');
|
||||
output.push_str(image_md);
|
||||
output.push('\n');
|
||||
PositionedBlockKind::Image => {
|
||||
inserted_images.insert((page, idx));
|
||||
}
|
||||
}
|
||||
@@ -432,8 +681,9 @@ fn flush_page_tables_and_images(
|
||||
pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
lines: Vec<TextLine>,
|
||||
options: MarkdownOptions,
|
||||
page_tables: std::collections::HashMap<u32, Vec<(f32, String)>>,
|
||||
page_images: std::collections::HashMap<u32, Vec<(f32, String)>>,
|
||||
page_tables: std::collections::HashMap<u32, Vec<PositionedMarkdown>>,
|
||||
page_images: std::collections::HashMap<u32, Vec<PositionedMarkdown>>,
|
||||
page_chart_regions: &std::collections::HashMap<u32, Vec<(f32, f32, f32, f32)>>,
|
||||
band_split_pages: &HashSet<u32>,
|
||||
struct_roles: Option<
|
||||
&std::collections::HashMap<u32, std::collections::HashMap<i64, StructRole>>,
|
||||
@@ -464,6 +714,17 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
// threshold and cause every line to be treated as a paragraph break.
|
||||
let para_threshold = compute_paragraph_threshold(&lines, base_size);
|
||||
|
||||
// Merge wrapped bold headings: a 2-3 line group of consecutive all-bold
|
||||
// body-size lines that is isolated as a group (paragraph break before
|
||||
// and after) is one heading that wrapped. Left split, the internal line
|
||||
// gap breaks each line's isolation and neither classifies as a heading —
|
||||
// the whole group then merges into the following body paragraph.
|
||||
let lines = if std::env::var("PI_NO_MERGE").is_ok() {
|
||||
lines
|
||||
} else {
|
||||
merge_wrapped_bold_heading_groups(lines, base_size, para_threshold)
|
||||
};
|
||||
|
||||
// Pre-scan: identify isolated lines (paragraph break before AND after).
|
||||
// These are heading candidates even without bold/large font — common in
|
||||
// academic papers where section titles like "Acknowledgements" sit alone
|
||||
@@ -473,6 +734,33 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
let wrapped_bold_paragraph_lines =
|
||||
find_wrapped_bold_paragraph_lines(&lines, base_size, para_threshold);
|
||||
|
||||
let mut sequence_excluded_lines = wrapped_bold_paragraph_lines.clone();
|
||||
for (line_idx, line) in lines.iter().enumerate() {
|
||||
if page_chart_regions.get(&line.page).is_some_and(|regions| {
|
||||
line.items
|
||||
.iter()
|
||||
.any(|item| item_is_in_chart_region(item, regions))
|
||||
}) {
|
||||
sequence_excluded_lines.insert(line_idx);
|
||||
}
|
||||
}
|
||||
if let Some(roles) = struct_roles {
|
||||
for (line_idx, line) in lines.iter().enumerate() {
|
||||
if resolve_line_struct_role(line, roles)
|
||||
.is_some_and(|role| role.is_non_heading_content())
|
||||
{
|
||||
sequence_excluded_lines.insert(line_idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
let sequence_heading_levels = classify_heading_sequences(
|
||||
&lines,
|
||||
base_size,
|
||||
&heading_tiers,
|
||||
&isolated_lines,
|
||||
&sequence_excluded_lines,
|
||||
);
|
||||
|
||||
// Detect struct heading levels that are overused (body text mistagged as headings)
|
||||
let overused_heading_levels = detect_overused_struct_heading_levels(&lines, struct_roles);
|
||||
|
||||
@@ -486,6 +774,7 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
let mut in_code_block = false;
|
||||
let mut prev_had_dot_leaders = false;
|
||||
let mut paragraph_in_wrapped_bold_run = false;
|
||||
let mut toc_suppress_page: Option<u32> = None;
|
||||
let mut inserted_tables: HashSet<(u32, usize)> = HashSet::new();
|
||||
let mut inserted_images: HashSet<(u32, usize)> = HashSet::new();
|
||||
|
||||
@@ -497,6 +786,18 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
.collect();
|
||||
all_content_pages.sort();
|
||||
all_content_pages.dedup();
|
||||
// Build the unified table/image order once per page. This is only a
|
||||
// meaningful sort on chart/prose pages; ordinary pages retain their
|
||||
// legacy table-then-image order without repeating work for every line.
|
||||
let page_blocks: HashMap<u32, Vec<PositionedBlockRef<'_>>> = all_content_pages
|
||||
.iter()
|
||||
.map(|&page| {
|
||||
(
|
||||
page,
|
||||
positioned_blocks_for_page(page, &page_tables, &page_images),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
for (line_idx, line) in lines.iter().enumerate() {
|
||||
// Page break
|
||||
@@ -509,8 +810,7 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
}
|
||||
flush_page_tables_and_images(
|
||||
current_page,
|
||||
&page_tables,
|
||||
&page_images,
|
||||
&page_blocks,
|
||||
&mut inserted_tables,
|
||||
&mut inserted_images,
|
||||
&mut output,
|
||||
@@ -534,8 +834,7 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
}
|
||||
flush_page_tables_and_images(
|
||||
p,
|
||||
&page_tables,
|
||||
&page_images,
|
||||
&page_blocks,
|
||||
&mut inserted_tables,
|
||||
&mut inserted_images,
|
||||
&mut output,
|
||||
@@ -558,38 +857,32 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should insert a table before this line
|
||||
if let Some(tables) = page_tables.get(¤t_page) {
|
||||
for (idx, (table_y, table_md)) in tables.iter().enumerate() {
|
||||
// Insert table when we pass its Y position
|
||||
if *table_y > line.y && !inserted_tables.contains(&(current_page, idx)) {
|
||||
// Insert tables and images through one ordered stream. Chart/prose
|
||||
// pages sort by zone, column, and physical Y; ordinary pages retain
|
||||
// the legacy table-then-image input order.
|
||||
if let Some(blocks) = page_blocks.get(¤t_page) {
|
||||
for &(kind, idx, block) in blocks {
|
||||
let already_inserted = match kind {
|
||||
PositionedBlockKind::Table => inserted_tables.contains(&(current_page, idx)),
|
||||
PositionedBlockKind::Image => inserted_images.contains(&(current_page, idx)),
|
||||
};
|
||||
if positioned_block_precedes_line(block, line) && !already_inserted {
|
||||
if in_paragraph {
|
||||
output.push_str("\n\n");
|
||||
in_paragraph = false;
|
||||
paragraph_in_wrapped_bold_run = false;
|
||||
}
|
||||
output.push('\n');
|
||||
output.push_str(table_md);
|
||||
output.push_str(&block.markdown);
|
||||
output.push('\n');
|
||||
inserted_tables.insert((current_page, idx));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we should insert an image before this line
|
||||
if let Some(images) = page_images.get(¤t_page) {
|
||||
for (idx, (image_y, image_md)) in images.iter().enumerate() {
|
||||
// Insert image when we pass its Y position
|
||||
if *image_y > line.y && !inserted_images.contains(&(current_page, idx)) {
|
||||
if in_paragraph {
|
||||
output.push_str("\n\n");
|
||||
in_paragraph = false;
|
||||
paragraph_in_wrapped_bold_run = false;
|
||||
match kind {
|
||||
PositionedBlockKind::Table => {
|
||||
inserted_tables.insert((current_page, idx));
|
||||
}
|
||||
PositionedBlockKind::Image => {
|
||||
inserted_images.insert((current_page, idx));
|
||||
}
|
||||
}
|
||||
output.push('\n');
|
||||
output.push_str(image_md);
|
||||
output.push('\n');
|
||||
inserted_images.insert((current_page, idx));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -698,14 +991,31 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
_ => false,
|
||||
};
|
||||
|
||||
// Lines explicitly tagged with a non-heading content role must never
|
||||
// be promoted by the visual heuristic — a tagged list item, quote, or
|
||||
// code line can look exactly like a heading (short, isolated).
|
||||
let non_heading_role = struct_role
|
||||
.as_ref()
|
||||
.is_some_and(StructRole::is_non_heading_content);
|
||||
let heuristic_heading = if options.detect_headers
|
||||
&& !non_heading_role
|
||||
&& !is_code_line
|
||||
&& !looks_like_list_continuation
|
||||
&& plain_trimmed.len() > 3
|
||||
&& plain_trimmed.split_whitespace().count() <= 15
|
||||
&& !starts_with_bullet_marker(plain_trimmed)
|
||||
&& !is_toc_entry_line(plain_trimmed)
|
||||
&& !is_heading_fragment(plain_trimmed)
|
||||
&& toc_suppress_page != Some(line.page)
|
||||
{
|
||||
let line_font_size = line.items.first().map(|i| i.font_size).unwrap_or(base_size);
|
||||
detect_header_level(line_font_size, base_size, &heading_tiers).or_else(|| {
|
||||
detect_header_level(
|
||||
line_font_size,
|
||||
base_size,
|
||||
&heading_tiers,
|
||||
crate::markdown::analysis::line_is_mostly_bold(line),
|
||||
)
|
||||
.or_else(|| {
|
||||
// Rarity-based heading detection (inspired by opendataloader).
|
||||
// Heading probability scoring with lookahead context.
|
||||
// Score = rarity * 0.5 + bold * 0.3 + standalone * 0.2
|
||||
@@ -738,12 +1048,23 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
// paragraph continuity and minor font-size variation
|
||||
// inflates rarity scores.
|
||||
let has_strong_signal = all_bold || isolated || (rarity >= 0.97 && word_count <= 8);
|
||||
if score >= 0.5 && standalone && word_count >= 2 && has_strong_signal {
|
||||
// Single-word headings ("IMPLEMENTATION", "CONTENTS",
|
||||
// "Replace") are common. All-bold single words qualify when
|
||||
// standalone (paragraph break before / page top) — headings
|
||||
// hug their section's first paragraph, so requiring a break
|
||||
// after as well missed most of them. Mixed bold lead-ins
|
||||
// ("Note: ...") are excluded by all_bold.
|
||||
let enough_words = word_count >= 2 || (all_bold && plain_trimmed.len() >= 4);
|
||||
let numbered_bold = all_bold && starts_with_section_number(plain_trimmed);
|
||||
if numbered_bold
|
||||
|| (score >= 0.5 && standalone && enough_words && has_strong_signal)
|
||||
{
|
||||
Some(bold_heading_level(&heading_tiers))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.or_else(|| sequence_heading_levels.get(&line_idx).copied())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -763,6 +1084,9 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
plain_text.clone()
|
||||
};
|
||||
output.push_str(&format!("{} {}\n\n", prefix, heading_text.trim()));
|
||||
if is_toc_marker_heading(plain_trimmed) {
|
||||
toc_suppress_page = Some(line.page);
|
||||
}
|
||||
in_list = false;
|
||||
continue;
|
||||
}
|
||||
@@ -892,8 +1216,7 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
// (handles table-only pages after the last text line, and trailing image-only pages)
|
||||
flush_page_tables_and_images(
|
||||
current_page,
|
||||
&page_tables,
|
||||
&page_images,
|
||||
&page_blocks,
|
||||
&mut inserted_tables,
|
||||
&mut inserted_images,
|
||||
&mut output,
|
||||
@@ -905,8 +1228,7 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
|
||||
}
|
||||
flush_page_tables_and_images(
|
||||
p,
|
||||
&page_tables,
|
||||
&page_images,
|
||||
&page_blocks,
|
||||
&mut inserted_tables,
|
||||
&mut inserted_images,
|
||||
&mut output,
|
||||
@@ -950,6 +1272,13 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
|
||||
let isolated_lines = find_isolated_lines(&lines, base_size, para_threshold);
|
||||
let wrapped_bold_paragraph_lines =
|
||||
find_wrapped_bold_paragraph_lines(&lines, base_size, para_threshold);
|
||||
let sequence_heading_levels = classify_heading_sequences(
|
||||
&lines,
|
||||
base_size,
|
||||
&heading_tiers,
|
||||
&isolated_lines,
|
||||
&wrapped_bold_paragraph_lines,
|
||||
);
|
||||
|
||||
let mut output = String::new();
|
||||
let mut current_page = 0u32;
|
||||
@@ -959,6 +1288,7 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
|
||||
let mut last_list_x: Option<f32> = None;
|
||||
let mut prev_had_dot_leaders = false;
|
||||
let mut paragraph_in_wrapped_bold_run = false;
|
||||
let mut toc_suppress_page: Option<u32> = None;
|
||||
|
||||
for (line_idx, line) in lines.iter().enumerate() {
|
||||
// Page break
|
||||
@@ -1037,33 +1367,45 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
|
||||
if options.detect_headers
|
||||
&& plain_trimmed.len() > 3
|
||||
&& plain_trimmed.split_whitespace().count() <= 15
|
||||
&& !is_toc_entry_line(plain_trimmed)
|
||||
&& !is_heading_fragment(plain_trimmed)
|
||||
&& toc_suppress_page != Some(line.page)
|
||||
&& !(options.detect_code && line.items.iter().any(|i| is_monospace_font(&i.font)))
|
||||
{
|
||||
let line_font_size = line.items.first().map(|i| i.font_size).unwrap_or(base_size);
|
||||
if let Some(header_level) =
|
||||
detect_header_level(line_font_size, base_size, &heading_tiers).or_else(|| {
|
||||
if line_font_size < base_size * 0.95 {
|
||||
return None;
|
||||
}
|
||||
let word_count = plain_trimmed.split_whitespace().count();
|
||||
if !(1..=15).contains(&word_count) {
|
||||
return None;
|
||||
}
|
||||
if wrapped_bold_paragraph_lines.contains(&line_idx) {
|
||||
return None;
|
||||
}
|
||||
let rarity = font_size_rarity(line_font_size, &font_stats);
|
||||
let all_bold = !line.items.is_empty() && line.items.iter().all(|i| i.is_bold);
|
||||
let standalone = !in_paragraph;
|
||||
let isolated = isolated_lines.contains(&line_idx);
|
||||
let score = rarity * 0.5
|
||||
+ if all_bold { 0.3 } else { 0.0 }
|
||||
+ if standalone { 0.2 } else { 0.0 }
|
||||
+ if isolated { 0.3 } else { 0.0 };
|
||||
if score >= 0.5 && standalone && word_count >= 2 {
|
||||
return Some(bold_heading_level(&heading_tiers));
|
||||
}
|
||||
None
|
||||
})
|
||||
if let Some(header_level) = detect_header_level(
|
||||
line_font_size,
|
||||
base_size,
|
||||
&heading_tiers,
|
||||
crate::markdown::analysis::line_is_mostly_bold(line),
|
||||
)
|
||||
.or_else(|| {
|
||||
if line_font_size < base_size * 0.95 {
|
||||
return None;
|
||||
}
|
||||
let word_count = plain_trimmed.split_whitespace().count();
|
||||
if !(1..=15).contains(&word_count) {
|
||||
return None;
|
||||
}
|
||||
if wrapped_bold_paragraph_lines.contains(&line_idx) {
|
||||
return None;
|
||||
}
|
||||
let rarity = font_size_rarity(line_font_size, &font_stats);
|
||||
let all_bold = !line.items.is_empty() && line.items.iter().all(|i| i.is_bold);
|
||||
let standalone = !in_paragraph;
|
||||
let isolated = isolated_lines.contains(&line_idx);
|
||||
let score = rarity * 0.5
|
||||
+ if all_bold { 0.3 } else { 0.0 }
|
||||
+ if standalone { 0.2 } else { 0.0 }
|
||||
+ if isolated { 0.3 } else { 0.0 };
|
||||
let enough_words =
|
||||
word_count >= 2 || (all_bold && isolated && plain_trimmed.len() >= 4);
|
||||
if score >= 0.5 && standalone && enough_words {
|
||||
return Some(bold_heading_level(&heading_tiers));
|
||||
}
|
||||
None
|
||||
})
|
||||
.or_else(|| sequence_heading_levels.get(&line_idx).copied())
|
||||
{
|
||||
if in_paragraph {
|
||||
output.push_str("\n\n");
|
||||
@@ -1078,6 +1420,9 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
|
||||
plain_text.clone()
|
||||
};
|
||||
output.push_str(&format!("{} {}\n\n", prefix, heading_text.trim()));
|
||||
if is_toc_marker_heading(plain_trimmed) {
|
||||
toc_suppress_page = Some(line.page);
|
||||
}
|
||||
in_list = false;
|
||||
continue;
|
||||
}
|
||||
@@ -1171,6 +1516,20 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
#[test]
|
||||
fn section_number_prefix_detection() {
|
||||
assert!(starts_with_section_number(
|
||||
"9.5. Adapting to the New Normal"
|
||||
));
|
||||
assert!(starts_with_section_number("12.3.1. Deep subsection"));
|
||||
assert!(starts_with_section_number("2.1 Systems thinking"));
|
||||
assert!(!starts_with_section_number("1. First item in a list"));
|
||||
assert!(!starts_with_section_number("24% in October 2020."));
|
||||
assert!(!starts_with_section_number("2020 was a hard year"));
|
||||
assert!(!starts_with_section_number("Introduction"));
|
||||
}
|
||||
|
||||
use super::*;
|
||||
use crate::structure_tree::StructRole;
|
||||
use crate::types::TextItem;
|
||||
@@ -1206,6 +1565,127 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn line_at(text: &str, page: u32, y: f32) -> TextLine {
|
||||
let mut item = make_item(text, page, None);
|
||||
item.y = y;
|
||||
make_line(vec![item])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chart_page_blocks_follow_zone_and_column_stream() {
|
||||
let line = |text: &str, x: f32, y: f32| {
|
||||
let mut item = make_item(text, 1, None);
|
||||
item.x = x;
|
||||
item.y = y;
|
||||
make_line(vec![item])
|
||||
};
|
||||
// Logical newspaper order for the prose zone: all left-column lines,
|
||||
// then all right-column lines, even though their physical Y values
|
||||
// jump back upward at the column switch.
|
||||
let lines = vec![
|
||||
line("Left column upper prose.", 90.0, 700.0),
|
||||
line("Left column lower prose.", 90.0, 500.0),
|
||||
line("Right column upper prose.", 340.0, 700.0),
|
||||
line("Right column lower prose.", 340.0, 500.0),
|
||||
];
|
||||
let order = ChartProseOrder::new(280.0, (100.0, 300.0, 500.0, 400.0));
|
||||
let mut tables = HashMap::new();
|
||||
tables.insert(
|
||||
1,
|
||||
vec![
|
||||
// Detection order is deliberately right before left.
|
||||
PositionedMarkdown::new(
|
||||
600.0,
|
||||
340.0,
|
||||
"| Right metric | Value |\n|---|---|\n| A | 1 |\n".into(),
|
||||
Some(order),
|
||||
),
|
||||
PositionedMarkdown::new(
|
||||
550.0,
|
||||
90.0,
|
||||
"| Left metric | Value |\n|---|---|\n| B | 2 |\n".into(),
|
||||
Some(order),
|
||||
),
|
||||
],
|
||||
);
|
||||
let mut images = HashMap::new();
|
||||
images.insert(
|
||||
1,
|
||||
vec\n".into(),
|
||||
Some(order),
|
||||
),
|
||||
PositionedMarkdown::new(
|
||||
575.0,
|
||||
340.0,
|
||||
"\n".into(),
|
||||
Some(order),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
let md = to_markdown_from_lines_with_tables_and_images(
|
||||
lines,
|
||||
MarkdownOptions::default(),
|
||||
tables,
|
||||
images,
|
||||
&HashMap::new(),
|
||||
&HashSet::from([1]),
|
||||
None,
|
||||
);
|
||||
let positions = [
|
||||
"Left column upper prose.",
|
||||
"",
|
||||
"| Left metric | Value |",
|
||||
"Left column lower prose.",
|
||||
"Right column upper prose.",
|
||||
"| Right metric | Value |",
|
||||
"",
|
||||
"Right column lower prose.",
|
||||
]
|
||||
.map(|needle| {
|
||||
md.find(needle)
|
||||
.unwrap_or_else(|| panic!("missing {needle:?} in {md}"))
|
||||
});
|
||||
assert!(
|
||||
positions.windows(2).all(|pair| pair[0] < pair[1]),
|
||||
"blocks must follow the logical chart-page stream: {md}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolated_lines_kept_on_sparse_pages() {
|
||||
// A ToC page with a lone title and one entry far below: the density
|
||||
// ratio is 50% but the page is too sparse for the multi-column
|
||||
// misfire the guard targets — the title must stay isolated.
|
||||
let lines = vec![
|
||||
line_at("CONTENTS", 1, 700.0),
|
||||
line_at("Chapter One 5", 1, 500.0),
|
||||
];
|
||||
let isolated = find_isolated_lines(&lines, 12.0, 20.0);
|
||||
assert!(
|
||||
isolated.contains(&0),
|
||||
"sparse-page title must stay isolated"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isolated_lines_wiped_on_dense_pages() {
|
||||
// 12 short lines all with paragraph gaps — the multi-column misfire
|
||||
// shape. The guard must clear them all.
|
||||
let lines: Vec<TextLine> = (0..12)
|
||||
.map(|i| line_at("Short column line", 1, 700.0 - i as f32 * 50.0))
|
||||
.collect();
|
||||
let isolated = find_isolated_lines(&lines, 12.0, 20.0);
|
||||
assert!(
|
||||
isolated.is_empty(),
|
||||
"dense page of isolated lines must be wiped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_struct_role_heading() {
|
||||
let lines = vec![
|
||||
@@ -1228,6 +1708,7 @@ mod tests {
|
||||
MarkdownOptions::default(),
|
||||
HashMap::new(),
|
||||
HashMap::new(),
|
||||
&HashMap::new(),
|
||||
&std::collections::HashSet::new(),
|
||||
Some(&roles),
|
||||
);
|
||||
@@ -1256,6 +1737,7 @@ mod tests {
|
||||
MarkdownOptions::default(),
|
||||
HashMap::new(),
|
||||
HashMap::new(),
|
||||
&HashMap::new(),
|
||||
&std::collections::HashSet::new(),
|
||||
Some(&roles),
|
||||
);
|
||||
@@ -1297,6 +1779,7 @@ mod tests {
|
||||
MarkdownOptions::default(),
|
||||
HashMap::new(),
|
||||
HashMap::new(),
|
||||
&HashMap::new(),
|
||||
&std::collections::HashSet::new(),
|
||||
Some(&roles),
|
||||
);
|
||||
@@ -1333,6 +1816,7 @@ mod tests {
|
||||
MarkdownOptions::default(),
|
||||
HashMap::new(),
|
||||
HashMap::new(),
|
||||
&HashMap::new(),
|
||||
&std::collections::HashSet::new(),
|
||||
Some(&roles),
|
||||
);
|
||||
@@ -1368,6 +1852,7 @@ mod tests {
|
||||
MarkdownOptions::default(),
|
||||
HashMap::new(),
|
||||
HashMap::new(),
|
||||
&HashMap::new(),
|
||||
&std::collections::HashSet::new(),
|
||||
Some(&roles),
|
||||
);
|
||||
@@ -1397,6 +1882,7 @@ mod tests {
|
||||
MarkdownOptions::default(),
|
||||
HashMap::new(),
|
||||
HashMap::new(),
|
||||
&HashMap::new(),
|
||||
&std::collections::HashSet::new(),
|
||||
None,
|
||||
);
|
||||
@@ -1438,6 +1924,7 @@ mod tests {
|
||||
MarkdownOptions::default(),
|
||||
HashMap::new(),
|
||||
HashMap::new(),
|
||||
&HashMap::new(),
|
||||
&std::collections::HashSet::new(),
|
||||
Some(&roles),
|
||||
);
|
||||
@@ -1486,6 +1973,7 @@ mod tests {
|
||||
MarkdownOptions::default(),
|
||||
HashMap::new(),
|
||||
HashMap::new(),
|
||||
&HashMap::new(),
|
||||
&std::collections::HashSet::new(),
|
||||
None,
|
||||
);
|
||||
@@ -1587,6 +2075,7 @@ mod tests {
|
||||
MarkdownOptions::default(),
|
||||
HashMap::new(),
|
||||
HashMap::new(),
|
||||
&HashMap::new(),
|
||||
&std::collections::HashSet::new(),
|
||||
None,
|
||||
);
|
||||
@@ -1634,6 +2123,7 @@ mod tests {
|
||||
MarkdownOptions::default(),
|
||||
HashMap::new(),
|
||||
HashMap::new(),
|
||||
&HashMap::new(),
|
||||
&std::collections::HashSet::new(),
|
||||
Some(&roles),
|
||||
);
|
||||
@@ -1773,6 +2263,7 @@ mod tests {
|
||||
MarkdownOptions::default(),
|
||||
HashMap::new(),
|
||||
HashMap::new(),
|
||||
&HashMap::new(),
|
||||
&std::collections::HashSet::new(),
|
||||
None,
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+1223
-85
File diff suppressed because it is too large
Load Diff
@@ -2,12 +2,15 @@
|
||||
|
||||
use regex::Regex;
|
||||
|
||||
use super::MarkdownOptions;
|
||||
use super::{MarkdownOptions, MarkdownProfile};
|
||||
|
||||
/// Clean up markdown output with post-processing
|
||||
pub(crate) fn clean_markdown(mut text: String, options: &MarkdownOptions) -> String {
|
||||
// Collapse dot leaders (e.g. TOC entries: "Introduction...............................1")
|
||||
text = collapse_dot_leaders(&text);
|
||||
if options.profile == MarkdownProfile::Compact {
|
||||
// Dot-leader collapse saves tokens but changes source text, so it is
|
||||
// reserved for the explicit compact profile.
|
||||
text = collapse_dot_leaders(&text);
|
||||
}
|
||||
|
||||
// Fix hyphenation first (before other processing)
|
||||
if options.fix_hyphenation {
|
||||
@@ -355,6 +358,23 @@ fn format_urls(text: &str) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn fidelity_profile_preserves_dot_leaders() {
|
||||
let input = "Introduction............................1".to_string();
|
||||
let result = clean_markdown(input.clone(), &MarkdownOptions::default());
|
||||
assert_eq!(result, format!("{input}\n"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_profile_collapses_dot_leaders() {
|
||||
let input = "Introduction............................1".to_string();
|
||||
let options = MarkdownOptions {
|
||||
profile: MarkdownProfile::Compact,
|
||||
..MarkdownOptions::default()
|
||||
};
|
||||
assert_eq!(clean_markdown(input, &options), "Introduction ... 1\n");
|
||||
}
|
||||
|
||||
// --- collapse_dot_leaders ---
|
||||
|
||||
#[test]
|
||||
|
||||
+106
-2
@@ -3,7 +3,7 @@
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::structure_tree::StructRole;
|
||||
use crate::types::TextLine;
|
||||
use crate::types::{TextItem, TextLine};
|
||||
|
||||
use super::analysis::detect_header_level;
|
||||
|
||||
@@ -42,7 +42,12 @@ fn effective_heading_level(
|
||||
|
||||
// Fall back to font-size heuristic
|
||||
let font = line.items.first().map(|i| i.font_size).unwrap_or(base_size);
|
||||
detect_header_level(font, base_size, heading_tiers)
|
||||
detect_header_level(
|
||||
font,
|
||||
base_size,
|
||||
heading_tiers,
|
||||
crate::markdown::analysis::line_is_mostly_bold(line),
|
||||
)
|
||||
}
|
||||
|
||||
/// Merge consecutive heading lines at the same level into a single line.
|
||||
@@ -87,6 +92,41 @@ pub(crate) fn merge_heading_lines(
|
||||
false
|
||||
};
|
||||
|
||||
// Bold headings at body font size never reach a tier, so wrapped ones
|
||||
// split into two output headings ("…of wood pellets and cost" /
|
||||
// "structure in Japan"). Merge a fully-bold line into the previous
|
||||
// fully-bold line when it reads as a wrap continuation: starts
|
||||
// lowercase, tiny Y gap, and the previous line has no terminal
|
||||
// punctuation. Kept deliberately narrow — bold list labels and bold
|
||||
// sentences start with markers or capitals and are unaffected.
|
||||
let should_merge = should_merge
|
||||
|| if let Some(prev) = result.last() {
|
||||
let all_bold = |l: &TextLine| {
|
||||
!l.items.is_empty() && l.items.iter().all(|i: &TextItem| i.is_bold)
|
||||
};
|
||||
let prev_text = prev.text();
|
||||
let prev_trim = prev_text.trim_end();
|
||||
let curr_text = line.text();
|
||||
let curr_trim = curr_text.trim();
|
||||
let y_gap = prev.y - line.y;
|
||||
// Both lines must be tier-less: a tiered/tagged bold heading
|
||||
// followed by bold body text must not absorb it.
|
||||
line_level.is_none()
|
||||
&& effective_heading_level(prev, base_size, heading_tiers, struct_roles)
|
||||
.is_none()
|
||||
&& prev.page == line.page
|
||||
&& all_bold(prev)
|
||||
&& all_bold(&line)
|
||||
&& y_gap > 0.0
|
||||
&& y_gap < line_font * 1.6
|
||||
&& curr_trim.chars().next().is_some_and(|c| c.is_lowercase())
|
||||
&& !prev_trim.ends_with(['.', ':', ';', '!', '?'])
|
||||
&& prev_trim.split_whitespace().count() + curr_trim.split_whitespace().count()
|
||||
<= 20
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if should_merge {
|
||||
// Append this line's items to the previous line
|
||||
let prev = result.last_mut().unwrap();
|
||||
@@ -685,4 +725,68 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(first_header.page, 1, "first occurrence should be on page 1");
|
||||
}
|
||||
|
||||
fn make_bold_line(text: &str, page: u32, y: f32) -> TextLine {
|
||||
let mut item = make_item(text, 12.0, None);
|
||||
item.is_bold = true;
|
||||
TextLine {
|
||||
items: vec![item],
|
||||
y,
|
||||
page,
|
||||
adaptive_threshold: 0.10,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_wrapped_bold_heading_lowercase_continuation() {
|
||||
// Bold-at-body-size heading wrapped across two lines: the second line
|
||||
// starts lowercase and must merge into the first.
|
||||
let lines = vec![
|
||||
make_bold_line(
|
||||
"3. Perspective of supply and demand balance and cost",
|
||||
1,
|
||||
700.0,
|
||||
),
|
||||
make_bold_line("structure in Japan", 1, 686.0),
|
||||
make_line("Body text paragraph follows here.", 12.0, 1, 660.0, None),
|
||||
];
|
||||
let result = merge_heading_lines(lines, 12.0, &[], None);
|
||||
assert_eq!(result.len(), 2, "wrapped bold heading should merge");
|
||||
assert!(result[0].text().contains("cost structure in Japan"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_merge_for_bold_sentences_or_new_headings() {
|
||||
// Second bold line starts with a capital — a new heading or label,
|
||||
// not a wrap continuation.
|
||||
let lines = vec![
|
||||
make_bold_line("Replace", 1, 700.0),
|
||||
make_bold_line("Trash", 1, 686.0),
|
||||
];
|
||||
let result = merge_heading_lines(lines, 12.0, &[], None);
|
||||
assert_eq!(result.len(), 2, "distinct bold lines must not merge");
|
||||
|
||||
// Previous line ends a sentence — continuation must not merge.
|
||||
let lines = vec![
|
||||
make_bold_line("This is a bold sentence.", 1, 700.0),
|
||||
make_bold_line("another bold line", 1, 686.0),
|
||||
];
|
||||
let result = merge_heading_lines(lines, 12.0, &[], None);
|
||||
assert_eq!(result.len(), 2, "sentence-final bold line must not merge");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tiered_bold_heading_does_not_absorb_bold_body() {
|
||||
// Previous line is a tier-level bold heading (16pt vs 12pt body);
|
||||
// a following lowercase bold body line must NOT merge into it.
|
||||
let mut heading = make_bold_line("Section Title", 1, 700.0);
|
||||
heading.items[0].font_size = 16.0;
|
||||
heading.items[0].height = 16.0;
|
||||
let lines = vec![
|
||||
heading,
|
||||
make_bold_line("emphasized body text continues here", 1, 686.0),
|
||||
];
|
||||
let result = merge_heading_lines(lines, 12.0, &[16.0], None);
|
||||
assert_eq!(result.len(), 2, "tiered heading must not absorb bold body");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,52 @@ pub enum StructRole {
|
||||
}
|
||||
|
||||
impl StructRole {
|
||||
/// Content roles whose text must never be promoted to a heading by the
|
||||
/// visual heuristic. These carry an explicit non-heading meaning in the
|
||||
/// struct tree (lists, quotes, notes, references, captions, formulas,
|
||||
/// forms, ToC entries), yet their text is often short and visually
|
||||
/// isolated — exactly what the heuristic keys on. Heading roles (H, H1–H6)
|
||||
/// and generic container/flow roles (P, Div, Sect, Span, …) are excluded
|
||||
/// so the heuristic can still fire there.
|
||||
///
|
||||
/// `Figure` is deliberately NOT in this set: cover/banner pages routinely
|
||||
/// tag the document title inside a Figure (alongside a seal or logo), and
|
||||
/// that title is a real heading. `Formula` and `Form` stay — a line
|
||||
/// explicitly tagged as an equation or form field is never a heading.
|
||||
///
|
||||
/// Table roles (Table/TR/TH/TD/THead/TBody/TFoot) are included so that
|
||||
/// when table reconstruction falls back and cells reach the line loop as
|
||||
/// plain text, a short isolated cell — a `TH` column header especially —
|
||||
/// is not promoted to a heading.
|
||||
pub(crate) fn is_non_heading_content(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::L
|
||||
| Self::LI
|
||||
| Self::Lbl
|
||||
| Self::LBody
|
||||
| Self::BlockQuote
|
||||
| Self::Quote
|
||||
| Self::Caption
|
||||
| Self::TOC
|
||||
| Self::TOCI
|
||||
| Self::Index
|
||||
| Self::Note
|
||||
| Self::Reference
|
||||
| Self::BibEntry
|
||||
| Self::Code
|
||||
| Self::Formula
|
||||
| Self::Form
|
||||
| Self::Table
|
||||
| Self::TR
|
||||
| Self::TH
|
||||
| Self::TD
|
||||
| Self::THead
|
||||
| Self::TBody
|
||||
| Self::TFoot
|
||||
)
|
||||
}
|
||||
|
||||
fn from_name(name: &str) -> Self {
|
||||
match name {
|
||||
"Document" => Self::Document,
|
||||
@@ -856,6 +902,54 @@ fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn non_heading_content_roles() {
|
||||
for r in [
|
||||
StructRole::L,
|
||||
StructRole::LI,
|
||||
StructRole::BlockQuote,
|
||||
StructRole::Quote,
|
||||
StructRole::Caption,
|
||||
StructRole::TOC,
|
||||
StructRole::TOCI,
|
||||
StructRole::Index,
|
||||
StructRole::Note,
|
||||
StructRole::Reference,
|
||||
StructRole::BibEntry,
|
||||
StructRole::Code,
|
||||
StructRole::Formula,
|
||||
StructRole::Form,
|
||||
StructRole::Table,
|
||||
StructRole::TR,
|
||||
StructRole::TH,
|
||||
StructRole::TD,
|
||||
StructRole::THead,
|
||||
StructRole::TBody,
|
||||
StructRole::TFoot,
|
||||
] {
|
||||
assert!(
|
||||
r.is_non_heading_content(),
|
||||
"{r:?} should block heading promotion"
|
||||
);
|
||||
}
|
||||
// Heading and generic container/flow roles must NOT block promotion
|
||||
for r in [
|
||||
StructRole::H,
|
||||
StructRole::H1,
|
||||
StructRole::H3,
|
||||
StructRole::P,
|
||||
StructRole::Div,
|
||||
StructRole::Sect,
|
||||
StructRole::Span,
|
||||
StructRole::Figure,
|
||||
] {
|
||||
assert!(
|
||||
!r.is_non_heading_content(),
|
||||
"{r:?} should allow heading promotion"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_struct_role_from_name() {
|
||||
assert_eq!(StructRole::from_name("H1"), StructRole::H1);
|
||||
|
||||
@@ -914,7 +914,126 @@ fn looks_like_number(s: &str) -> bool {
|
||||
///
|
||||
/// Used by format.rs to render TOCs as flat lists instead of markdown tables.
|
||||
pub fn is_table_of_contents(cells: &[Vec<String>]) -> bool {
|
||||
is_dot_leader_toc(cells) || is_tabular_toc(cells)
|
||||
is_dot_leader_toc(cells) || is_tabular_toc(cells) || is_page_number_toc(cells)
|
||||
}
|
||||
|
||||
/// Parse a page-number-like token: a short arabic integer (≤4 digits) or a
|
||||
/// canonical roman numeral (front-matter pages: i, ii, …, xxxviii). Roman
|
||||
/// parsing is shared with the formatter via `super::canonical_roman_value` so
|
||||
/// the two stay in sync.
|
||||
fn page_number_value(token: &str) -> Option<u32> {
|
||||
let t = token.trim();
|
||||
if t.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if t.chars().all(|c| c.is_ascii_digit()) && t.len() <= 4 {
|
||||
return t.parse().ok();
|
||||
}
|
||||
super::canonical_roman_value(t)
|
||||
}
|
||||
|
||||
/// Page-number-column TOC: title-based contents with no dot leaders and no
|
||||
/// section numbers (e.g. "About the Publisher vii", "Experiment #1 … 3").
|
||||
/// The signature is a text-title first column and a last column that is almost
|
||||
/// entirely page numbers whose values are *mostly non-decreasing* — the
|
||||
/// monotonic run is what separates a real TOC from an incidental 2-column
|
||||
/// numeric data table.
|
||||
pub(super) fn is_page_number_toc(cells: &[Vec<String>]) -> bool {
|
||||
let num_cols = cells.first().map(|r| r.len()).unwrap_or(0);
|
||||
// A page-number TOC is a narrow list (title + page, optionally a leader
|
||||
// column). Wider grids are data tables, not contents.
|
||||
if !(2..=3).contains(&num_cols) || cells.len() < 5 {
|
||||
return false;
|
||||
}
|
||||
let last = num_cols - 1;
|
||||
|
||||
// No header row: a TOC's first row is already an entry, so its last cell is
|
||||
// a page number. A data table's first row is a column header (non-numeric,
|
||||
// or an empty units cell like "Category | ") — the tell that separates
|
||||
// "Mineral | CEC" tables from real contents. Check the actual first row,
|
||||
// not the first non-empty one, so a blank header cell still rejects.
|
||||
let first_last = cells[0].get(last).map(|s| s.trim()).unwrap_or("");
|
||||
if page_number_value(first_last).is_none() {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Last column: page numbers on ≥70% of filled rows; collect their values.
|
||||
let mut filled = 0u32;
|
||||
let mut page_vals: Vec<u32> = Vec::new();
|
||||
for row in cells {
|
||||
let cell = row.get(last).map(|s| s.trim()).unwrap_or("");
|
||||
if cell.is_empty() {
|
||||
continue;
|
||||
}
|
||||
filled += 1;
|
||||
if let Some(v) = page_number_value(cell) {
|
||||
page_vals.push(v);
|
||||
}
|
||||
}
|
||||
if filled < 4 || (page_vals.len() as f32) < 0.7 * filled as f32 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// First column: mostly text titles (has alphabetic content). This rejects
|
||||
// numeric-vs-numeric grids.
|
||||
let text_first = cells
|
||||
.iter()
|
||||
.filter(|row| {
|
||||
row.first()
|
||||
.is_some_and(|c| c.chars().any(|ch| ch.is_alphabetic()))
|
||||
})
|
||||
.count();
|
||||
if (text_first as f32) < 0.6 * cells.len() as f32 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Page numbers mostly ascend (allow front-matter→body resets and noise).
|
||||
if page_vals.len() < 2 {
|
||||
return false;
|
||||
}
|
||||
let non_decreasing = page_vals.windows(2).filter(|w| w[1] >= w[0]).count();
|
||||
if (non_decreasing as f32) < 0.7 * (page_vals.len() - 1) as f32 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stronger TOC signal. Real page numbers SPAN the document — entries skip
|
||||
// (3, 6, 13, 24, …) so their range exceeds the entry count. A rank / ID /
|
||||
// ordinal column is instead a *perfectly dense* consecutive run (1,2,3,… or
|
||||
// 100,101,102,…). Accept anything with page gaps; for a dense run — which a
|
||||
// one-page-per-entry TOC can also produce — fall back to a title signal:
|
||||
// real contents entries are multi-word headings, rank labels are short.
|
||||
let min = *page_vals.iter().min().unwrap();
|
||||
let max = *page_vals.iter().max().unwrap();
|
||||
let span = max.saturating_sub(min);
|
||||
if span > page_vals.len() as u32 {
|
||||
return true;
|
||||
}
|
||||
let dense_consecutive = (span as usize) + 1 == page_vals.len() && {
|
||||
let mut sorted = page_vals.clone();
|
||||
sorted.sort_unstable();
|
||||
sorted.dedup();
|
||||
sorted.len() == page_vals.len()
|
||||
};
|
||||
if !dense_consecutive {
|
||||
// Narrow range but with a gap or repeat — still contents-like.
|
||||
return true;
|
||||
}
|
||||
// Dense counter: only a TOC if the titles read like headings, not the
|
||||
// short single-word labels typical of rank/leaderboard/ID tables.
|
||||
let (total_words, titled_rows) = cells
|
||||
.iter()
|
||||
.filter_map(|row| row.first())
|
||||
.filter(|c| c.chars().any(|ch| ch.is_alphabetic()))
|
||||
.fold((0usize, 0usize), |(w, n), c| {
|
||||
(
|
||||
w + c
|
||||
.split_whitespace()
|
||||
.filter(|t| t.chars().any(|ch| ch.is_alphabetic()))
|
||||
.count(),
|
||||
n + 1,
|
||||
)
|
||||
});
|
||||
titled_rows > 0 && (total_words as f32) / titled_rows as f32 >= 1.8
|
||||
}
|
||||
|
||||
/// Dot-leader TOC: any "Chapter 1 ........ 42" style with explicit leader
|
||||
@@ -1886,4 +2005,166 @@ mod tests {
|
||||
assert!(!starts_with_section_number(""));
|
||||
assert!(!starts_with_section_number("Hello world"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_number_value_rejects_roman_lookalike_words() {
|
||||
// Ordinary words made only of {i,v,x,l,c} are not page numbers.
|
||||
assert!(page_number_value("civil").is_none());
|
||||
assert!(page_number_value("mix").is_none());
|
||||
assert!(page_number_value("ill").is_none());
|
||||
assert!(page_number_value("lil").is_none());
|
||||
// Canonical roman numerals still parse.
|
||||
assert_eq!(page_number_value("vii"), Some(7));
|
||||
assert_eq!(page_number_value("ix"), Some(9));
|
||||
assert_eq!(page_number_value("xii"), Some(12));
|
||||
assert_eq!(page_number_value("42"), Some(42));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_number_toc_matches_consecutive_pages_with_titles() {
|
||||
// A short chapter-per-page contents: pages are a dense 1..n run, but
|
||||
// the multi-word titles mark it as a real TOC (recovered by the title
|
||||
// signal rather than rejected for lacking page gaps).
|
||||
let cells: Vec<Vec<String>> = vec![
|
||||
vec!["Introduction to the Study".into(), "1".into()],
|
||||
vec!["Materials and Methods".into(), "2".into()],
|
||||
vec!["Results and Discussion".into(), "3".into()],
|
||||
vec!["Summary of Findings".into(), "4".into()],
|
||||
vec!["References and Notes".into(), "5".into()],
|
||||
];
|
||||
assert!(is_page_number_toc(&cells));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_number_toc_rejects_dense_ordinal_column() {
|
||||
// Headerless title | rank table: values are a consecutive 1..n
|
||||
// sequence (monotonic, no header, text first column) but their range
|
||||
// ~= the row count, so it is data, not a table of contents.
|
||||
let cells: Vec<Vec<String>> = vec![
|
||||
vec!["Alice".into(), "1".into()],
|
||||
vec!["Bob".into(), "2".into()],
|
||||
vec!["Carol".into(), "3".into()],
|
||||
vec!["Dave".into(), "4".into()],
|
||||
vec!["Erin".into(), "5".into()],
|
||||
vec!["Frank".into(), "6".into()],
|
||||
];
|
||||
assert!(!is_page_number_toc(&cells));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_number_toc_rejects_blank_header_cell() {
|
||||
// First row is a header whose last cell is blank ("Category | ");
|
||||
// must not be flattened even though later rows look TOC-like.
|
||||
let cells = vec![
|
||||
vec!["Category".into(), "".into()],
|
||||
vec!["Alpha".into(), "3".into()],
|
||||
vec!["Beta".into(), "9".into()],
|
||||
vec!["Gamma".into(), "14".into()],
|
||||
vec!["Delta".into(), "20".into()],
|
||||
];
|
||||
assert!(!is_page_number_toc(&cells));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_number_toc_matches_title_based_contents() {
|
||||
// Title-left, page-number-right, no dot leaders, no section numbers.
|
||||
let cells = vec![
|
||||
vec!["About the Publisher".into(), "vii".into()],
|
||||
vec!["About This Project".into(), "ix".into()],
|
||||
vec!["Acknowledgments".into(), "xi".into()],
|
||||
vec!["Experiment #1: Hydrostatic Pressure".into(), "3".into()],
|
||||
vec!["Experiment #2: Bernoulli's Theorem".into(), "13".into()],
|
||||
vec![
|
||||
"Experiment #3: Energy Loss in Pipe Fittings".into(),
|
||||
"24".into(),
|
||||
],
|
||||
];
|
||||
assert!(is_page_number_toc(&cells));
|
||||
assert!(is_table_of_contents(&cells));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_number_toc_rejects_numeric_data_table() {
|
||||
// Real 2-col data table: numeric first column, non-monotonic values.
|
||||
let cells = vec![
|
||||
vec!["101".into(), "45".into()],
|
||||
vec!["102".into(), "12".into()],
|
||||
vec!["103".into(), "88".into()],
|
||||
vec!["104".into(), "7".into()],
|
||||
vec!["105".into(), "63".into()],
|
||||
];
|
||||
assert!(!is_page_number_toc(&cells));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_number_toc_rejects_non_monotonic_pages() {
|
||||
// Text labels but the "page" column jumps around — a small data table,
|
||||
// not a contents listing. 5 rows so the row-count guard passes and the
|
||||
// monotonicity check is what does the rejecting.
|
||||
let cells: Vec<Vec<String>> = vec![
|
||||
vec!["Apples".into(), "42".into()],
|
||||
vec!["Oranges".into(), "7".into()],
|
||||
vec!["Pears".into(), "91".into()],
|
||||
vec!["Plums".into(), "3".into()],
|
||||
vec!["Grapes".into(), "60".into()],
|
||||
];
|
||||
// Sanity: this input clears the row-count and header guards, so a
|
||||
// failure here is genuinely the monotonicity check.
|
||||
assert!(cells.len() >= 5 && page_number_value(cells[0][1].trim()).is_some());
|
||||
assert!(!is_page_number_toc(&cells));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_number_toc_rejects_header_row_data_table() {
|
||||
// Real 2-col data table with a header row ("Mineral | CEC") and
|
||||
// ascending values that mimic page numbers — the header tells us it
|
||||
// is data, not contents.
|
||||
let cells = vec![
|
||||
vec![
|
||||
"Mineral or colloid type".into(),
|
||||
"CEC of pure colloid".into(),
|
||||
],
|
||||
vec!["kaolinite".into(), "10".into()],
|
||||
vec!["illite".into(), "30".into()],
|
||||
vec!["montmorillonite".into(), "100".into()],
|
||||
vec!["vermiculite".into(), "150".into()],
|
||||
];
|
||||
assert!(!is_page_number_toc(&cells));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_number_toc_rejects_wide_data_grid() {
|
||||
// A 4-column regional data table must not be read as a TOC even with a
|
||||
// text first column and integer last column.
|
||||
let cells = vec![
|
||||
vec![
|
||||
"REGIONS".into(),
|
||||
"2007".into(),
|
||||
"2010".into(),
|
||||
"2016".into(),
|
||||
],
|
||||
vec![
|
||||
"National Capital Region".into(),
|
||||
"9".into(),
|
||||
"8".into(),
|
||||
"5".into(),
|
||||
],
|
||||
vec!["Cordillera".into(), "1".into(), "2".into(), "1".into()],
|
||||
vec!["Ilocos Region".into(), "1".into(), "5".into(), "4".into()],
|
||||
vec!["Cagayan Valley".into(), "1".into(), "3".into(), "5".into()],
|
||||
];
|
||||
assert!(!is_page_number_toc(&cells));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn page_number_toc_needs_page_number_last_column() {
|
||||
// Last column is prose, not page numbers.
|
||||
let cells = vec![
|
||||
vec!["Section A".into(), "see appendix".into()],
|
||||
vec!["Section B".into(), "see notes".into()],
|
||||
vec!["Section C".into(), "later".into()],
|
||||
vec!["Section D".into(), "TBD".into()],
|
||||
];
|
||||
assert!(!is_page_number_toc(&cells));
|
||||
}
|
||||
}
|
||||
|
||||
+2111
-21
File diff suppressed because it is too large
Load Diff
+906
-9
File diff suppressed because it is too large
Load Diff
+62
-2
@@ -123,6 +123,7 @@ fn format_toc_as_list(cells: &[Vec<String>], footnotes: &[String]) -> String {
|
||||
|
||||
/// True when the cell looks like a page number. Accepts:
|
||||
/// - plain digit tokens: "42", "86 86"
|
||||
/// - canonical roman numerals (front-matter pages): "vii", "ix", "xii"
|
||||
/// - dashed section-page IDs: "5-21", "A-1", "B--3", "TC-2" (common in
|
||||
/// technical manuals)
|
||||
fn is_page_number_cell(cell: &str) -> bool {
|
||||
@@ -138,6 +139,9 @@ fn is_page_number_cell(cell: &str) -> bool {
|
||||
if all_digits {
|
||||
return t.len() <= 4;
|
||||
}
|
||||
if super::canonical_roman_value(t).is_some() {
|
||||
return true;
|
||||
}
|
||||
// Section-page form: uppercase letters, digits, dashes; at least
|
||||
// one digit present.
|
||||
t.chars()
|
||||
@@ -184,6 +188,21 @@ fn starts_with_numbered_label(cell: &str) -> bool {
|
||||
.is_some_and(|c| matches!(c, '.' | ')' | '-' | ':'))
|
||||
}
|
||||
|
||||
fn starts_with_hierarchical_numbered_label(cell: &str) -> bool {
|
||||
let token = cell
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim_end_matches(['.', ')', ':', '-']);
|
||||
let levels: Vec<&str> = token.split('.').collect();
|
||||
(2..=4).contains(&levels.len())
|
||||
&& levels.iter().all(|level| {
|
||||
!level.is_empty()
|
||||
&& level.len() <= 3
|
||||
&& level.chars().all(|character| character.is_ascii_digit())
|
||||
})
|
||||
}
|
||||
|
||||
fn alpha_word_count(cell: &str) -> usize {
|
||||
cell.split_whitespace()
|
||||
.filter(|word| word.chars().any(|c| c.is_alphabetic()))
|
||||
@@ -337,11 +356,12 @@ fn clean_table_cells(cells: &[Vec<String>]) -> (Vec<Vec<String>>, Vec<String>) {
|
||||
// mid-sentence/lowercase ("continued text here", "with 3.5%...") or
|
||||
// carry lowercase fragments in the later cells, so keep those mergeable.
|
||||
let looks_like_hierarchical_subrow = first_cell.is_empty()
|
||||
&& row.len() >= 3
|
||||
&& first_non_empty_col == Some(1)
|
||||
&& looks_like_compact_entry_label(first_non_empty_cell)
|
||||
&& ((non_first_cells.len() >= 2 && title_like_later_cells > 0)
|
||||
&& ((row.len() == 2 && starts_with_hierarchical_numbered_label(first_non_empty_cell))
|
||||
|| (row.len() >= 3 && non_first_cells.len() >= 2 && title_like_later_cells > 0)
|
||||
|| (non_first_cells.len() == 1
|
||||
&& row.len() >= 3
|
||||
&& prev_first_cell_empty
|
||||
&& alpha_word_count(first_non_empty_cell) >= 2));
|
||||
let looks_like_new_first_column_entry = !first_cell.is_empty()
|
||||
@@ -684,6 +704,46 @@ mod tests {
|
||||
assert_eq!(cleaned[4][1], "Model training");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_table_cells_two_column_numbered_subrows_not_merged() {
|
||||
let cells = vec![
|
||||
vec!["Area".into(), "Competence".into()],
|
||||
vec![
|
||||
"1. Embodying sustainability values".into(),
|
||||
"1.1 Valuing sustainability".into(),
|
||||
],
|
||||
vec!["".into(), "1.2 Supporting fairness".into()],
|
||||
vec!["".into(), "1.3 Promoting nature".into()],
|
||||
vec![
|
||||
"2. Embracing complexity".into(),
|
||||
"2.1 Systems thinking".into(),
|
||||
],
|
||||
vec!["".into(), "2.2 Critical thinking".into()],
|
||||
];
|
||||
let (cleaned, _) = clean_table_cells(&cells);
|
||||
|
||||
assert_eq!(cleaned.len(), 6);
|
||||
assert_eq!(cleaned[2], vec!["", "1.2 Supporting fairness"]);
|
||||
assert_eq!(cleaned[3], vec!["", "1.3 Promoting nature"]);
|
||||
assert_eq!(cleaned[5], vec!["", "2.2 Critical thinking"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_table_cells_two_column_numbered_continuation_merges() {
|
||||
let cells = vec![
|
||||
vec!["Area".into(), "Requirement".into()],
|
||||
vec!["Safety".into(), "The program includes".into()],
|
||||
vec!["".into(), "1. First requirement for every operator".into()],
|
||||
];
|
||||
let (cleaned, _) = clean_table_cells(&cells);
|
||||
|
||||
assert_eq!(cleaned.len(), 2);
|
||||
assert_eq!(
|
||||
cleaned[1][1],
|
||||
"The program includes 1. First requirement for every operator"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clean_table_cells_partial_hierarchical_subrow_not_merged() {
|
||||
let cells = vec![
|
||||
|
||||
+57
-2
@@ -4,7 +4,7 @@
|
||||
|
||||
mod detect_heuristic;
|
||||
mod detect_lines;
|
||||
mod detect_rects;
|
||||
pub(crate) mod detect_rects;
|
||||
mod detect_struct;
|
||||
mod financial;
|
||||
mod format;
|
||||
@@ -14,8 +14,9 @@ pub mod structured;
|
||||
pub use detect_heuristic::detect_tables;
|
||||
pub(crate) use detect_heuristic::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_rects::cluster_rects;
|
||||
pub use detect_rects::{detect_tables_from_rects, RectHintRegion};
|
||||
pub use detect_rects::{detect_chart_regions, detect_tables_from_rects, RectHintRegion};
|
||||
pub use detect_struct::detect_tables_from_struct_tree;
|
||||
pub use format::table_to_markdown;
|
||||
pub use structured::{cells_to_markdown, StructuredCell};
|
||||
@@ -177,6 +178,60 @@ pub(crate) fn try_build_rect_guided_table(
|
||||
))
|
||||
}
|
||||
|
||||
/// Canonical lowercase roman numeral for `n` (the i/v/x/l/c range).
|
||||
pub(super) fn to_roman_lower(mut n: u32) -> String {
|
||||
const TABLE: [(u32, &str); 9] = [
|
||||
(100, "c"),
|
||||
(90, "xc"),
|
||||
(50, "l"),
|
||||
(40, "xl"),
|
||||
(10, "x"),
|
||||
(9, "ix"),
|
||||
(5, "v"),
|
||||
(4, "iv"),
|
||||
(1, "i"),
|
||||
];
|
||||
let mut out = String::new();
|
||||
for (val, sym) in TABLE {
|
||||
while n >= val {
|
||||
out.push_str(sym);
|
||||
n -= val;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse a *canonical* roman numeral (i/v/x/l/c range, ≤8 chars) to its value.
|
||||
/// Returns `None` for non-canonical strings, so ordinary words made of those
|
||||
/// letters — "civil", "mix", "ill" — are not mistaken for numbers. Shared by
|
||||
/// the TOC detector and the TOC formatter so the two stay in sync.
|
||||
pub(super) fn canonical_roman_value(token: &str) -> Option<u32> {
|
||||
let lower = token.trim().to_ascii_lowercase();
|
||||
if lower.is_empty() || lower.len() > 8 || !lower.chars().all(|c| "ivxlc".contains(c)) {
|
||||
return None;
|
||||
}
|
||||
let mut total = 0i32;
|
||||
let mut prev = 0i32;
|
||||
for c in lower.chars().rev() {
|
||||
let v = match c {
|
||||
'i' => 1,
|
||||
'v' => 5,
|
||||
'x' => 10,
|
||||
'l' => 50,
|
||||
'c' => 100,
|
||||
_ => return None,
|
||||
};
|
||||
if v < prev {
|
||||
total -= v;
|
||||
} else {
|
||||
total += v;
|
||||
prev = v;
|
||||
}
|
||||
}
|
||||
let value = u32::try_from(total).ok().filter(|&n| n > 0)?;
|
||||
(to_roman_lower(value) == lower).then_some(value)
|
||||
}
|
||||
|
||||
/// Split a TextItem whose text contains multiple whitespace-separated tokens
|
||||
/// (like "10 11 12 ... 31") into individual TextItems, each assigned to the
|
||||
/// nearest column boundary.
|
||||
|
||||
@@ -0,0 +1,520 @@
|
||||
//! Text-quality detection: deciding when an extracted text layer is too broken
|
||||
//! to serve and a page should fall back to OCR.
|
||||
//!
|
||||
//! Extraction can produce plausible-looking bytes that are actually garbage —
|
||||
//! failed CID→Unicode mappings, broken ToUnicode CMaps, mojibake. These
|
||||
//! detectors catch that and let callers set `needs_ocr`. They come in two
|
||||
//! layers, sharing the same primitives:
|
||||
//!
|
||||
//! - **Markdown-level** ([`detect_encoding_issues`], [`is_garbage_text`],
|
||||
//! [`is_cid_garbage`]) run on a page's final markdown string. Used as a
|
||||
//! backstop on the region-extraction and whole-document paths.
|
||||
//! - **Item/span-level** ([`analyze_text_quality`],
|
||||
//! [`region_items_have_decoding_issue`]) run on individual `TextItem`s and
|
||||
//! accumulate per-page evidence, so localized garbled spans on an otherwise
|
||||
//! clean page are caught without a single span having to condemn the page.
|
||||
//!
|
||||
//! Detection classes, roughly by signal:
|
||||
//! - **Replacement runs**: U+FFFD clusters ([`has_replacement_text_run`]).
|
||||
//! - **Private-use / C1-control runs**: CID passthrough landing in PUA or the
|
||||
//! C1 block ([`has_private_use_text_run`], [`has_cid_control_token`]).
|
||||
//! - **Dollar-as-space**: `Word$Word$Word` from broken CMaps
|
||||
//! ([`has_dollar_as_space_pattern`]).
|
||||
//! - **Non-alphanumeric dominance**: symbol soup ([`is_garbage_text`]).
|
||||
//! - **Substitution-cipher letter statistics**: pure-ASCII output whose letter
|
||||
//! distribution is a permutation of natural language ([`CipherGarbleStats`]).
|
||||
|
||||
use crate::types::TextItem;
|
||||
use crate::{add_ocr_reason, OCR_REASON_SUSPECTED_GARBLED_TEXT};
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// Detect broken font encodings in extracted markdown text.
|
||||
///
|
||||
/// Two heuristics:
|
||||
/// 1. **U+FFFD**: Any replacement character indicates decode failures.
|
||||
/// 2. **Dollar-as-space**: Pattern like `Word$Word$Word` where `$` is used as a
|
||||
/// word separator due to broken ToUnicode CMaps. Triggers when either:
|
||||
/// - More than 50% of `$` are between letters (clear substitution pattern), OR
|
||||
/// - More than 20 letter-dollar-letter occurrences (even if some `$` are also
|
||||
/// used as trailing/leading separators, 20+ is far beyond normal financial text).
|
||||
pub(crate) fn detect_encoding_issues(markdown: &str) -> bool {
|
||||
// Heuristic 1: U+FFFD replacement characters
|
||||
if markdown.contains('\u{FFFD}') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Heuristic 2: dollar-as-space pattern
|
||||
if has_dollar_as_space_pattern(markdown) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Heuristic 3: substitution-cipher letter statistics (broken ToUnicode)
|
||||
let mut stats = CipherGarbleStats::default();
|
||||
stats.add_text(markdown);
|
||||
stats.looks_garbled()
|
||||
}
|
||||
|
||||
fn has_dollar_as_space_pattern(markdown: &str) -> bool {
|
||||
let total_dollars = markdown.matches('$').count();
|
||||
if total_dollars > 10 {
|
||||
let bytes = markdown.as_bytes();
|
||||
let mut letter_dollar_letter = 0usize;
|
||||
for i in 1..bytes.len().saturating_sub(1) {
|
||||
if bytes[i] == b'$'
|
||||
&& bytes[i - 1].is_ascii_alphabetic()
|
||||
&& bytes[i + 1].is_ascii_alphabetic()
|
||||
{
|
||||
letter_dollar_letter += 1;
|
||||
}
|
||||
}
|
||||
if letter_dollar_letter > 20 || letter_dollar_letter * 2 > total_dollars {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// English letter frequencies (percent, a–z). Used as a natural-language
|
||||
/// reference: every Latin-script language in the eval corpus (Swedish,
|
||||
/// Finnish, Turkish, German, romaji) scores ≥ 0.80 cosine similarity against
|
||||
/// it, while substitution-cipher text scores ~0.53.
|
||||
const ENGLISH_LETTER_FREQ: [f64; 26] = [
|
||||
8.2, 1.5, 2.8, 4.3, 12.7, 2.2, 2.0, 6.1, 7.0, 0.15, 0.8, 4.0, 2.4, 6.7, 7.5, 1.9, 0.1, 6.0,
|
||||
6.3, 9.1, 2.8, 1.0, 2.4, 0.15, 2.0, 0.07,
|
||||
];
|
||||
|
||||
/// Letter statistics for detecting substitution-cipher garbling: broken
|
||||
/// ToUnicode CMaps that shift every character by a per-range constant (e.g.
|
||||
/// `Certificate` extracted as `8VceZWZTReV`). Such text is 100% printable
|
||||
/// ASCII with word-like token lengths, so it defeats `is_garbage_text` and
|
||||
/// produces no replacement characters — it needs its own discriminator.
|
||||
#[derive(Debug, Default)]
|
||||
struct CipherGarbleStats {
|
||||
/// Case-folded ASCII letter histogram.
|
||||
letter_counts: [u32; 26],
|
||||
ascii_letters: usize,
|
||||
ascii_vowels: usize,
|
||||
/// Accented Latin letters (Latin-1 Supplement through Latin Extended-B,
|
||||
/// plus Latin Extended Additional). Count toward Latin dominance only.
|
||||
latin_ext_letters: usize,
|
||||
non_latin_letters: usize,
|
||||
/// Adjacent ASCII-letter pairs, and how many of them switch from
|
||||
/// lowercase straight to uppercase mid-word.
|
||||
letter_bigrams: usize,
|
||||
case_shift_bigrams: usize,
|
||||
}
|
||||
|
||||
impl CipherGarbleStats {
|
||||
fn add_text(&mut self, text: &str) {
|
||||
let mut prev: Option<char> = None;
|
||||
for ch in text.chars() {
|
||||
if ch.is_ascii_alphabetic() {
|
||||
let idx = (ch.to_ascii_lowercase() as u8 - b'a') as usize;
|
||||
self.letter_counts[idx] += 1;
|
||||
self.ascii_letters += 1;
|
||||
if matches!(ch.to_ascii_lowercase(), 'a' | 'e' | 'i' | 'o' | 'u') {
|
||||
self.ascii_vowels += 1;
|
||||
}
|
||||
if let Some(p) = prev {
|
||||
self.letter_bigrams += 1;
|
||||
if p.is_ascii_lowercase() && ch.is_ascii_uppercase() {
|
||||
self.case_shift_bigrams += 1;
|
||||
}
|
||||
}
|
||||
prev = Some(ch);
|
||||
} else {
|
||||
if ch.is_alphabetic() {
|
||||
if matches!(ch as u32, 0xC0..=0x24F | 0x1E00..=0x1EFF) {
|
||||
self.latin_ext_letters += 1;
|
||||
} else {
|
||||
self.non_latin_letters += 1;
|
||||
}
|
||||
}
|
||||
prev = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cosine similarity between the observed letter histogram and English
|
||||
/// letter frequencies. A shifted alphabet permutes the histogram, which
|
||||
/// destroys the similarity regardless of the shift amount.
|
||||
fn english_cosine(&self) -> f64 {
|
||||
if self.ascii_letters == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
let n = self.ascii_letters as f64;
|
||||
let mut dot = 0.0;
|
||||
let mut norm_obs = 0.0;
|
||||
for (count, freq) in self.letter_counts.iter().zip(ENGLISH_LETTER_FREQ) {
|
||||
let p = *count as f64 / n;
|
||||
dot += p * freq;
|
||||
norm_obs += p * p;
|
||||
}
|
||||
let norm_en = ENGLISH_LETTER_FREQ
|
||||
.iter()
|
||||
.map(|f| f * f)
|
||||
.sum::<f64>()
|
||||
.sqrt();
|
||||
dot / (norm_obs.sqrt() * norm_en)
|
||||
}
|
||||
|
||||
/// Cosine similarity between the observed histogram and English
|
||||
/// frequencies after sorting BOTH descending — i.e. comparing the *shape*
|
||||
/// of the frequency profile, ignoring which letter sits where. A
|
||||
/// substitution cipher is a bijection, so it preserves this shape exactly
|
||||
/// (att10k 0.97, arbitrary shifts 0.99) regardless of case or offset.
|
||||
/// Non-linguistic ASCII has a different profile: a small alphabet is far
|
||||
/// steeper (random DNA 0.74, hex dumps 0.81), so the shape diverges.
|
||||
fn english_shape_cosine(&self) -> f64 {
|
||||
if self.ascii_letters == 0 {
|
||||
return 1.0;
|
||||
}
|
||||
let n = self.ascii_letters as f64;
|
||||
let mut obs: [f64; 26] = std::array::from_fn(|i| self.letter_counts[i] as f64 / n);
|
||||
obs.sort_unstable_by(|a, b| b.total_cmp(a));
|
||||
let mut en = ENGLISH_LETTER_FREQ;
|
||||
en.sort_unstable_by(|a, b| b.total_cmp(a));
|
||||
|
||||
let dot: f64 = obs.iter().zip(en).map(|(o, e)| o * e).sum();
|
||||
let norm_obs = obs.iter().map(|o| o * o).sum::<f64>().sqrt();
|
||||
let norm_en = en.iter().map(|e| e * e).sum::<f64>().sqrt();
|
||||
dot / (norm_obs * norm_en)
|
||||
}
|
||||
|
||||
/// Thresholds validated against the 380-document pdf-evals snapshot
|
||||
/// corpus (0 false positives) and the garbled ParseBench `att10k` page
|
||||
/// (vowel ratio 0.245, case-shift rate 0.225, cosine 0.532). Closest
|
||||
/// legitimate document on each axis: vowel ratio 0.264 (circuit
|
||||
/// schematic), case-shift rate 0.021, cosine 0.801.
|
||||
fn looks_garbled(&self) -> bool {
|
||||
// Need a statistically meaningful, Latin-dominant sample.
|
||||
if self.ascii_letters < 200
|
||||
|| self.non_latin_letters > self.ascii_letters + self.latin_ext_letters
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Real Latin-script text keeps vowels above ~30% of letters even in
|
||||
// acronym- and part-number-heavy documents; shifted text starves them.
|
||||
let vowel_ratio = self.ascii_vowels as f64 / self.ascii_letters as f64;
|
||||
if vowel_ratio > 0.30 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Signal 1: lowercase→uppercase transitions inside words. A shifted
|
||||
// lowercase alphabet straddles the ASCII uppercase block ('i'→'Z',
|
||||
// 't'→'e'), so garbled words flip case constantly. Real documents
|
||||
// stay ≤ 0.02 even with camelCase identifiers.
|
||||
let case_shifts = self.letter_bigrams >= 100
|
||||
&& self.case_shift_bigrams as f64 >= self.letter_bigrams as f64 * 0.10;
|
||||
|
||||
// Signal 2: the histogram is a permutation of natural language — an
|
||||
// English-like frequency SHAPE (sorted cosine high) but with letters
|
||||
// in the wrong POSITIONS (unsorted cosine low). This is the signature
|
||||
// of a substitution cipher and is case-independent, so it catches
|
||||
// all-lowercase and all-uppercase shifts as well as case-straddling
|
||||
// ones. Genuinely non-linguistic ASCII that is merely "unlike English"
|
||||
// fails one of the two halves: DNA/hex dumps have too steep a profile
|
||||
// (shape cosine < 0.90), while protein sequences, ticker symbols and
|
||||
// base64 are not sufficiently unlike English in position (unsorted
|
||||
// cosine ≥ 0.60) — so none of them are routed to OCR.
|
||||
let permuted_language = self.english_cosine() < 0.60 && self.english_shape_cosine() >= 0.90;
|
||||
|
||||
case_shifts || permuted_language
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct TextQualityReport {
|
||||
pub(crate) pages_needing_ocr: Vec<u32>,
|
||||
pub(crate) has_encoding_issues: bool,
|
||||
pub(crate) reasons_by_page: BTreeMap<u32, Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct PageTextQualityEvidence {
|
||||
chars: usize,
|
||||
replacement_chars: usize,
|
||||
replacement_spans: usize,
|
||||
longest_replacement_run: usize,
|
||||
cipher_garble: CipherGarbleStats,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum TextSpanIssueKind {
|
||||
Replacement,
|
||||
Strong,
|
||||
}
|
||||
|
||||
pub(crate) fn analyze_text_quality(items: &[TextItem]) -> TextQualityReport {
|
||||
let mut reasons_by_page = BTreeMap::new();
|
||||
let mut evidence_by_page = BTreeMap::<u32, PageTextQualityEvidence>::new();
|
||||
|
||||
for item in items {
|
||||
if !matches!(item.item_type, crate::types::ItemType::Text) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let evidence = evidence_by_page.entry(item.page).or_default();
|
||||
evidence.chars += item.text.chars().filter(|ch| !ch.is_whitespace()).count();
|
||||
evidence.cipher_garble.add_text(&item.text);
|
||||
|
||||
match text_span_decoding_issue_kind(&item.text) {
|
||||
Some(TextSpanIssueKind::Strong) => {
|
||||
add_ocr_reason(
|
||||
&mut reasons_by_page,
|
||||
item.page,
|
||||
OCR_REASON_SUSPECTED_GARBLED_TEXT,
|
||||
);
|
||||
}
|
||||
Some(TextSpanIssueKind::Replacement) => {
|
||||
let stats = replacement_text_stats(&item.text);
|
||||
evidence.replacement_chars += stats.0;
|
||||
evidence.replacement_spans += 1;
|
||||
evidence.longest_replacement_run = evidence.longest_replacement_run.max(stats.1);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
for (page, evidence) in evidence_by_page {
|
||||
if reasons_by_page.contains_key(&page) {
|
||||
continue;
|
||||
}
|
||||
if page_replacement_evidence_needs_ocr(&evidence) || evidence.cipher_garble.looks_garbled()
|
||||
{
|
||||
add_ocr_reason(
|
||||
&mut reasons_by_page,
|
||||
page,
|
||||
OCR_REASON_SUSPECTED_GARBLED_TEXT,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let pages_needing_ocr: Vec<u32> = reasons_by_page.keys().copied().collect();
|
||||
TextQualityReport {
|
||||
has_encoding_issues: !pages_needing_ocr.is_empty(),
|
||||
pages_needing_ocr,
|
||||
reasons_by_page,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn region_items_have_decoding_issue(items: &[TextItem]) -> bool {
|
||||
items.iter().any(|item| {
|
||||
matches!(item.item_type, crate::types::ItemType::Text)
|
||||
&& text_span_has_decoding_issue(&item.text)
|
||||
})
|
||||
}
|
||||
|
||||
fn text_span_has_decoding_issue(text: &str) -> bool {
|
||||
text_span_decoding_issue_kind(text).is_some()
|
||||
}
|
||||
|
||||
fn text_span_decoding_issue_kind(text: &str) -> Option<TextSpanIssueKind> {
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
if has_dollar_as_space_pattern(text)
|
||||
|| has_private_use_text_run(text)
|
||||
|| is_cid_garbage(text)
|
||||
|| has_cid_control_token(text)
|
||||
{
|
||||
return Some(TextSpanIssueKind::Strong);
|
||||
}
|
||||
|
||||
if has_replacement_text_run(text) {
|
||||
return Some(TextSpanIssueKind::Replacement);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn replacement_text_stats(text: &str) -> (usize, usize) {
|
||||
let mut replacement = 0usize;
|
||||
let mut current_run = 0usize;
|
||||
let mut longest_run = 0usize;
|
||||
|
||||
for ch in text.chars() {
|
||||
if ch == '\u{FFFD}' {
|
||||
replacement += 1;
|
||||
current_run += 1;
|
||||
longest_run = longest_run.max(current_run);
|
||||
} else {
|
||||
current_run = 0;
|
||||
}
|
||||
}
|
||||
|
||||
(replacement, longest_run)
|
||||
}
|
||||
|
||||
fn page_replacement_evidence_needs_ocr(evidence: &PageTextQualityEvidence) -> bool {
|
||||
if evidence.replacement_chars == 0 || evidence.chars == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the entire page is only a short broken text layer, even a short
|
||||
// replacement run is enough evidence. On otherwise text-heavy pages,
|
||||
// require density so math formulas do not force full-page OCR.
|
||||
if evidence.chars <= 80 && evidence.longest_replacement_run >= 2 {
|
||||
return true;
|
||||
}
|
||||
|
||||
let replacement_density_bps = evidence.replacement_chars * 10_000 / evidence.chars;
|
||||
let enough_bad_text = evidence.replacement_chars >= 12 && replacement_density_bps >= 500;
|
||||
let repeated_bad_spans = evidence.replacement_spans >= 3 && replacement_density_bps >= 250;
|
||||
let long_bad_run = evidence.longest_replacement_run >= 8 && replacement_density_bps >= 250;
|
||||
|
||||
enough_bad_text || repeated_bad_spans || long_bad_run
|
||||
}
|
||||
|
||||
fn has_replacement_text_run(text: &str) -> bool {
|
||||
let (replacement, longest_run) = replacement_text_stats(text);
|
||||
longest_run >= 2 || replacement >= 3
|
||||
}
|
||||
|
||||
fn has_private_use_text_run(text: &str) -> bool {
|
||||
let mut total = 0usize;
|
||||
let mut private_use = 0usize;
|
||||
let mut current_run = 0usize;
|
||||
let mut longest_run = 0usize;
|
||||
|
||||
for ch in text.chars() {
|
||||
if ch.is_whitespace() {
|
||||
current_run = 0;
|
||||
continue;
|
||||
}
|
||||
total += 1;
|
||||
if is_private_use_char(ch) {
|
||||
private_use += 1;
|
||||
current_run += 1;
|
||||
longest_run = longest_run.max(current_run);
|
||||
} else {
|
||||
current_run = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if private_use == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
longest_run >= 3 || (total >= 5 && private_use >= 2 && private_use * 2 >= total)
|
||||
}
|
||||
|
||||
fn has_cid_control_token(text: &str) -> bool {
|
||||
text.split_whitespace().any(token_has_cid_control)
|
||||
}
|
||||
|
||||
fn token_has_cid_control(token: &str) -> bool {
|
||||
let mut total = 0usize;
|
||||
let mut c1_control = 0usize;
|
||||
|
||||
for ch in token.chars() {
|
||||
total += 1;
|
||||
if ('\u{0080}'..='\u{009F}').contains(&ch) {
|
||||
c1_control += 1;
|
||||
}
|
||||
}
|
||||
|
||||
total >= 5 && c1_control >= 2 && c1_control * 20 >= total
|
||||
}
|
||||
|
||||
fn is_private_use_char(ch: char) -> bool {
|
||||
matches!(
|
||||
ch as u32,
|
||||
0xE000..=0xF8FF | 0xF0000..=0xFFFFD | 0x100000..=0x10FFFD
|
||||
)
|
||||
}
|
||||
|
||||
/// Check if extracted text is predominantly garbage (non-alphanumeric).
|
||||
///
|
||||
/// Broken font encodings produce text like "----1-.-.-.___ --.-. .._ I_---."
|
||||
/// where most characters are punctuation/symbols. Real text in any language
|
||||
/// has >50% alphanumeric characters.
|
||||
pub(crate) fn is_garbage_text(markdown: &str) -> bool {
|
||||
let mut alphanum = 0usize;
|
||||
let mut non_alphanum = 0usize;
|
||||
|
||||
let chars: Vec<char> = markdown.chars().collect();
|
||||
let mut i = 0usize;
|
||||
while i < chars.len() {
|
||||
let ch = chars[i];
|
||||
let mut run_end = i + 1;
|
||||
while run_end < chars.len() && chars[run_end] == ch {
|
||||
run_end += 1;
|
||||
}
|
||||
|
||||
let is_decorative_leader = matches!(ch, '.' | '_' | '·') && run_end - i >= 3;
|
||||
if !is_decorative_leader {
|
||||
for &run_ch in &chars[i..run_end] {
|
||||
if run_ch.is_whitespace() {
|
||||
continue;
|
||||
}
|
||||
// Skip markdown syntax chars that we add (not from the PDF)
|
||||
if matches!(run_ch, '#' | '*' | '|' | '-' | '\n') {
|
||||
continue;
|
||||
}
|
||||
if run_ch.is_alphanumeric() {
|
||||
alphanum += 1;
|
||||
} else {
|
||||
non_alphanum += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
i = run_end;
|
||||
}
|
||||
|
||||
let total = alphanum + non_alphanum;
|
||||
total >= 50 && alphanum * 2 < total
|
||||
}
|
||||
|
||||
/// Detect garbage from failed CID-to-Unicode mapping on Identity-H fonts.
|
||||
///
|
||||
/// When CID values don't correspond to Unicode codepoints, the raw bytes often
|
||||
/// produce characters in the C1 control range (U+0080–U+009F) or Private Use
|
||||
/// Area, mixed with random Latin Extended characters. Valid text in any
|
||||
/// language almost never contains C1 controls. We also fall back to the
|
||||
/// general `is_garbage_text` check for non-alphanumeric-heavy patterns.
|
||||
pub(crate) fn is_cid_garbage(text: &str) -> bool {
|
||||
if is_garbage_text(text) {
|
||||
return true;
|
||||
}
|
||||
let mut total = 0usize;
|
||||
let mut c1_control = 0usize;
|
||||
let mut high_latin = 0usize;
|
||||
for ch in text.chars() {
|
||||
if ch.is_whitespace() {
|
||||
continue;
|
||||
}
|
||||
total += 1;
|
||||
// C1 control characters (U+0080–U+009F) — almost never in real text
|
||||
if ch == '·' {
|
||||
continue;
|
||||
}
|
||||
if ('\u{0080}'..='\u{009F}').contains(&ch) {
|
||||
c1_control += 1;
|
||||
}
|
||||
// High Latin-1 (U+00A0–U+00FF) — legitimate in Western European text
|
||||
// but when combined with ASCII in CID passthrough, indicates mojibake
|
||||
// from CID values being misinterpreted as Latin-1 characters.
|
||||
if ('\u{00A0}'..='\u{00FF}').contains(&ch) {
|
||||
high_latin += 1;
|
||||
}
|
||||
}
|
||||
if total < 5 {
|
||||
return false;
|
||||
}
|
||||
// If ≥5% of non-whitespace chars are C1 controls, it's garbage
|
||||
if c1_control >= 2 && c1_control * 20 >= total {
|
||||
return true;
|
||||
}
|
||||
// If ≥40% of non-whitespace chars are high Latin-1 AND the text has few
|
||||
// ASCII letters, it's likely CID-as-Latin-1 mojibake (Japanese/CJK PDFs
|
||||
// where CID values 0x80-0xFF become accented Latin characters). Keep a
|
||||
// minimum length so short math tokens like "2×()×" do not route a clean
|
||||
// page to OCR.
|
||||
let ascii_letters = text.chars().filter(|c| c.is_ascii_alphabetic()).count();
|
||||
total >= 20 && high_latin * 5 >= total * 2 && ascii_letters * 3 < total
|
||||
}
|
||||
@@ -93,6 +93,9 @@ pub fn is_bold_font(font_name: &str) -> bool {
|
||||
|| lower.contains("extrabold")
|
||||
|| lower.contains("ultrabold")
|
||||
|| lower.contains("medium") && !lower.contains("mediumitalic") // Some fonts use Medium for semi-bold
|
||||
// URW Type 1 fonts abbreviate Medium as "Medi" (e.g. NimbusRomNo9L-Medi,
|
||||
// the Times-Bold substitute in LaTeX documents; -MediItal is bold italic).
|
||||
|| lower.contains("-medi") && !lower.contains("mediumital")
|
||||
}
|
||||
|
||||
/// Detect if a font name indicates italic/oblique style
|
||||
@@ -762,6 +765,17 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::types::ItemType;
|
||||
|
||||
#[test]
|
||||
fn bold_font_urw_medi_abbreviation() {
|
||||
// URW Type 1 fonts (LaTeX default Times) abbreviate Medium as "Medi"
|
||||
assert!(is_bold_font("NROFIU+NimbusRomNo9L-Medi"));
|
||||
assert!(is_bold_font("NimbusRomNo9L-MediItal"));
|
||||
assert!(!is_bold_font("DSSZWN+NimbusRomNo9L-Regu"));
|
||||
assert!(!is_bold_font("NimbusRomNo9L-ReguItal"));
|
||||
// Medium-Italic exclusion still holds
|
||||
assert!(!is_bold_font("Foo-MediumItalic"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_soft_hyphen() {
|
||||
assert_eq!(expand_ligatures("con\u{00AD}tent"), "content");
|
||||
|
||||
BIN
Binary file not shown.
@@ -337,6 +337,7 @@ fn test_group_into_lines_sorting_by_x() {
|
||||
#[test]
|
||||
fn test_markdown_options_default() {
|
||||
let opts = MarkdownOptions::default();
|
||||
assert_eq!(opts.profile, pdf_inspector::MarkdownProfile::Fidelity);
|
||||
assert!(opts.detect_headers);
|
||||
assert!(opts.detect_lists);
|
||||
assert!(opts.detect_code);
|
||||
@@ -346,6 +347,7 @@ fn test_markdown_options_default() {
|
||||
#[test]
|
||||
fn test_markdown_options_custom() {
|
||||
let opts = MarkdownOptions {
|
||||
profile: pdf_inspector::MarkdownProfile::Compact,
|
||||
detect_headers: false,
|
||||
detect_lists: true,
|
||||
detect_code: false,
|
||||
@@ -361,6 +363,7 @@ fn test_markdown_options_custom() {
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!opts.detect_headers);
|
||||
assert_eq!(opts.profile, pdf_inspector::MarkdownProfile::Compact);
|
||||
assert!(opts.detect_lists);
|
||||
assert!(!opts.detect_code);
|
||||
assert_eq!(opts.base_font_size, Some(14.0));
|
||||
@@ -1102,6 +1105,7 @@ fn test_pages_needing_ocr_field_accessible() {
|
||||
title: None,
|
||||
ocr_recommended: false,
|
||||
pages_needing_ocr: Vec::new(),
|
||||
ocr_reasons_by_page: std::collections::BTreeMap::new(),
|
||||
};
|
||||
assert!(detection_result.pages_needing_ocr.is_empty());
|
||||
|
||||
@@ -3606,3 +3610,45 @@ fn test_markdown_options_default_has_include_images_false() {
|
||||
let opts = MarkdownOptions::default();
|
||||
assert!(!opts.include_images);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encrypted_pdf_decrypts_with_correct_password() {
|
||||
let path = "tests/fixtures/encrypted-secret123.pdf";
|
||||
|
||||
// No password: the file is encrypted and can't be read.
|
||||
let no_pw = process_pdf_with_options(path, PdfOptions::new());
|
||||
assert!(
|
||||
matches!(no_pw, Err(PdfError::Encrypted)),
|
||||
"expected Encrypted without a password, got {no_pw:?}"
|
||||
);
|
||||
|
||||
// Wrong password: still rejected.
|
||||
let wrong = process_pdf_with_options(path, PdfOptions::new().password("wrong"));
|
||||
assert!(
|
||||
matches!(wrong, Err(PdfError::Encrypted)),
|
||||
"expected Encrypted with a wrong password, got {wrong:?}"
|
||||
);
|
||||
|
||||
// Correct password: decrypts and extracts real content.
|
||||
let ok = process_pdf_with_options(path, PdfOptions::new().password("secret123"))
|
||||
.expect("correct password should decrypt");
|
||||
let md = ok.markdown.unwrap_or_default();
|
||||
// Assert a stable fixture token so a garbled-but-long extraction (the
|
||||
// encrypted-stream regression this guards) still fails the test.
|
||||
assert!(
|
||||
md.contains("Procurement"),
|
||||
"decrypted markdown should contain the fixture's real text, got {} chars",
|
||||
md.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pdf_options_debug_redacts_password() {
|
||||
let opts = PdfOptions::new().password("secret123");
|
||||
let dbg = format!("{opts:?}");
|
||||
assert!(
|
||||
!dbg.contains("secret123"),
|
||||
"password leaked in Debug: {dbg}"
|
||||
);
|
||||
assert!(dbg.contains("REDACTED"), "expected redaction marker: {dbg}");
|
||||
}
|
||||
|
||||
@@ -22,7 +22,9 @@ Name and address of employee
|
||||
|
||||
**Publication 1244 (Rev. 7-96)** Cat. No. 44472W
|
||||
|
||||
**Instructions** You must keep sufficient proof to show the amount of your tip income for the year. A daily record of your tip income is considered sufficient proof. Keep a daily record for each workday showing the amount of cash and credit card tips received directly from customers or other employees. Also keep a record of the amount of tips, if any, you paid to other employees through tip sharing, tip pooling or other arrangements, and the names of employees to whom you paid tips. Show the date that each entry is made. This date should be on or near the date you received the tip income. You may use **Form 4070A**, Employee’s Daily Record of Tips, or any other daily record to record your tips. **Reporting Tips to Your Employer.—**If you receive tips that total $20 or more for any month while working for one employer, you must report the tips to your employer. Tips include cash left by customers, tips customers add to credit card charges, and tips you receive from other employees. You must report your tips for any one month by the 10th day of the next month. If the 10th day falls on a Saturday, Sunday, or legal holiday, you may give the report to your employer on the next business day that is not a Saturday, Sunday, or legal holiday. You must report tips that total $20 or more every month regardless of your total wages and tips for the year. You may use **Form 4070**, Employee’s Report of Tips to Employer, to report your tips to your employer. See the instructions on the back of Form 4070. You must include all tips, including tips not reported to your employer, as wages on your income tax return. You may use the last page of this publication to total your tips for the year. Your employer must withhold income, social security, and Medicare (or railroad retirement) taxes on tips you report. Your employer usually deducts the withholding due on tips from your regular wages.
|
||||
### Instructions
|
||||
|
||||
You must keep sufficient proof to show the amount of your tip income for the year. A daily record of your tip income is considered sufficient proof. Keep a daily record for each workday showing the amount of cash and credit card tips received directly from customers or other employees. Also keep a record of the amount of tips, if any, you paid to other employees through tip sharing, tip pooling or other arrangements, and the names of employees to whom you paid tips. Show the date that each entry is made. This date should be on or near the date you received the tip income. You may use **Form 4070A**, Employee’s Daily Record of Tips, or any other daily record to record your tips. **Reporting Tips to Your Employer.—**If you receive tips that total $20 or more for any month while working for one employer, you must report the tips to your employer. Tips include cash left by customers, tips customers add to credit card charges, and tips you receive from other employees. You must report your tips for any one month by the 10th day of the next month. If the 10th day falls on a Saturday, Sunday, or legal holiday, you may give the report to your employer on the next business day that is not a Saturday, Sunday, or legal holiday. You must report tips that total $20 or more every month regardless of your total wages and tips for the year. You may use **Form 4070**, Employee’s Report of Tips to Employer, to report your tips to your employer. See the instructions on the back of Form 4070. You must include all tips, including tips not reported to your employer, as wages on your income tax return. You may use the last page of this publication to total your tips for the year. Your employer must withhold income, social security, and Medicare (or railroad retirement) taxes on tips you report. Your employer usually deducts the withholding due on tips from your regular wages.
|
||||
|
||||
*(continued on inside of back cover)*
|
||||
|
||||
@@ -48,7 +50,7 @@ tips of directly from customers received other employees paid tips rec’d. entr
|
||||
|
||||
**Page 3**
|
||||
|
||||
27 28 29 30 31 **Subtotals** **from pages** **1, 2, and 3** **Totals**
|
||||
27 28 29 30 31 **Subtotals from pages** **1, 2, and 3** **Totals**
|
||||
|
||||
**1.** Report total cash tips (col. **a**) on Form 4070, line **1.**
|
||||
**2.** Report total credit card tips (col. **b**) on Form 4070, line **2.**
|
||||
@@ -72,11 +74,11 @@ Month or shorter period in which tips were received **4** Net tips (lines **1 +
|
||||
|
||||
forms simpler, we would be happy to hear from you. You can write to the Tax Forms Committee, Western Area Distribution Center, Rancho Cordova, CA 95743-0001. **Purpose.—**Use this form to report tips you receive to your employer. This includes cash tips, tips you receive from other employees, and credit card tips. You must report tips every month regardless of your total wages and tips for the year. However, you do not have to report tips to your employer for any month you received less than $20 in tips while working for that employer. Report tips by the 10th day of the month following the month that you receive them. If the 10th day is a Saturday, Sunday, or legal holiday, report tips by the next day that is not a Saturday, Sunday, or legal holiday. See **Pub. 531**, Reporting Tip Income, for more information. You can get additional copies of **Pub. 1244**, Employee’s Daily Record of Tips and Report to Employer, which contains both Forms 4070A and 4070, by calling 1-800-TAX-FORM (1-800-829-3676).
|
||||
|
||||
**Instructions** *(continued)*
|
||||
<u>Instructions (continued)</u>
|
||||
|
||||
**Unreported Tips.—**If you received tips of $20 or more for any month while working for one employer but did not report them to your employer, you must figure and pay social security and Medicare taxes on the unreported tips when you file your tax return. If you have unreported tips, you **must** use Form 1040 and **Form 4137,** Social Security and Medicare Tax on Unreported Tip Income, to report them. You may **not** use Form 1040A or 1040EZ. Employees subject to the Railroad Retirement Tax Act **cannot** use Form 4137 to pay railroad retirement tax on unreported tips. To get railroad retirement credit, you must report tips to your employer. If you do not report tips to your employer as required, you may be charged a penalty of 50% of the social security and Medicare taxes (or railroad retirement tax) due on the unreported tips unless there was reasonable cause for not reporting them. **Additional Information.—**Get **Pub. 531,** Reporting Tip Income, and Form 4137 for more information on tips. If you are an employee of certain large food or beverage establishments, see Pub. 531 for tip allocation rules. **Recordkeeping.—**If you do not keep a daily record of tips, you must keep other reliable proof of the tip income you received. This proof includes copies of restaurant bills and credit card charges that show amounts customers added as tips. Keep your tip income records for as long as the information on them may be needed in the administration of any Internal Revenue law.
|
||||
|
||||
**Instructions** *(continued)*
|
||||
### Instructions (continued)
|
||||
|
||||
Use this space to total your tips for the year
|
||||
|
||||
|
||||
@@ -1,17 +1,8 @@
|
||||
(e) [Reserved]. For further guidance, see §1.1563-3T(e)(1). Par. 50. Section 1.1563-3T is added to read as follows:
|
||||
<u>§1.1563-3T Rules for determining stock ownership (temporary)</u>.
|
||||
|
||||
(a) through (d)(2)(iii) [Reserved]. For further guidance, see §1.1563-3(a)
|
||||
through (d)(2)(iii). (iv) <u>Statement</u>. If the application of paragraph (d)(2)(ii) or (iii) of §1.1563-3 does not result in a corporation being treated as a component member of only one controlled group of corporations on a December 31, then such corporation will be treated as a component member of only one such group on such date. Such corporation may elect the group in which it is to be included by including on or with its income tax return a statement entitled, “STATEMENT TO ELECT CONTROLLED GROUP PURSUANT TO §1.1563-3T(d)(2)(iv).” The statement must include--
|
||||
|
||||
(A) A description of each of the controlled groups in which the corporation
|
||||
could be included. The description must include the name and employer identification number of each component member of each such group and the stock ownership of the component members of each such group; and
|
||||
|
||||
(B) The following representation: [INSERT NAME AND EMPLOYER
|
||||
IDENTIFICATION NUMBER OF CORPORATION] ELECTS TO BE TREATED AS A COMPONENT MEMBER OF THE [INSERT DESIGNATION OF GROUP].
|
||||
|
||||
(v) <u>Election</u>-- (A) <u>Election filed</u>. An election filed under paragraph (d)(2)(iv) of
|
||||
this section is irrevocable and effective until paragraph (d)(2)(ii) or (iii) of §1.1563-3 applies or until a change in the stock ownership of the corporation results in
|
||||
||||(e) [Reserved]. For further guidance, see §1.1563-3T(e)(1). Par. 50. Section 1.1563-3T is added to read as follows: §1.1563-3T Rules for determining stock ownership (temporary). (a) through (d)(2)(iii) [Reserved]. For further guidance, see §1.1563-3(a)|
|
||||
|---|---|---|---|
|
||||
||through (d)(2)(iii).|||
|
||||
||(iv)|Statement|. If the application of paragraph (d)(2)(ii) or (iii) of §1.1563-3 does not result in a corporation being treated as a component member of only one controlled group of corporations on a December 31, then such corporation will be treated as a component member of only one such group on such date. Such corporation may elect the group in which it is to be included by including on or with its income tax return a statement entitled, “STATEMENT TO ELECT CONTROLLED GROUP PURSUANT TO §1.1563-3T(d)(2)(iv).” The statement must include-- (A) A description of each of the controlled groups in which the corporation could be included. The description must include the name and employer identification number of each component member of each such group and the stock ownership of the component members of each such group; and (B) The following representation: [INSERT NAME AND EMPLOYER IDENTIFICATION NUMBER OF CORPORATION] ELECTS TO BE TREATED AS A COMPONENT MEMBER OF THE [INSERT DESIGNATION OF GROUP].|
|
||||
||(v)|Election|-- (A) Election filed. An election filed under paragraph (d)(2)(iv) of this section is irrevocable and effective until paragraph (d)(2)(ii) or (iii) of §1.1563-3 applies or until a change in the stock ownership of the corporation results in|
|
||||
|
||||
|termination of membership in the controlled group in which such corporation has||
|
||||
|---|---|
|
||||
@@ -29,7 +20,7 @@ this section is irrevocable and effective until paragraph (d)(2)(ii) or (iii) of
|
||||
Federal income tax return (including any amended return filed on or before the due date (including extensions) of such original return) timely filed on or after May 30,
|
||||
|
||||
2006.
|
||||
(2) Expiration date. The applicability of this section will expire on May 26,
|
||||
(2) <u>Expiration date</u>. The applicability of this section will expire on May 26,
|
||||
2009. Par. 51. Section 1.6012-2 is amended by revising paragraph (c) and adding paragraph (k) to read as follows: <u>§1.6012-2 Corporations required to make returns of income</u>.
|
||||
* * * * *
|
||||
(c) [Reserved]. For further guidance, see §1.6012-2T(c).
|
||||
|
||||
@@ -6,20 +6,22 @@
|
||||
|
||||
#### Thermodynamic Properties
|
||||
|
||||
**of**
|
||||
|
||||
®
|
||||
**of** ®
|
||||
|
||||
# Freon 12
|
||||
|
||||
**(R-12)** **Technical Information** **Technical Information**
|
||||
##### (R-12)
|
||||
|
||||
##### Technical Information Technical Information
|
||||
|
||||
**®** **Thermodynamic Properties of Freon 12 Refrigerant** **(R-12)** **SI Units**
|
||||
|
||||
Tables of the thermodynamic **Units** properties of R-12 have been developed and are presented here. P = Pressure in kPa. Absolute This information is based on values calculated using the NIST REFPROP T = Temperature in Celcius Database (McLinden, M.O., Klein,
|
||||
|
||||
S.A., Lemmon, E.W., and Peskin, Vf = Fluid (liquid) specific volume
|
||||
A.P., NIST Standard Reference in cubic meters per kilogram Database 23, NIST thermodynamic and transport properties of Vg = Vapour (gas) specific volume refrigerants and refrigerant in cubic meters per kilogram mixtures – REFPROP version 6.01, Standard Reference Data Program, df and dg = Fluid and Vapour National Institute of Standards and (respectively) densities in Technology, 1998). kilograms per cubic meter
|
||||
A.P., NIST Standard Reference in cubic meters per kilogram Database 23, NIST thermodynamic and transport properties of Vg = Vapour (gas) specific volume refrigerants and refrigerant in cubic meters per kilogram mixtures – REFPROP version 6.01, Standard Reference Data Program, df and dg = Fluid and Vapour National Institute of Standards and (respectively) densities in Technology, 1998).
|
||||
kilograms per cubic meter
|
||||
|
||||
##### H = Enthalpy (kJ/kg)
|
||||
|
||||
##### S = Entropy (kJ/kg.K)
|
||||
@@ -43,9 +45,9 @@ l
|
||||
|
||||
**Freon** **®** **12 Saturation Properties-Temperature Table**
|
||||
|
||||
|Temp|Pressure||Volume|||Density||Enthalpy|||Entropy|Temp|
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
|°C|[kPa]|[m³ Liquid v f|/kg]|Vapour v g|Liquid d f|[kg/m³] Vapour d g|Liquid H f|[kJ/kg] Latent H fg|Vapour H g|Liquid S f|[kJ/K-kg] Vapour S g|°C|
|
||||
|Temp|Pressure||Volume||Density||Enthalpy|||Entropy|Temp|
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
|°C|[kPa]|[m³ Liquid v f|/kg] Vapour v g|[kg/m³ Liquid d f|] Vapour d g|Liquid H f|[kJ/kg] Latent H fg|Vapour H g|Liquid S f|[kJ/K-kg] Vapour S g|°C|
|
||||
|
||||
|-100|1.2|0.0006|10.0000|1679.0|0.100|113.3|192.8|306.1|0.6077|1.7210|-100|
|
||||
|---|---|---|---|---|---|---|---|---|---|---|---|
|
||||
|
||||
Reference in New Issue
Block a user