Compare commits

...
13 Commits
Author SHA1 Message Date
Abimael Martell 841513d3fb fix(extract): pass page lines to per-page markdown conversion (#434)
CI / Test (push) Has been cancelled
CI / Format (push) Has been cancelled
CI / Clippy (push) Has been cancelled
CI / Build (macos-latest) (push) Has been cancelled
CI / Build (ubuntu-latest) (push) Has been cancelled
CI / OCR (macos-latest) (push) Has been cancelled
CI / OCR (ubuntu-latest) (push) Has been cancelled
CI / OCR (windows-latest) (push) Has been cancelled
CI / OCR runtime smoke (push) Has been cancelled
CI / WebAssembly (push) Has been cancelled
* fix(layout): keep relative-valley column detection on table pages

The page_has_table guard predates item claiming: by grouping time the
detected table's items have already left the flow, so a table cannot
fake a gutter in the histogram this guard protects, and the prose gate
inside relative-valley acceptance rejects any residual table-shaped
split. Without the fallback, the prose remainder of a table-bearing
two-column page fell to single-column Y-sorting and its columns
interleaved line by line.

* fix(layout): lower the relative-valley floor to sparse pages

The 100-item floor guarded against shallow histogram dips on sparse
pages, but OCR'd multi-column pages produce few long line-runs (a
two-column French academic page arrives as ~60 items) and were falling
to single-column Y-sorting, weaving their columns line by line. The
prose gate inside relative-valley acceptance is the real defense
against spurious dips; 30 items is enough for it to judge.

* fix(layout): re-guard the ungated XY-cut fallthrough on table pages

Removing page_has_table from the relative-valley condition also
unlocked the XY-cut fallback inside that block, which has no prose
gate — a table page whose valley candidate was just rejected could
take an unvalidated split. The guard is restored on that call
specifically; the relative-valley path keeps its prose-gated access.
Also rewrite the stale dense-page comment for the 30-item floor.
Bench and corpus unchanged (462/884, corpus byte-identical).

* fix(extract): pass page lines to per-page markdown conversion

The per-page extraction path (extract_pages_markdown_mem_impl, used by the
pages API and every --ocr auto run) partitioned each page's rects but
passed an empty slice for PDF lines to markdown conversion, while the
extracted all_lines sat unused. Every table only the line-based detector
finds (text-anchor rule tables, ruled grids) was silently dropped in that
mode, even though the whole-document path emitted it fine.

Partition all_lines per page like rects and pass them through. Regression
test: a rule-anchored table fixture must survive the pages API.
2026-08-19 09:45:21 -07:00
Abimael Martell bac056e801 fix(layout): restore column detection on table pages and sparse pages (#430)
* fix(layout): keep relative-valley column detection on table pages

The page_has_table guard predates item claiming: by grouping time the
detected table's items have already left the flow, so a table cannot
fake a gutter in the histogram this guard protects, and the prose gate
inside relative-valley acceptance rejects any residual table-shaped
split. Without the fallback, the prose remainder of a table-bearing
two-column page fell to single-column Y-sorting and its columns
interleaved line by line.

* fix(layout): lower the relative-valley floor to sparse pages

The 100-item floor guarded against shallow histogram dips on sparse
pages, but OCR'd multi-column pages produce few long line-runs (a
two-column French academic page arrives as ~60 items) and were falling
to single-column Y-sorting, weaving their columns line by line. The
prose gate inside relative-valley acceptance is the real defense
against spurious dips; 30 items is enough for it to judge.

* fix(layout): re-guard the ungated XY-cut fallthrough on table pages

Removing page_has_table from the relative-valley condition also
unlocked the XY-cut fallback inside that block, which has no prose
gate — a table page whose valley candidate was just rejected could
take an unvalidated split. The guard is restored on that call
specifically; the relative-valley path keeps its prose-gated access.
Also rewrite the stale dense-page comment for the 30-item floor.
Bench and corpus unchanged (462/884, corpus byte-identical).
2026-08-18 17:06:21 -07:00
Abimael Martell 0027b048ce fix(tables): reject parallel-prose grids on all unsplit pages (#429)
* fix(tables): reject parallel-prose grids on all unsplit pages

The body-font heuristic pass projects multi-column text pages onto
table grids: on a two-column reference section, every line pair across
the gutter looks like a row with two X-clusters, and the page is
emitted as a woven table. The parallel-prose rejector — which requires
transition evidence (unterminated cells flowing into lowercase starts
in the same column), not mere cell length — was gated to chart pages;
it now runs for every unsplit page.

A compact header row still blocks the rejection, except when cross-row
prose continuations outnumber the rows: no genuine table produces a
continuation on average in every row, so the 'header' there is just
short line fragments atop parallel prose columns.

Band-split retries stay exempt: they exist for tables that only
assemble after recombining bands.

* fix(tables): header bypass requires continuations to strictly outnumber rows

Align the code with its stated rule (the comparison allowed the bypass
at exact equality) and add the dedicated positive-path test: a compact
header atop parallel prose columns whose cross-row continuations
outnumber the rows is flagged as parallel prose. Bench unchanged.
2026-08-18 15:58:20 -07:00
Abimael Martell fb45d37dfe feat(markdown): strip page-edge furniture on short documents (#427)
* feat(markdown): strip page-edge furniture on short documents

Running headers, footer credits, and page indicators are proven by
cross-page repetition, which needs three-plus pages; on one- and
two-page documents they survived untouched. Add a positional and
typographic classifier for that gap, dispatched explicitly by document
length (strip_header_footer_lines), and consolidate all header/footer
policy — repetition classifier, edge classifier, shared predicates —
into a new markdown::furniture module.

The edge classifier only strips where evidence is strong: the outermost
block (<= 2 lines) per page edge, vertically isolated from the body by
1.8x the median leading, set strictly smaller than body text, short,
and non-structural. Guards, each earned by a measured false positive:

- same-size blocks stay (section headings, affiliations, continuation
  paragraphs merely sit at the margin)
- digit- or (digit)-led blocks stay (footnotes, legal enumerations)
- three-plus item clusters on a baseline stay (figure label rows,
  numeric rows of undetected borderless tables)
- number-only blocks set above body size stay (cover years); at or
  below body size they strip on isolation alone (page indicators)
- pages under 8 lines and blocks over 90 chars are never touched

* fix(furniture): address review findings on edge and repetition guards

- Number-only blocks strip only on the full page-indicator shape: one
  text run, <= 20 chars, set no larger than body text. Multi-cell
  numeric rows are form or table data and stay.
- Marker-led guard extends to symbol footnotes ('† ...') and single
  lowercase-letter markers ('a See ...'); capitalized one-letter words
  ('A Publication ...') remain strippable prose.
- The repetition classifier now checks structural shape on the original
  text: normalization strips leading digits, so numbered headings like
  '1. Introduction' were losing their protection along with the number.
- Restore the repetition classifier's doc comment to strip_repeated_lines;
  the module move had left it fused onto the dispatcher.

* fix(furniture): extend structural protection to removal passes, accept (letter) markers

- The repetition classifier's structural check ran only at candidate
  collection; a structural line sharing a normalized key with a
  non-structural candidate (normalization strips the heading's leading
  digit) was still stripped by the removal passes. All three passes —
  individual, Y-band, and sibling propagation — now recheck the
  original text.
- The marker-led guard accepts parenthesized-letter enumerations
  ('(a) See ...'), common in legal and scholarly documents.

* fix(furniture): count characters in the repetition floor, protect (digit) enumerations

- The ten-character candidate minimum counted UTF-8 bytes, so short
  non-ASCII headings (a six-character CJK section head is 18 bytes)
  slipped past the floor and were stripped as repeated furniture. Count
  characters in both frequency loops; a Japanese corpus doc recovers a
  repeated section heading.
- is_structural_line recognizes parenthesized numbered markers
  ('(1) Sign and date ...'), protecting repeated enumerations in the
  repetition classifier the same way bare numbered lines already were.

* fix(furniture): per-member band protection, close parenthesized markers

- The Y-band removal pass inserted whole bands after checking only the
  coalesced text, so a structural sibling banded with a plain fragment
  was stripped on every page after the first. Each member is now checked
  individually, matching the sibling-propagation pass.
- A parenthesized marker must be a short digit/lowercase token closed by
  ')' — '(1)', '(a)', '(iv)'. Parenthetical prose footers
  ('(all amounts in thousands)') strip again.

* fix(furniture): accept Unicode lowercase in parenthesized markers

'(α)'-style footnote markers are lowercase too; the paren-marker check
was ASCII-only while the bare single-letter guard already accepted
Unicode. Token extraction and validation now use Unicode alphanumeric
and lowercase classes, keeping the three-character and closing-paren
bounds.

* fix(furniture): whole-row structural protection, Unicode digits, page floor

- A Y-band holding a structural member is one physical row of content:
  a precomputed protected-band set now guards ALL removal paths
  (individual, band, propagation), so a heading's row-mates are never
  half-removed. Replaces the per-member filter, which mangled rows.
- Every digit check in the marker and structural guards uses Unicode
  is_numeric(): '(١)' earns the same protection as '(1)'.
- The edge classifier requires strictly more than 8 lines per page —
  at the floor, the two edge blocks could remove half the content.

* fix(furniture): Unicode page-number normalization, shared marker predicate, boundary-safe row protection

- normalize_for_comparison trims Unicode numerics, so headers whose page
  numbers use non-ASCII digits normalize to one key across pages and the
  repetition classifier can see them.
- The parenthesized-marker rule is one shared predicate
  (starts_with_paren_marker) used by both the edge classifier and
  is_structural_line: repeated '(a) See ...' annotations survive on
  three-plus-page documents too.
- Whole-row structural protection extends to neighboring quantization
  buckets: same-row fragments 0.02pt apart can straddle a rounding
  boundary, and the boundary must never split a row's protection.

* refactor(furniture): single source for Y-band coalescing

The sort-join-normalize sequence was triplicated across the band
frequency build and both band passes, with each copy free to drift.
One helper (coalesced_band) now produces the sorted members, the
coalesced row text, and its normalized comparison key; each site keeps
only its own guards. Behavior-neutral: full suite unchanged.
2026-08-18 14:41:12 -07:00
Abimael Martell 5eb6a13860 feat(layout): banded region segmentation for vertically-changing column layouts (#426)
* feat(layout): banded region segmentation for vertically-changing column layouts

Pages whose column structure changes down the page (newsletter bands,
figure-split flows, a three-column strip inside a two-column page) cannot
be represented by one full-height column set: the projection profile
either finds nothing and Y-interleaves the columns, or weaves the odd
band's text into the wrong buckets.

- Split pages into horizontal bands at full-width whitespace gaps (wide
  spanning items are excluded from occupancy — separators sit inside the
  very gaps being sought), detect columns independently per band, and
  re-merge consecutive bands with matching gutters across empty gaps so
  figure floats keep flowing down their columns while headline-separated
  bands stay independent.
- Engage only on contradicting evidence: a prose-validated band whose
  column count differs from the page-level structure. Pages the flat
  column model already explains keep their current ordering.
- Read short prose columns (5-14 lines, >=60% width fill on >=60% of
  lines in every column) as newspaper instead of Y-interleaving them as
  tabular. Kept as a standalone reading-order refinement so the table
  pipeline's is_newspaper_layout veto is unaffected.
- Split ordering entry points: order_multi_column_region keeps every
  page-level defense; order_validated_band (banded planner only) trusts
  validated bands, skipping line-count minimums and straggler splitting
  that would misfire on Y-cohesive bands.

* docs(layout): record why the band wide-item test is per-item

Assembling same-baseline fragments into runs before the wide test was
implemented and measured against the reading-order benchmark: word-gap
and gutter-gap distributions overlap in real documents, so assembled
runs fused narrow-guttered column pairs into page-wide lines, emptied
the occupancy, and disengaged banding on pages it rescues — a measured
regression with no measured win. Keep the per-item test (a fragmented
separator can suppress a cut, which only misses an engagement) and
document the boundary for future attempts.

* fix(layout): keep figure placeholders out of band whitespace probes

An image placeholder sitting between two matching column bands is the
very figure float whose flow-through the band merge exists for, yet it
read as content twice: its glyph box filled the occupancy gap (blocking
the cut) and the merge probe counted it as separator content (blocking
the merge). Both probes now see text layout items only.

* fix(layout): anchor band-merge matching on the founding band's columns

The merge comparison ran against the widened union, whose gutter is the
intersection of its constituents' gutters. Across a chain of
one-directionally drifting bands that intersection can walk past
GUTTER_TOLERANCE and reject a band identical to the run's own first
member. Compare candidates against the founding band's raw columns
instead: the run's column system is defined by its founder, so
drift can no longer accumulate in either direction. Union widening is
kept for item bucketing only.

* test(layout): differential coverage for the band-merge anchor rule

- banded_layout_rejects_creeping_drift: a band within tolerance of the
  moving union but 36pt from the founder must not join the run — the
  case the anchor rule exists for; the pre-anchor union admitted it.
- Reword the founder-anchor chain test as the invariant lock it is.
- Note at the merge site why a reject-overlapping-unions guard is
  unimplementable: detect_columns returns contiguous partitions whose
  adjacent regions share boundary coordinates, so the check degenerates
  to exact-equality matching and rejects every legitimate merge; the
  boundary-disagreement zone is bounded by GUTTER_TOLERANCE and split
  proportionally by greatest-overlap bucketing.
2026-08-18 11:00:34 -07:00
Abimael Martell 74ebce430c fix(layout): accept figure-diluted prose columns via a sustained full-line run (#417)
* fix(layout): accept figure-diluted prose columns via a sustained full-line run

The columns_have_prose gate (added against table/TOC/checklist
false-splits in relative-valley detection) requires >=40% of a
column's lines to span >=45% of its width. A genuine prose column
hosting a figure and caption dilutes that global ratio below the bar
— measured 0.38 on a two-column research page — so the valley is
rejected, the page falls back to single-column, and same-baseline
items from both columns merge across the gutter into woven sentences.

Accept a column that contains a sustained paragraph block instead: at
least 6 consecutive full-width lines. The scattered layouts the gate
exists to reject cannot produce an unbroken run of full lines, and
the other guards (column width, minimum lines, items-per-line) still
apply. Tests pin both directions; regression corpus is unchanged.

* docs(layout): restore columns_have_prose rustdoc displaced by hoisted const

The hoisted LINE_FILL_THRESHOLD landed between the gate's rustdoc block
and the items that followed, absorbing the function's documentation onto
the constant. The docs return to the function, extended to cover the
sustained-run acceptance path.
2026-08-17 21:38:52 -07:00
Abimael Martell cf4e42b91c perf(ocr): adaptive detection escalation and parallel staged engine (#413)
* perf(ocr): adaptive detection escalation and parallel staged engine

Three structural limits in the OCR engine, found benchmarking against
other parsers, each addressed here:

1. Detection resolution. The backend downscales every page so its
   longest side fits 960px before text detection. A broadsheet render
   loses over 80% of its resolution, making body print ~2px tall —
   invisible to the detection model regardless of render DPI, since
   recognition crops come from the full-resolution render but detection
   never sees the text. Detection now runs at 960 and escalates to 2560
   only when the standard pass proves the page is dense fine print
   (page was downscaled, >=80 regions, median region height under 13px
   at detection scale — measured margins separate gaining pages at
   9.9-12.0px from non-gaining pages at 12.2px+). Renders more than
   twice the standard limit skip the doomed standard pass entirely.

2. Session serialization. oar-ocr guards each ONNX session behind a
   mutex (pool size is fixed at one), so concurrent predict calls
   serialize and added intra-op threads go to waste — measured, 12
   threads run 2.4x slower than 4. The engine now builds one worker
   (detector + recognizer sessions) per cores/4 capped at 3, each with
   2 intra-op threads, and fans pages across them via a sized rayon
   pool. A new OcrEngine::preferred_page_concurrency hint lets the
   pipeline size page batches at three waves per worker, replacing the
   fixed 4-page chunks that starved workers at batch barriers.

3. Escalation double-cost. The engine is restaged onto oar-ocr's own
   public components — detect, sort (sort_quad_boxes), crop
   (TextCroppingProcessor, rotation-aware), recognize — as separate
   calls instead of the combined predict, with output verified
   byte-identical across an 8-document corpus. An escalated page now
   reruns only detection; recognition runs once, on the final region
   set. Recognition stays one crop per call: batching pads every crop
   to the widest member, and even width-sorted batches measured 2-3x
   slower on CPU (ONNX Runtime re-plans kernels per input shape).

Escalation lifts ground-truth word recall on dense fine print from
0.678 to 0.895 with no change on ordinary scans, which keep first-pass
speed and quality; a 15-page scan drops from 9.4s to 8.0s end to end.

* fix(ocr): address review — even-count median, escalation fallback, orientation note

- median_detection_height averages the two middle values for even-count
  region lists instead of taking the upper one, so borderline pages
  near the 13px escalation threshold are not skewed away from it.
- Direct escalated detection (renders over twice the standard limit)
  falls back to the standard pass on error, matching the adaptive
  branch, instead of failing the page outright.
- Document why OcrSpan::orientation_degrees is None: the combined
  pipeline's angle came from the text-line-orientation classifier,
  a model this engine never loads — it was structurally None before
  the staged split too; region rotation is carried by the polygon.

* fix(ocr): return standard boxes directly after direct-escalation failure

Falling through to the adaptive branch would re-invoke the escalated
detector that just failed — repeating an OOM on a large dense page —
before settling on the standard boxes anyway.

* fix(ocr): restore unclip_ratio 2.0 and recalibrate escalation for it

Supplying an explicit TextDetectionConfig suppresses OAROCR's
general-text-type overrides, so the staged detector was silently
running unclip 1.5 (the struct default) where the pre-split pipeline
ran 2.0 — tighter box expansion that risks clipping edge glyphs. Pin
unclip 2.0 explicitly and document that every override-set field must
be pinned when passing an explicit config.

The escalation threshold is coupled to unclip (expansion inflates
measured region heights ~15-20%), so it is recalibrated from fresh
measurements at 2.0: gain pages sit at 12.0-14.2px with 144+ regions,
the nearest non-gaining page above the region gate at 15.7px —
threshold moves 13 -> 15. A trace-level log records each page's
standard-pass median to keep future recalibration cheap. Trigger
tests updated to the measured values; quality re-verified end to end
(GT scores match pre-split main exactly on non-escalated docs;
escalation gains intact).
2026-08-17 20:46:22 -07:00
Abimael Martell d390d402a5 fix(detector): route masthead-over-scan pages to OCR (#412)
A full-page scan carrying a few text ops of genuine chrome (a newspaper
masthead, stamp, or date line) defeated the alphanum_low scan heuristic
— the chrome is real, diverse, decodable text — and cleared the bare
min_text_ops_per_page floor (3), so page_ocr_signals called the page
native while whole-document classification, whose pages_with_text floor
for image-bearing pages is max(10), correctly called it scanned. The
per-page and document-level paths silently disagreed (the drift #227
exists to prevent), and OCR auto mode returned almost nothing for a
third of a fully scanned document.

Align both per-page sites on classification's own floor: a template-
image page below min_text_ops_per_page.max(10) text ops routes to OCR
regardless of byte diversity, in page_ocr_signals and in the Mixed-type
routing clause of detect_from_document.

Tests pin the masthead case (form-wrapped full-page scan, ~4 diverse
chrome ops must route) and the letterhead counter-case (a real text
page over a background image must stay native).
2026-08-17 17:33:47 -07:00
Abimael Martell 84789459b1 feat(extractor): stamp items with the font family name, not the resource tag (#415)
* feat(extractor): stamp items with the font family name, not the resource tag

TextItem::font carried the page's font resource name ("F2", "T22") —
an arbitrary per-page tag — even though both content-stream parsers
already resolve the /BaseFont family name for bold/italic detection at
every item-creation site. Stamp that resolved family name instead
("ABCDEF+CMMI10", "Courier"), from a single item_font_name helper so
the two parsers cannot drift.

One deliberate carve-out, documented on the helper: resource names
using Distiller's CID convention (C2_0, C0_1) are kept as-is, because
text_utils::is_cid_font keys on that prefix for micro-gap joining and
the family name carries no CID marker to replace it.

Consumers that match on font names start working against real names:

- Code detection (is_monospace_font) previously never fired against
  opaque resource tags. It now does — so line classification also moves
  from any-item matching to a majority-by-characters rule
  (line_is_monospace): code lines are wholly monospace, while a lone
  URL or identifier styled in a mono face inside a prose line must not
  fence the surrounding sentence.
- Heading/body font grouping now merges resource aliases of the same
  family instead of treating them as distinct fonts.
- Positioned-item output (--items-json and the bindings) reports real
  face names.

Regression corpus: code-heavy manuals improve substantially (assembly
and C snippets previously emitted as prose now fence with line
structure preserved); remaining churn reviewed as improvements.

* fix(markdown): address review of font-name consumers

- Monotype is a foundry prefix on proportional faces (Monotype Corsiva,
  Monotype Garamond); it must not satisfy is_monospace_font's generic
  "mono" token. Regression tests pin both directions.
- Flush the pending code block before inserting a positioned table or
  image, so a block that falls between two code lines cannot be emitted
  ahead of code that precedes it in reading order; a code line after
  the block reopens a new fence naturally.

* fix(markdown): emit sub-3-char mono fragments as plain text, not fences

A lone registered-trademark glyph or stray bullet set in a mono face is
not code; a fenced block containing one character reads as noise.

* fix(markdown): font-based code blocks open only at paragraph boundaries

HTML-to-PDF producers smear an inline code literal's mono style across
whole wrapped lines, so a prose paragraph can alternate body and mono
fonts line by line. Fencing those lines cut sentences in three: prose
head, fenced middle, prose tail. A mono-set line that continues an open
prose paragraph now stays prose; font-based blocks open at paragraph
boundaries (or continue an open block), and struct-tree Code roles are
honored unconditionally.

* refactor(markdown): drop paragraph-flush branch made unreachable by the boundary gate

The enclosing guard proves in_paragraph is false, so the nested flush
could never run; the guard and mono check collapse into one condition.
2026-08-17 17:21:24 -07:00
Abimael Martell 06a9bab6b3 fix(release): repair ARM64 package builds (#411)
Define the ARMv8 assembler macro for legacy AArch64 cross-compilers used by the npm and PyPI release matrices, and keep independent targets running when one fails.
2026-08-17 10:52:32 -07:00
Abimael Martell ca6d667146 chore(release): bump package versions to 1.15.0 (#410)
Synchronize the Rust, Python, Node, platform, and WebAssembly package versions for the OCR release.
2026-08-17 10:39:17 -07:00
Abimael Martell a4b1c714e8 chore(ocr): polish launch readiness (#409)
Polish the selective OCR runtime documentation, packaging guidance, and cross-language launch smoke coverage.
2026-08-17 10:30:29 -07:00
Abimael Martell 0f9b5fa1c6 feat(bindings): expose OCR in Node and Python (#405)
* feat(bindings): expose selective OCR

* fix(bindings): address review feedback
2026-08-17 09:25:56 -07:00
38 changed files with 6418 additions and 610 deletions
+74
View File
@@ -134,10 +134,21 @@ jobs:
with:
toolchain: stable
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
with:
bun-version: latest
- name: Cache cargo
uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
key: ocr-runtime
workspaces: |
. -> target
napi -> target
- name: Install PDFium
shell: bash
@@ -169,18 +180,31 @@ jobs:
test -n "$ort_path"
echo "ORT_DYLIB_PATH=$ort_path" >> "$GITHUB_ENV"
- name: Configure isolated model cache
shell: bash
run: echo "PDF_INSPECTOR_MODEL_CACHE=$RUNNER_TEMP/pdf-inspector-models" >> "$GITHUB_ENV"
- name: Build OCR CLI
run: cargo build --features ocr --bin pdf2md
- name: Test PDFium runtime
run: cargo test --features ocr --test local_render_tests
- name: Provision OCR model cache
shell: bash
run: |
target/debug/pdf2md \
tests/fixtures/scan_with_native_header_text.pdf \
--ocr force \
--json > /dev/null
- name: Run OCR CLI
shell: bash
run: |
target/debug/pdf2md \
tests/fixtures/scan_with_native_header_text.pdf \
--ocr auto \
--ocr-offline \
--json > "$RUNNER_TEMP/ocr-result.json"
- name: Validate OCR JSON contract
@@ -202,6 +226,56 @@ jobs:
assert "layout_ms" not in result["pages"][0]["timings"]
PY
- name: Run OCR launch smoke set
shell: bash
run: |
export PDF_INSPECTOR_OCR_TEST_MODELS="$PDF_INSPECTOR_MODEL_CACHE/pp-ocrv6-small/oar-ocr-v0.7.0"
cargo test --features ocr --test ocr_tests -- --nocapture
- name: Build Node binding
working-directory: napi
run: |
bun install --frozen-lockfile
bunx napi build --platform --release
- name: Run Node OCR binding
shell: bash
run: |
node --input-type=module - <<'JS'
import { readFileSync } from 'node:fs'
import { processPdfWithOcr } from './napi/index.js'
const pdf = readFileSync('tests/fixtures/scan_with_native_header_text.pdf')
const result = await processPdfWithOcr(pdf, { offline: true })
if (JSON.stringify(result.pagesRoutedToOcr) !== '[1]') throw new Error('unexpected OCR route')
if (result.pagesRecommendingHosted.length !== 0) throw new Error('unexpected hosted recommendation')
if (!['Ocr', 'Fused'].includes(result.pages[0].provenance.source)) throw new Error('unexpected source')
if (!result.pages[0].markdown.trim()) throw new Error('empty OCR markdown')
JS
- name: Build and install Python binding
shell: bash
run: |
python -m pip install 'maturin>=1,<2'
maturin build --release --out "$RUNNER_TEMP/python-wheels"
python -m pip install "$RUNNER_TEMP"/python-wheels/*.whl
- name: Run Python OCR binding
shell: bash
run: |
python - <<'PY'
import pdf_inspector
result = pdf_inspector.process_pdf_with_ocr(
"tests/fixtures/scan_with_native_header_text.pdf",
offline=True,
)
assert result.pages_routed_to_ocr == [1]
assert result.pages_recommending_hosted == []
assert result.pages[0].provenance.source in {"ocr", "fused"}
assert result.pages[0].markdown.strip()
PY
wasm:
name: WebAssembly
runs-on: ubuntu-latest
+5
View File
@@ -86,12 +86,14 @@ jobs:
name: Build ${{ matrix.target }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
target: x86_64-unknown-linux-gnu
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
docker-options: -e CFLAGS_aarch64_unknown_linux_gnu=-D__ARM_ARCH=8
# macos-13 was retired by GitHub; macos-15-intel is the remaining
# Intel runner label (available through 2027).
- os: macos-15-intel
@@ -113,6 +115,9 @@ jobs:
target: ${{ matrix.target }}
args: --release --out dist
manylinux: auto
# The manylinux AArch64 GCC omits this macro while preprocessing
# ring's assembly. AArch64 is ARMv8 by definition.
docker-options: ${{ matrix.docker-options }}
- name: Upload wheel
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+6
View File
@@ -60,6 +60,7 @@ jobs:
name: Build ${{ matrix.target }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-latest
@@ -70,6 +71,7 @@ jobs:
- os: ubuntu-latest
target: aarch64-unknown-linux-gnu
build-flags: --use-napi-cross
cflags: -D__ARM_ARCH=8
- os: ubuntu-latest
target: x86_64-unknown-linux-musl
build-flags: -x
@@ -126,6 +128,10 @@ jobs:
- name: Build native addon
working-directory: napi
env:
# napi-cross's old AArch64 GCC omits this predefined macro while
# preprocessing ring's assembly. AArch64 is ARMv8 by definition.
CFLAGS_aarch64_unknown_linux_gnu: ${{ matrix.cflags }}
run: bunx napi build --platform --release --target ${{ matrix.target }} ${{ matrix.build-flags }}
- name: Upload native binary
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "pdf-inspector"
version = "1.14.2"
version = "1.15.0"
edition = "2021"
autobins = false
authors = ["Firecrawl Team"]
@@ -80,7 +80,7 @@ tempfile = "3.3"
[features]
default = []
python = ["pyo3"]
python = ["pyo3", "ocr"]
vision = []
model-cache = ["vision", "dep:dirs", "dep:fs2", "dep:sha2", "dep:windows-sys"]
model-download = ["model-cache", "dep:ureq"]
+20 -9
View File
@@ -18,10 +18,10 @@ Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in
- **CID font support** — ToUnicode CMap decoding for Type0/Identity-H fonts, UTF-16BE, UTF-8, and Latin-1 encodings.
- **Multi-column layout** — Automatic detection of newspaper-style columns, sequential reading order, and RTL text support.
- **Encoding issue detection** — Automatically flags broken font encodings so callers can fall back to OCR.
- **Optional OCR** — An opt-in Rust and CLI feature selectively renders only pages that need OCR, runs PP-OCRv6 Small locally, and preserves per-page provenance and hosted-fallback recommendations.
- **Selective OCR** — Rust, CLI, Python, and Node can render only pages that need OCR, run PP-OCRv6 Small locally, and preserve per-page provenance and hosted-fallback recommendations.
- **Single document load** — The document is parsed once and shared between detection and extraction, avoiding redundant I/O.
- **Browser WebAssembly** — Run the same Rust parser locally in browsers and Web Workers, with embedded CMaps and no server round trip.
- **Lightweight by default** — The default build is pure Rust with no ML models or external services. PDFium, ONNX Runtime, and OCR models are added only when the native `ocr` feature is selected and remain external runtime artifacts.
- **Lightweight by default** — The default Rust and browser builds remain pure extraction. Native Python and Node packages include the OCR integration, but PDFium, ONNX Runtime, and model files remain external and are touched only when a page is routed to OCR.
## Benchmark
@@ -48,8 +48,7 @@ Use the [paired benchmark harness](docs/benchmarking.md) to compare two local bu
### Python
```bash
pip install maturin
maturin develop --release
pip install pdf-inspector
```
```python
@@ -58,6 +57,10 @@ import pdf_inspector
result = pdf_inspector.process_pdf("document.pdf")
print(result.pdf_type) # "text_based", "scanned", "image_based", "mixed"
print(result.markdown) # Markdown string or None
# Selective OCR; clean text PDFs do not load the external OCR runtime.
ocr = pdf_inspector.process_pdf_with_ocr("document.pdf")
print(ocr.pages_routed_to_ocr)
```
> Full API reference: [docs/python.md](docs/python.md)
@@ -70,11 +73,15 @@ npm install @firecrawl/pdf-inspector
```javascript
import { readFileSync } from 'fs';
import { processPdf, classifyPdf } from '@firecrawl/pdf-inspector';
import { processPdf, processPdfWithOcr } from '@firecrawl/pdf-inspector';
const result = processPdf(readFileSync('document.pdf'));
const pdf = readFileSync('document.pdf');
const result = processPdf(pdf);
console.log(result.pdfType); // "TextBased", "Scanned", "ImageBased", "Mixed"
console.log(result.markdown); // Markdown string or null
const ocr = await processPdfWithOcr(pdf); // selective OCR, off the event loop
console.log(ocr.pagesRoutedToOcr);
```
> Full API reference: [napi/README.md](napi/README.md)
@@ -161,7 +168,7 @@ detect-pdf document.pdf --json
detect-pdf document.pdf --analyze --json
```
OCR is a separate native CLI build and does not change the default package:
Rust and CLI consumers opt into OCR at build time:
```bash
cargo install pdf-inspector --features ocr --bin pdf2md
@@ -171,8 +178,12 @@ PDFIUM_LIB_PATH=/path/to/libpdfium ORT_DYLIB_PATH=/path/to/libonnxruntime \
The OCR JSON envelope is versioned and reports routed pages, per-page source
and confidence, warnings, and pages recommended for the hosted document
pipeline. See the [Rust API guide](docs/rust-api.md#complete-ocr-api) for model
cache and offline configuration.
pipeline. Native Python and Node packages expose the same pipeline without a
source-build feature. All native entry points still require separately
installed PDFium and ONNX Runtime libraries only when OCR is routed. See the
[OCR runtime setup guide](docs/ocr-runtime.md) for pinned downloads, platform
support, model-cache behavior, and hosted-fallback integration. See the
[Rust API guide](docs/rust-api.md#complete-ocr-api) for lower-level controls.
From a source checkout, use `cargo run --bin pdf2md -- document.pdf` or `cargo run --bin detect-pdf -- document.pdf` instead.
+97
View File
@@ -0,0 +1,97 @@
# OCR runtime setup
Selective OCR is available from the Rust library and CLI, Python, and Node.js.
Clean native-text documents do not load an OCR dependency or download a model.
When `auto` routes at least one page, the process needs PDFium, ONNX Runtime,
and the pinned PP-OCRv6 Small model set.
## Validated versions
The reproducible runtime path uses these builds:
- [Firecrawl PDFium `native-v7988`](https://github.com/firecrawl/pdfium-rs/releases/tag/native-v7988),
containing PDFium `153.0.7988.0`
- [ONNX Runtime `1.27.0`](https://github.com/microsoft/onnxruntime/releases/tag/v1.27.0)
- PP-OCRv6 Small artifact revision `oar-ocr-v0.7.0`
Use these versions for the reproducible path. Other compatible shared-library
builds may work, but are not part of the release smoke test.
## Install the shared libraries
Download and extract the matching archives:
| Platform | PDFium asset | ONNX Runtime asset |
|---|---|---|
| Linux x64 | `firecrawl-pdfium-linux-x64.tgz` | `onnxruntime-linux-x64-1.27.0.tgz` |
| Linux ARM64 | `firecrawl-pdfium-linux-arm64.tgz` | `onnxruntime-linux-aarch64-1.27.0.tgz` |
| macOS Apple Silicon | `firecrawl-pdfium-mac-arm64.tgz` | `onnxruntime-osx-arm64-1.27.0.tgz` |
| Windows x64 | `firecrawl-pdfium-win-x64.tgz` | `onnxruntime-win-x64-1.27.0.zip` |
The PDFium release publishes `SHA256SUMS`, build provenance, license files,
and an SPDX document for every platform archive. GitHub publishes a SHA-256
digest with each ONNX Runtime asset.
Point pdf-inspector at the extracted shared libraries when they are not on the
platform library search path:
```bash
export PDFIUM_LIB_PATH=/absolute/path/to/libpdfium.so
export ORT_DYLIB_PATH=/absolute/path/to/libonnxruntime.so
pdf2md scan.pdf --ocr auto --json
```
On macOS the filenames end in `.dylib`. On Windows, use PowerShell and point
the variables at `pdfium.dll` and `onnxruntime.dll`:
```powershell
$env:PDFIUM_LIB_PATH = "C:\absolute\path\to\pdfium.dll"
$env:ORT_DYLIB_PATH = "C:\absolute\path\to\onnxruntime.dll"
pdf2md scan.pdf --ocr auto --json
```
The native extraction packages also support platforms without these exact
runtime assets. In particular, the Python package has an Intel macOS wheel,
but ONNX Runtime 1.27.0 does not publish an Intel macOS archive; local OCR on
that target requires a compatible custom ONNX Runtime build.
The full OCR path is exercised end to end on Linux x64 in CI. macOS and
Windows compile and run the feature's platform-independent tests, while their
external-runtime paths should be treated as preview until equivalent smoke
jobs are added.
## Model cache and offline mode
The first routed page downloads and SHA-256-verifies three pinned artifacts:
the detection model, recognition model, and character dictionary. Together
they are about 31 MB. They are stored below the platform cache directory.
Set `PDF_INSPECTOR_MODEL_CACHE` to choose a managed cache root.
For hermetic deployments, populate the model directory ahead of time and use
the language-specific offline option:
- CLI: `--ocr-offline --ocr-model-dir /models/pp-ocrv6-small`
- Rust: `ModelDownloadPolicy::Offline` with `OcrOptions::model_directory`
- Python: `offline=True, model_directory="/models/pp-ocrv6-small"`
- Node.js: `offline: true, modelDirectory: "/models/pp-ocrv6-small"`
The model artifacts come from
[`GreatV/oar-ocr`](https://github.com/GreatV/oar-ocr/releases/tag/v0.7.0),
whose OCR implementation and upstream PaddleOCR project use Apache-2.0
licensing. Models are downloaded at runtime and are not embedded in any
pdf-inspector package.
## Hosted fallback boundary
`pages_recommending_hosted` is available after the local pipeline completes.
It marks pages whose completed OCR result is empty, low-confidence, or still
appears incomplete.
Setup and execution failures happen before that result exists. A missing or
incompatible PDFium/ONNX Runtime library, failed model acquisition, or OCR
execution error is returned as an error. A downstream integration that has a
hosted parser should catch that error and route the document to the hosted
path. This keeps deployment problems distinct from page-quality judgments.
In `auto`, documents with no routed pages return successfully without touching
PDFium, ONNX Runtime, the model cache, or the network.
+65 -2
View File
@@ -1,6 +1,6 @@
# pdf-inspector
Fast PDF classification and text extraction. Detects whether a PDF is text-based or scanned, extracts text with position awareness, and converts to clean Markdown — all without OCR. Python bindings via [PyO3](https://pyo3.rs) for the [pdf-inspector](https://github.com/firecrawl/pdf-inspector) Rust library.
Fast PDF classification, text extraction, and selective OCR. Detects whether a PDF is text-based or scanned, extracts text with position awareness, and converts clean native and OCR results to Markdown. Python bindings via [PyO3](https://pyo3.rs) for the [pdf-inspector](https://github.com/firecrawl/pdf-inspector) Rust library.
Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in under 200ms, skipping expensive OCR services for the ~54% of PDFs that don't need them.
@@ -10,7 +10,8 @@ Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in
- **Markdown conversion** — headings, lists, code blocks, bold/italic, URL linking, and dual-mode table detection (PDF drawing ops + text-alignment heuristics).
- **Layout-aware extraction** — multi-column reading order, position and font info per text item, RTL support.
- **Robust text decoding** — CID/Type0 fonts via ToUnicode CMaps, plus automatic flagging of broken encodings so callers can fall back to OCR.
- **Lightweight** — native Rust core, no ML models, no external services; ships type stubs.
- **Selective OCR** — `auto` routes only pages rejected by native extraction; `force` OCRs every selected page; `off` keeps the result/provenance contract without external runtime work.
- **External artifacts** — the wheel embeds no OCR models, PDFium, or ONNX Runtime; clean `auto` requests never load or download them.
## Benchmark
@@ -39,6 +40,14 @@ pip install maturin
maturin develop --release
```
OCR calls that route work require compatible PDFium and ONNX Runtime shared
libraries. Set `PDFIUM_LIB_PATH` and `ORT_DYLIB_PATH` when they are not on the
platform library search path. The pinned OCR model set is downloaded and
checksum-verified on the first routed page; use `offline=True` with a warm
cache or `model_directory` to prohibit network access. See the
[OCR runtime setup guide](https://github.com/firecrawl/pdf-inspector/blob/main/docs/ocr-runtime.md)
for pinned downloads, supported platforms, and hosted-fallback behavior.
## Usage
```python
@@ -81,6 +90,19 @@ for page in result.pages:
# Restrict to specific 0-indexed pages (preserves caller order)
result = pdf_inspector.extract_pages_markdown("document.pdf", pages=[0, 2])
# One-call selective OCR. This releases the GIL while processing.
ocr = pdf_inspector.process_pdf_with_ocr("document.pdf")
for page in ocr.pages:
print(page.page_number, page.provenance.source)
# Restrict OCR processing to 1-indexed PDF pages and prohibit downloads.
ocr = pdf_inspector.process_pdf_with_ocr(
"document.pdf",
page_numbers=[1, 3],
model_directory="/opt/models/pp-ocrv6-small",
offline=True,
)
# Structure-tree elements from tagged PDFs (empty list when untagged).
# Pages are 1-indexed to match TextItem.page, so (page, mcid) joins directly
# against extract_text_with_positions — e.g. to recover real heading levels:
@@ -99,6 +121,8 @@ headings = [
|---|---|
| `process_pdf(path, pages=None)` | Full processing (detect + extract + markdown) |
| `process_pdf_bytes(data, pages=None)` | Full processing from bytes |
| `process_pdf_with_ocr(path, **options)` | Native extraction + selective OCR with provenance |
| `process_pdf_with_ocr_bytes(data, **options)` | Native extraction + selective OCR from bytes |
| `detect_pdf(path)` | Fast detection only (returns PdfResult) |
| `detect_pdf_bytes(data)` | Fast detection from bytes |
| `classify_pdf(path)` | Lightweight classification (returns PdfClassification) |
@@ -137,6 +161,45 @@ class PageOcrReasons: # per-page OCR diagnostics
page: int # 1-indexed
reasons: list[str] # machine-readable reason identifiers
class OcrModelIdentity:
name: str # model family/name
revision: str # immutable artifact-set revision
class OcrTimings: # per-page processing stages
render_ms: int
ocr_ms: int
assembly_ms: int
class OcrPageProvenance:
page_number: int # 1-indexed
source: Literal["native", "ocr", "fused"]
ocr_model: OcrModelIdentity | None
render_dpi: float | None
ocr_confidence: float | None
timings: OcrTimings
warnings: list[str]
hosted_recommended: bool
class OcrPageResult:
page_number: int # 1-indexed
markdown: str
provenance: OcrPageProvenance
class OcrPdfResult: # process_pdf_with_ocr / bytes
markdown: str
pages: list[OcrPageResult]
page_count: int
pages_recommended_for_ocr: list[int]
pages_routed_to_ocr: list[int]
pages_recommending_hosted: list[int]
ocr_reasons_by_page: list[PageOcrReasons]
pages_with_tables: list[int]
pages_with_columns: list[int]
is_complex: bool
processing_time_ms: int
render_time_ms: int
ocr_time_ms: int
class PdfClassification: # classify_pdf
pdf_type: str
page_count: int
+4
View File
@@ -383,6 +383,10 @@ shape; `Force` renders every selected page. OCR uses the existing deterministic
table, column, reading-order, and Markdown assembly path; no learned layout
model is included.
The [OCR runtime setup guide](https://github.com/firecrawl/pdf-inspector/blob/main/docs/ocr-runtime.md)
lists the pinned PDFium and ONNX Runtime builds, environment variables, model
cache behavior, and the error boundary downstream hosted fallbacks should use.
For ambiguous mixed pages, `Auto` privately retains clean native fragments
instead of discarding them when OCR is selected. After recognition it compares
script-agnostic text quality, OCR confidence, character overlap, and material
+2256 -33
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,13 +1,13 @@
[package]
name = "pdf-inspector-napi"
version = "1.14.2"
version = "1.15.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
pdf-inspector = { path = ".." }
pdf-inspector = { path = "..", features = ["ocr"] }
napi = { version = "3.0.0", features = ["serde-json"] }
napi-derive = "3.0.0"
+52 -1
View File
@@ -10,7 +10,8 @@ Built by [Firecrawl](https://firecrawl.dev) for hybrid OCR pipelines — extract
- **Region-based extraction** — pull text from bounding boxes with per-region quality checks (`needsOcr`).
- **Layout-aware** — multi-column reading order, position and font info per text item, RTL support.
- **Robust text decoding** — CID/Type0 fonts via ToUnicode CMaps, plus automatic flagging of broken encodings so callers can fall back to OCR.
- **Lightweight** — native Rust core via napi-rs, no ML models, no external services; ~56 MB platform binary, TypeScript definitions included.
- **Selective OCR** — `Auto` routes only pages rejected by native extraction and returns source/model provenance plus hosted-fallback recommendations.
- **External artifacts** — the native package embeds no OCR models, PDFium, or ONNX Runtime; clean `Auto` requests never load or download them.
## Benchmark
@@ -36,8 +37,42 @@ bun add @firecrawl/pdf-inspector
Prebuilt binaries for **Linux x64/ARM64** (glibc and musl/Alpine), **macOS ARM64**, and **Windows x64** — npm installs only the one matching your platform. No Rust toolchain needed.
OCR calls that route work require compatible PDFium and ONNX Runtime shared
libraries. Set `PDFIUM_LIB_PATH` and `ORT_DYLIB_PATH` when they are not on the
platform library search path. The pinned OCR model set is downloaded and
checksum-verified on the first routed page; use `offline: true` with a warm
cache or `modelDirectory` to prohibit network access. See the
[OCR runtime setup guide](https://github.com/firecrawl/pdf-inspector/blob/main/docs/ocr-runtime.md)
for pinned downloads, supported platforms, and hosted-fallback behavior.
## API
### `processPdfWithOcr(buffer: Buffer, options?: OcrOptions): Promise<OcrPdfResult>`
Run native extraction first and OCR only the pages selected by its quality
signals. The default mode is `Auto`; `Off` returns the same detailed result
shape without external runtime work, and `Force` OCRs every selected page.
The work runs on the libuv thread pool and never blocks Node's event loop.
```typescript
import { OcrMode, processPdfWithOcr } from '@firecrawl/pdf-inspector'
const result = await processPdfWithOcr(pdf, {
mode: OcrMode.Auto,
pageNumbers: [1, 3], // 1-indexed
})
for (const page of result.pages) {
console.log(page.pageNumber, page.provenance.source)
}
console.log(result.pagesRoutedToOcr)
console.log(result.pagesRecommendingHosted)
```
For offline deployments, pass `modelDirectory` and `offline: true`. Other
controls include `dpi`, `minimumConfidence`,
`hostedRecommendationConfidence`, and `password`.
### `classifyPdf(buffer: Buffer): PdfClassification`
Classify a PDF as TextBased, Scanned, Mixed, or ImageBased (~10-50ms). Returns which pages need OCR.
@@ -124,6 +159,22 @@ interface RegionText {
needsOcr: boolean // true when text is unreliable
ocrReason?: string // "suspected_garbled_text" when known
}
interface OcrPdfResult {
markdown: string
pages: OcrPageResult[] // 1-indexed pages + provenance
pageCount: number
pagesRecommendedForOcr: number[]
pagesRoutedToOcr: number[]
pagesRecommendingHosted: number[]
ocrReasonsByPage: PageOcrReasons[]
pagesWithTables: number[]
pagesWithColumns: number[]
isComplex: boolean
processingTimeMs: number
renderTimeMs: number
ocrTimeMs: number
}
```
## Platforms
+6 -6
View File
@@ -8,12 +8,12 @@
"@napi-rs/cli": "^3.4.1",
},
"optionalDependencies": {
"@firecrawl/pdf-inspector-darwin-arm64": "1.14.2",
"@firecrawl/pdf-inspector-linux-arm64-gnu": "1.14.2",
"@firecrawl/pdf-inspector-linux-arm64-musl": "1.14.2",
"@firecrawl/pdf-inspector-linux-x64-gnu": "1.14.2",
"@firecrawl/pdf-inspector-linux-x64-musl": "1.14.2",
"@firecrawl/pdf-inspector-win32-x64-msvc": "1.14.2",
"@firecrawl/pdf-inspector-darwin-arm64": "1.15.0",
"@firecrawl/pdf-inspector-linux-arm64-gnu": "1.15.0",
"@firecrawl/pdf-inspector-linux-arm64-musl": "1.15.0",
"@firecrawl/pdf-inspector-linux-x64-gnu": "1.15.0",
"@firecrawl/pdf-inspector-linux-x64-musl": "1.15.0",
"@firecrawl/pdf-inspector-win32-x64-msvc": "1.15.0",
},
},
},
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@firecrawl/pdf-inspector",
"version": "1.14.2",
"version": "1.15.0",
"description": "Fast PDF classification and text extraction. Detect text-based vs scanned PDFs, extract text by region with quality checks. Native Rust performance via napi-rs.",
"main": "index.js",
"types": "index.d.ts",
@@ -52,11 +52,11 @@
"@napi-rs/cli": "^3.4.1"
},
"optionalDependencies": {
"@firecrawl/pdf-inspector-linux-x64-gnu": "1.14.2",
"@firecrawl/pdf-inspector-linux-x64-musl": "1.14.2",
"@firecrawl/pdf-inspector-linux-arm64-gnu": "1.14.2",
"@firecrawl/pdf-inspector-linux-arm64-musl": "1.14.2",
"@firecrawl/pdf-inspector-darwin-arm64": "1.14.2",
"@firecrawl/pdf-inspector-win32-x64-msvc": "1.14.2"
"@firecrawl/pdf-inspector-linux-x64-gnu": "1.15.0",
"@firecrawl/pdf-inspector-linux-x64-musl": "1.15.0",
"@firecrawl/pdf-inspector-linux-arm64-gnu": "1.15.0",
"@firecrawl/pdf-inspector-linux-arm64-musl": "1.15.0",
"@firecrawl/pdf-inspector-darwin-arm64": "1.15.0",
"@firecrawl/pdf-inspector-win32-x64-msvc": "1.15.0"
}
}
+241
View File
@@ -27,6 +27,26 @@ pub enum ItemType {
FormField,
}
/// Selects when OCR runs.
#[napi(string_enum)]
#[derive(Clone, Copy)]
pub enum OcrMode {
/// Never run OCR; return the native extraction in the OCR result shape.
Off,
/// Run OCR only on pages selected by the native quality signals.
Auto,
/// Run OCR on every selected page.
Force,
}
/// How final page content was sourced.
#[napi(string_enum)]
pub enum PageContentSource {
Native,
Ocr,
Fused,
}
// ---------------------------------------------------------------------------
// Result types
// ---------------------------------------------------------------------------
@@ -128,6 +148,84 @@ pub struct VectorGridDetectionJs {
pub cell_bboxes: Vec<Vec<f64>>,
}
/// Options for one-call native extraction with selective OCR.
#[napi(object)]
#[derive(Clone)]
pub struct OcrOptions {
/// OCR routing behavior. Defaults to Auto.
pub mode: Option<OcrMode>,
/// Optional 1-indexed page selection.
pub page_numbers: Option<Vec<u32>>,
/// Password for an encrypted PDF.
pub password: Option<String>,
/// Page rasterization resolution. Defaults to 150 DPI.
pub dpi: Option<f64>,
/// Drop OCR spans below this inclusive 0-1 threshold.
pub minimum_confidence: Option<f64>,
/// Recommend hosted parsing below this inclusive 0-1 page confidence.
pub hosted_recommendation_confidence: Option<f64>,
/// Directory containing an offline OCR model set.
pub model_directory: Option<String>,
/// Disable model downloads and require a model directory or warm cache.
pub offline: Option<bool>,
}
/// Exact OCR model identity retained in page provenance.
#[napi(object)]
pub struct OcrModelIdentity {
pub name: String,
pub revision: String,
}
/// Per-page OCR processing timings.
#[napi(object)]
pub struct OcrTimings {
pub render_ms: u32,
pub ocr_ms: u32,
pub assembly_ms: u32,
}
/// Source, model, confidence, and fallback metadata for one page.
#[napi(object)]
pub struct OcrPageProvenance {
/// 1-indexed page number.
pub page_number: u32,
pub source: PageContentSource,
pub ocr_model: Option<OcrModelIdentity>,
pub render_dpi: Option<f64>,
pub ocr_confidence: Option<f64>,
pub timings: OcrTimings,
pub warnings: Vec<String>,
pub hosted_recommended: bool,
}
/// Final Markdown and provenance for one page.
#[napi(object)]
pub struct OcrPageResult {
/// 1-indexed page number.
pub page_number: u32,
pub markdown: String,
pub provenance: OcrPageProvenance,
}
/// Complete native/OCR Markdown output.
#[napi(object)]
pub struct OcrPdfResult {
pub markdown: String,
pub pages: Vec<OcrPageResult>,
pub page_count: u32,
pub pages_recommended_for_ocr: Vec<u32>,
pub pages_routed_to_ocr: Vec<u32>,
pub pages_recommending_hosted: Vec<u32>,
pub ocr_reasons_by_page: Vec<PageOcrReasons>,
pub pages_with_tables: Vec<u32>,
pub pages_with_columns: Vec<u32>,
pub is_complex: bool,
pub processing_time_ms: u32,
pub render_time_ms: u32,
pub ocr_time_ms: u32,
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -168,6 +266,103 @@ fn to_napi_page_ocr_reasons(reasons: Vec<pdf_inspector::PageOcrReasons>) -> Vec<
.collect()
}
fn to_core_ocr_options(options: Option<OcrOptions>) -> pdf_inspector::vision::OcrPdfOptions {
let mut result = pdf_inspector::vision::OcrPdfOptions::auto();
let Some(options) = options else {
return result;
};
if let Some(mode) = options.mode {
result.ocr.mode = match mode {
OcrMode::Off => pdf_inspector::vision::OcrMode::Off,
OcrMode::Auto => pdf_inspector::vision::OcrMode::Auto,
OcrMode::Force => pdf_inspector::vision::OcrMode::Force,
};
}
if let Some(pages) = options.page_numbers {
result = result.page_numbers(pages);
}
if let Some(password) = options.password {
result = result.password(password);
}
if let Some(dpi) = options.dpi {
result.render.dpi = dpi as f32;
}
if let Some(minimum_confidence) = options.minimum_confidence {
result.ocr.minimum_confidence = minimum_confidence as f32;
}
if let Some(confidence) = options.hosted_recommendation_confidence {
result.hosted_recommendation_confidence = confidence as f32;
}
if let Some(directory) = options.model_directory {
result.ocr.model_directory = Some(directory.into());
}
if options.offline.unwrap_or(false) {
result.ocr.model_downloads = pdf_inspector::vision::ModelDownloadPolicy::Offline;
}
result
}
fn convert_page_content_source(
source: pdf_inspector::vision::PageContentSource,
) -> PageContentSource {
match source {
pdf_inspector::vision::PageContentSource::Native => PageContentSource::Native,
pdf_inspector::vision::PageContentSource::Ocr => PageContentSource::Ocr,
pdf_inspector::vision::PageContentSource::Fused => PageContentSource::Fused,
_ => PageContentSource::Native,
}
}
fn timing_ms(value: u64) -> u32 {
u32::try_from(value).unwrap_or(u32::MAX)
}
fn to_napi_ocr_result(result: pdf_inspector::vision::OcrPdfResult) -> OcrPdfResult {
OcrPdfResult {
markdown: result.markdown,
pages: result
.pages
.into_iter()
.map(|page| {
let provenance = page.provenance;
OcrPageResult {
page_number: page.page_number,
markdown: page.markdown,
provenance: OcrPageProvenance {
page_number: provenance.page_number,
source: convert_page_content_source(provenance.source),
ocr_model: provenance.ocr_model.map(|model| OcrModelIdentity {
name: model.name,
revision: model.revision,
}),
render_dpi: provenance.render_dpi.map(f64::from),
ocr_confidence: provenance.ocr_confidence.map(f64::from),
timings: OcrTimings {
render_ms: timing_ms(provenance.timings.render_ms),
ocr_ms: timing_ms(provenance.timings.ocr_ms),
assembly_ms: timing_ms(provenance.timings.assembly_ms),
},
warnings: provenance.warnings,
hosted_recommended: provenance.hosted_recommended,
},
}
})
.collect(),
page_count: result.page_count,
pages_recommended_for_ocr: result.pages_recommended_for_ocr,
pages_routed_to_ocr: result.pages_routed_to_ocr,
pages_recommending_hosted: result.pages_recommending_hosted,
ocr_reasons_by_page: to_napi_page_ocr_reasons(result.ocr_reasons_by_page),
pages_with_tables: result.pages_with_tables,
pages_with_columns: result.pages_with_columns,
is_complex: result.is_complex,
processing_time_ms: timing_ms(result.processing_time_ms),
render_time_ms: timing_ms(result.render_time_ms),
ocr_time_ms: timing_ms(result.ocr_time_ms),
}
}
fn convert_item_type(t: &pdf_inspector::types::ItemType) -> (ItemType, Option<String>) {
match t {
pdf_inspector::types::ItemType::Text => (ItemType::Text, None),
@@ -219,6 +414,13 @@ fn process_pdf_impl(bytes: &[u8], pages: Option<Vec<u32>>) -> Result<PdfResult>
Ok(to_napi_result(result))
}
fn process_pdf_with_ocr_impl(bytes: &[u8], options: Option<OcrOptions>) -> Result<OcrPdfResult> {
let options = to_core_ocr_options(options);
let result = pdf_inspector::vision::process_pdf_with_ocr_mem(bytes, options)
.map_err(|error| to_napi_err(error, "process_pdf_with_ocr"))?;
Ok(to_napi_ocr_result(result))
}
fn classify_pdf_impl(bytes: &[u8]) -> Result<PdfClassification> {
let result =
pdf_inspector::classify_pdf_mem(bytes).map_err(|e| to_napi_err(e, "classify_pdf"))?;
@@ -818,6 +1020,45 @@ pub fn process_pdf_async(buffer: Buffer, pages: Option<Vec<u32>>) -> AsyncTask<P
})
}
pub struct ProcessPdfWithOcrTask {
bytes: Vec<u8>,
options: Option<OcrOptions>,
}
impl Task for ProcessPdfWithOcrTask {
type Output = OcrPdfResult;
type JsValue = OcrPdfResult;
fn compute(&mut self) -> Result<Self::Output> {
let bytes = std::mem::take(&mut self.bytes);
let options = self.options.take();
catch_panic(
"process_pdf_with_ocr",
panic::AssertUnwindSafe(move || process_pdf_with_ocr_impl(&bytes, options)),
)
}
fn resolve(&mut self, _env: Env, output: Self::Output) -> Result<Self::JsValue> {
Ok(output)
}
}
/// Process a PDF with selective OCR on the libuv thread pool.
///
/// OCR defaults to Auto, which only loads PDFium, ONNX Runtime, and the OCR
/// model if native extraction routes at least one page. The input buffer is
/// copied before the promise is returned and is safe to reuse immediately.
#[napi(ts_return_type = "Promise<OcrPdfResult>")]
pub fn process_pdf_with_ocr(
buffer: Buffer,
options: Option<OcrOptions>,
) -> AsyncTask<ProcessPdfWithOcrTask> {
AsyncTask::new(ProcessPdfWithOcrTask {
bytes: buffer.to_vec(),
options,
})
}
pub struct ClassifyPdfTask {
bytes: Vec<u8>,
}
+32
View File
@@ -3,6 +3,7 @@ import { strict as assert } from 'assert';
import {
processPdf,
processPdfAsync,
processPdfWithOcr,
detectPdf,
classifyPdf,
classifyPdfAsync,
@@ -220,6 +221,37 @@ const fromMutated = await inFlight;
assert.equal(fromMutated.markdown, result.markdown);
console.log(' processPdfAsync input copied at call time: OK');
// --- Selective OCR ---
console.log('Testing processPdfWithOcr...');
// Off exercises the complete result/provenance contract without loading
// external PDFium, ONNX Runtime, or model artifacts.
const ocrOff = await processPdfWithOcr(fixture, { mode: 'Off' });
assert.equal(ocrOff.pageCount, 3);
assert.equal(ocrOff.pages.length, 3);
assert.deepEqual(ocrOff.pagesRoutedToOcr, []);
assert.ok(ocrOff.pages.every(page => page.provenance.source === 'Native'));
assert.ok(ocrOff.pages.every(page => page.provenance.ocrModel === undefined));
assert.ok(ocrOff.markdown.length > 0);
// Auto must preserve the lightweight path for clean text PDFs.
const ocrAuto = await processPdfWithOcr(fixture);
assert.deepEqual(ocrAuto.pagesRoutedToOcr, []);
assert.equal(ocrAuto.renderTimeMs, 0);
assert.equal(ocrAuto.ocrTimeMs, 0);
const ocrSelected = await processPdfWithOcr(fixture, {
mode: 'Off',
pageNumbers: [2],
});
assert.deepEqual(ocrSelected.pages.map(page => page.pageNumber), [2]);
await assert.rejects(
processPdfWithOcr(fixture, { mode: 'Off', pageNumbers: [0] }),
/page 0/,
);
console.log(' processPdfWithOcr: OK');
// concurrent async calls all settle
const [c1, c2, c3] = await Promise.all([
processPdfAsync(fixture),
+81 -1
View File
@@ -1,6 +1,6 @@
"""Type stubs for pdf_inspector."""
from typing import Optional
from typing import Literal, Optional
class PdfResult:
"""Result of processing a PDF file."""
@@ -27,6 +27,53 @@ class PageOcrReasons:
reasons: list[str]
"""Machine-readable OCR reason identifiers."""
class OcrModelIdentity:
"""Exact OCR model identity retained in page provenance."""
name: str
revision: str
class OcrTimings:
"""Per-page OCR processing timings."""
render_ms: int
ocr_ms: int
assembly_ms: int
class OcrPageProvenance:
"""Source, model, confidence, and fallback metadata for one page."""
page_number: int
"""1-indexed page number."""
source: Literal["native", "ocr", "fused"]
"""'native', 'ocr', or 'fused'."""
ocr_model: Optional[OcrModelIdentity]
render_dpi: Optional[float]
ocr_confidence: Optional[float]
timings: OcrTimings
warnings: list[str]
hosted_recommended: bool
class OcrPageResult:
"""Final Markdown and provenance for one page."""
page_number: int
"""1-indexed page number."""
markdown: str
provenance: OcrPageProvenance
class OcrPdfResult:
"""Complete native/OCR Markdown output."""
markdown: str
pages: list[OcrPageResult]
page_count: int
pages_recommended_for_ocr: list[int]
pages_routed_to_ocr: list[int]
pages_recommending_hosted: list[int]
ocr_reasons_by_page: list[PageOcrReasons]
pages_with_tables: list[int]
pages_with_columns: list[int]
is_complex: bool
processing_time_ms: int
render_time_ms: int
ocr_time_ms: int
class PdfClassification:
"""Lightweight PDF classification result."""
pdf_type: str
@@ -114,6 +161,39 @@ def process_pdf_bytes(data: bytes, pages: Optional[list[int]] = None) -> PdfResu
"""Process a PDF from bytes in memory."""
...
def process_pdf_with_ocr(
path: str,
*,
mode: Literal["off", "auto", "force"] = "auto",
page_numbers: Optional[list[int]] = None,
password: Optional[str] = None,
dpi: float = 150.0,
minimum_confidence: float = 0.0,
hosted_recommendation_confidence: float = 0.5,
model_directory: Optional[str] = None,
offline: bool = False,
) -> OcrPdfResult:
"""Process a PDF through native extraction and selective OCR.
Page numbers are 1-indexed. OCR runs without holding the Python GIL.
"""
...
def process_pdf_with_ocr_bytes(
data: bytes,
*,
mode: Literal["off", "auto", "force"] = "auto",
page_numbers: Optional[list[int]] = None,
password: Optional[str] = None,
dpi: float = 150.0,
minimum_confidence: float = 0.0,
hosted_recommendation_confidence: float = 0.5,
model_directory: Optional[str] = None,
offline: bool = False,
) -> OcrPdfResult:
"""Process PDF bytes through native extraction and selective OCR."""
...
def detect_pdf(path: str) -> PdfResult:
"""Fast detection only — no text extraction."""
...
+1 -1
View File
@@ -6,7 +6,7 @@ build-backend = "maturin"
name = "pdf-inspector"
# Keep package versions in sync with `python3 scripts/version.py <version>`.
# CI publishes automatically when the synchronized change lands on main.
version = "1.14.2"
version = "1.15.0"
description = "Fast PDF inspection, classification, and text extraction with smart scanned vs text-based detection"
readme = "docs/python.md"
license = { text = "MIT" }
+1 -1
View File
@@ -975,7 +975,7 @@ result = pdf_inspector.<span class="fn">process_pdf</span>(<span class="str">"do
<script>
(() => {
const MAX_FILE_SIZE = 25 * 1024 * 1024;
const WASM_MODULE_URL = "https://cdn.jsdelivr.net/npm/@firecrawl/pdf-inspector-wasm@1.14.2/pdf_inspector_wasm.js";
const WASM_MODULE_URL = "https://cdn.jsdelivr.net/npm/@firecrawl/pdf-inspector-wasm@1.15.0/pdf_inspector_wasm.js";
const input = document.querySelector("#pdf-input");
const dropZone = document.querySelector("#drop-zone");
const filePanel = document.querySelector("#demo-file");
+150 -12
View File
@@ -403,8 +403,15 @@ pub(crate) fn detect_from_document(
&& !(analysis.has_decodable_text_fonts && analysis.text_operator_count >= 10);
let looks_like_scan =
analysis.image_count <= 1 && analysis.text_operator_count < 50 && alphanum_low;
// A template-image page below the `pages_with_text` floor is
// a scan with incidental chrome (masthead, stamp, date line)
// even when that chrome is diverse, decodable text — keep
// this in sync with `page_ocr_signals`.
let sparse_text_over_scan = analysis.has_template_image
&& analysis.text_operator_count < config.min_text_ops_per_page.max(10);
if (analysis.has_template_image && looks_like_scan)
|| analysis.has_vector_text
|| sparse_text_over_scan
|| (analysis.text_operator_count < config.min_text_ops_per_page
&& analysis.has_images)
{
@@ -1795,17 +1802,24 @@ pub(crate) fn analyze_page_images(doc: &Document, page_id: ObjectId) -> (bool, u
/// low alphanumeric diversity in raw string operands (unless decodable
/// CID/ToUnicode fonts explain that away) — the gate used for
/// `pages_with_template_images` and Mixed-type per-page routing.
/// 2. Insufficient real text volume, using `DetectionConfig::default()`'s
/// `min_text_ops_per_page` (3) — the same threshold Mixed-type per-page
/// routing applies via `text_operator_count < config.min_text_ops_per_page
/// && has_images` (simplified here since a template image implies
/// `has_images`). Deliberately *not* the higher `effective_min_ops`
/// floor (`min_text_ops_per_page.max(10)`) that whole-document
/// `PdfType::ImageBased`/`Scanned` classification uses for
/// `pages_with_text` — that's a cross-page aggregate decision this
/// per-page function has no way to replicate exactly, and the lower
/// per-page threshold is the one a single page's own signals can
/// actually agree with.
/// 2. Insufficient real text volume, using the same `effective_min_ops`
/// floor (`min_text_ops_per_page.max(10)`) that `pages_with_text`
/// applies to image-bearing pages. That floor is a per-page judgment,
/// not part of the cross-page aggregate: classification counts a
/// template-image page with fewer ops as textless and routes it to OCR,
/// so this function must agree. The lower bare threshold (3) let a
/// full-page scan carrying a small native masthead — a newspaper
/// header, stamp, or date line of ~4 diverse, decodable text ops —
/// extract as "a text page" here while whole-document classification
/// called the same page scanned, silently dropping the page body from
/// OCR routing. `alphanum_low` can't catch that case: masthead chrome
/// is real text, so its byte diversity is high.
///
/// This function always evaluates against `DetectionConfig::default()` —
/// it has no config parameter, and the per-page extraction path that calls
/// it never carries one. A caller passing a custom `min_text_ops_per_page`
/// to `detect_from_document` affects whole-document detection only; the
/// two paths agree under the default configuration.
///
/// `has_vector_text` is true when a page has vector-outlined text (glyphs
/// drawn as paths rather than shown via text-showing operators) —
@@ -1827,7 +1841,7 @@ pub(crate) fn page_ocr_signals(doc: &Document, page_id: ObjectId) -> (bool, bool
let looks_like_scan =
analysis.image_count <= 1 && analysis.text_operator_count < 50 && alphanum_low;
let insufficient_text =
analysis.text_operator_count < DetectionConfig::default().min_text_ops_per_page;
analysis.text_operator_count < DetectionConfig::default().min_text_ops_per_page.max(10);
looks_like_scan || insufficient_text
};
@@ -2995,6 +3009,130 @@ mod tests {
);
}
// ---------- masthead-over-scan tests: template image + sparse chrome ----------
/// Builds a page whose only image is a full-page scan inside a Form
/// XObject, plus `masthead_ops` native text-show ops of diverse,
/// decodable chrome (newspaper masthead / date line style).
fn masthead_scan_page(masthead_lines: &[&str]) -> (Document, ObjectId) {
use lopdf::dictionary;
let mut doc = Document::with_version("1.4");
let pages_id = doc.new_object_id();
let page_id = doc.new_object_id();
let image_id = doc.add_object(Object::Stream(lopdf::Stream::new(
dictionary! {
"Type" => "XObject",
"Subtype" => Object::Name(b"Image".to_vec()),
"Width" => Object::Integer(1500),
"Height" => Object::Integer(2383),
},
Vec::new(),
)));
let form_id = doc.add_object(Object::Stream(lopdf::Stream::new(
dictionary! {
"Type" => "XObject",
"Subtype" => Object::Name(b"Form".to_vec()),
"Resources" => dictionary! {
"XObject" => dictionary! {
"Im0" => Object::Reference(image_id),
},
},
},
b"1500 0 0 2383 0 0 cm /Im0 Do".to_vec(),
)));
let font_id = doc.add_object(dictionary! {
"Type" => "Font",
"Subtype" => Object::Name(b"Type1".to_vec()),
"BaseFont" => Object::Name(b"Helvetica".to_vec()),
});
let mut content = b"q /Fm0 Do Q BT /F1 12 Tf ".to_vec();
for line in masthead_lines {
content.extend_from_slice(format!("({line}) Tj ").as_bytes());
}
content.extend_from_slice(b"ET");
let content_id =
doc.add_object(Object::Stream(lopdf::Stream::new(dictionary! {}, content)));
doc.objects.insert(
page_id,
Object::Dictionary(dictionary! {
"Type" => "Page",
"Parent" => Object::Reference(pages_id),
"MediaBox" => vec![0.into(), 0.into(), 1500.into(), 2383.into()],
"Resources" => dictionary! {
"Font" => dictionary! { "F1" => Object::Reference(font_id) },
"XObject" => dictionary! { "Fm0" => Object::Reference(form_id) },
},
"Contents" => Object::Reference(content_id),
}),
);
doc.objects.insert(
pages_id,
Object::Dictionary(dictionary! {
"Type" => "Pages",
"Kids" => vec![Object::Reference(page_id)],
"Count" => Object::Integer(1),
}),
);
(doc, page_id)
}
#[test]
fn test_masthead_over_form_wrapped_scan_needs_ocr() {
// A full-page scan wrapped in a Form XObject with ~4 ops of real,
// diverse masthead text. `alphanum_low` can't flag it (the chrome is
// genuine text), so the sparse-text floor must: without OCR the page
// body is silently lost while classification calls the page scanned.
let (doc, page_id) = masthead_scan_page(&[
"18",
"FINANCIAL EXPRESS",
"WWW.FINANCIALEXPRESS.COM",
"FRIDAY, DECEMBER 13, 2024",
]);
let analysis = analyze_page_content(&doc, page_id);
assert!(
analysis.has_template_image,
"sanity: full-page image inside the form must be found"
);
assert!(
analysis.unique_alphanum_chars >= 10,
"sanity: masthead text is diverse, alphanum_low cannot fire"
);
let (needs_ocr, _) = page_ocr_signals(&doc, page_id);
assert!(
needs_ocr,
"template image + text below the pages_with_text floor is a scan"
);
}
#[test]
fn test_text_page_over_background_image_stays_native() {
// Counterpart: a real text page over a full-page background image
// (letterhead/watermark) has enough text ops to clear the
// `pages_with_text` floor and must NOT be routed to OCR.
let lines: Vec<String> = (0..12)
.map(|i| format!("Paragraph line {i} with ordinary body text"))
.collect();
let refs: Vec<&str> = lines.iter().map(String::as_str).collect();
let (doc, page_id) = masthead_scan_page(&refs);
let analysis = analyze_page_content(&doc, page_id);
assert!(
analysis.has_template_image,
"sanity: background image found"
);
assert!(
analysis.text_operator_count >= 10,
"sanity: body text clears the floor"
);
let (needs_ocr, _) = page_ocr_signals(&doc, page_id);
assert!(
!needs_ocr,
"a text page with a background image must stay native"
);
}
// ---------- P2 tests: Form XObject font traversal ----------
#[test]
+20 -4
View File
@@ -561,7 +561,11 @@ pub(crate) fn extract_page_text_items(
y,
width,
height: rendered_size,
font: current_font.clone(),
font: crate::extractor::fonts::item_font_name(
&current_font,
base_font,
)
.to_string(),
font_size: rendered_size,
page: page_num,
is_bold: is_bold_font(base_font) || desc_bold,
@@ -745,7 +749,11 @@ pub(crate) fn extract_page_text_items(
y,
width,
height: rendered_size,
font: current_font.clone(),
font: crate::extractor::fonts::item_font_name(
&current_font,
base_font,
)
.to_string(),
font_size: rendered_size,
page: page_num,
is_bold: is_bold_font(base_font) || desc_bold,
@@ -852,7 +860,11 @@ pub(crate) fn extract_page_text_items(
y,
width,
height: rendered_size,
font: current_font.clone(),
font: crate::extractor::fonts::item_font_name(
&current_font,
base_font,
)
.to_string(),
font_size: rendered_size,
page: page_num,
is_bold: is_bold_font(base_font) || desc_bold,
@@ -1005,7 +1017,11 @@ pub(crate) fn extract_page_text_items(
y,
width,
height: rendered_size,
font: current_font.clone(),
font: crate::extractor::fonts::item_font_name(
&current_font,
base_font,
)
.to_string(),
font_size: rendered_size,
page: page_num,
is_bold: is_bold_font(base_font) || desc_bold,
+31
View File
@@ -225,6 +225,26 @@ pub(crate) fn build_type3_scales(
scales
}
/// The name a `TextItem` carries for its font: the `/BaseFont` family name
/// ("ABCDEF+CMMI10"), which identifies the actual face, rather than the
/// arbitrary per-page resource tag ("F2").
///
/// Exception: resource names using Distiller's CID convention (`C2_0`,
/// `C0_1`) are kept as-is — `text_utils::is_cid_font` keys on that prefix
/// for micro-gap joining, and the family name carries no CID marker to
/// replace it. This is a known, deliberate wart: `TextItem::font` is the
/// face name except for this one producer convention. The clean fix is an
/// explicit CID flag on `TextItem`, which touches its ~29 construction
/// sites; do that migration when `TextItem` next changes shape, and delete
/// this carve-out with it.
pub(crate) fn item_font_name<'a>(resource_name: &'a str, base_font: &'a str) -> &'a str {
if crate::text_utils::is_cid_font(resource_name) {
resource_name
} else {
base_font
}
}
/// Parse font widths from a font dictionary, dispatching by Subtype
pub(crate) fn parse_font_widths(
doc: &Document,
@@ -1664,6 +1684,17 @@ fn score_text(text: &str) -> i32 {
#[cfg(test)]
mod tests {
#[test]
fn item_font_name_prefers_family_over_resource_tag() {
use super::item_font_name;
assert_eq!(item_font_name("F2", "ABCDEF+CMMI10"), "ABCDEF+CMMI10");
assert_eq!(item_font_name("T22", "Times-Roman"), "Times-Roman");
// Distiller CID-convention resources keep the resource name:
// is_cid_font keys on the C2_/C0_ prefix for micro-gap joining.
assert_eq!(item_font_name("C2_0", "ABCDEE+SimSun"), "C2_0");
assert_eq!(item_font_name("C0_1", "ABCDEE+MSMincho"), "C0_1");
}
#[test]
fn type3_scale_resolves_indirect_matrix_and_bbox_numbers() {
use lopdf::{dictionary, Document, Object};
+954 -62
View File
File diff suppressed because it is too large Load Diff
+10 -2
View File
@@ -620,7 +620,11 @@ fn extract_form_xobject_text_inner(
y,
width,
height: rendered_size,
font: current_font.clone(),
font: crate::extractor::fonts::item_font_name(
&current_font,
base_font,
)
.to_string(),
font_size: rendered_size,
page: page_num,
is_bold: is_bold_font(base_font) || desc_bold,
@@ -775,7 +779,11 @@ fn extract_form_xobject_text_inner(
y,
width,
height: rendered_size,
font: current_font.clone(),
font: crate::extractor::fonts::item_font_name(
&current_font,
base_font,
)
.to_string(),
font_size: rendered_size,
page: page_num,
is_bold: is_bold_font(base_font) || desc_bold,
+7 -1
View File
@@ -592,6 +592,12 @@ fn extract_pages_markdown_mem_impl(
.cloned()
.collect();
let page_lines: Vec<types::PdfLine> = all_lines
.iter()
.filter(|l| l.page == page_1idx)
.cloned()
.collect();
let has_gid = gid_pages.contains(&page_1idx);
let has_text_quality_issue = text_quality.pages_needing_ocr.contains(&page_1idx);
@@ -629,7 +635,7 @@ fn extract_pages_markdown_mem_impl(
page_items,
options,
&page_rects,
&[],
&page_lines,
markdown::MarkdownDocumentContext {
page_thresholds: &page_thresholds,
struct_roles: None,
+44
View File
@@ -203,9 +203,42 @@ pub(crate) fn is_code_like(text: &str) -> bool {
false
}
/// True when a line's text is essentially all monospace (≥90% by character
/// count). Code lines are wholly monospace; anything less is prose carrying
/// mono-styled fragments — a URL sidebar, or a sentence quoting an inline
/// code literal — and fencing it would split paragraphs mid-sentence.
/// Any-item matching was safe only while items carried opaque font resource
/// names that never matched the monospace patterns; items now carry real
/// family names.
pub(crate) fn line_is_monospace(line: &crate::types::TextLine) -> bool {
let mut monospace_chars = 0usize;
let mut total_chars = 0usize;
for item in &line.items {
let text = item.text.trim();
let chars = text.chars().count();
total_chars += chars;
// Hyperlinks and underlined text set in a mono face are link
// styling, not code — a URL sidebar must not fence lyric lines.
let looks_like_link = item.is_underline
|| matches!(item.item_type, crate::types::ItemType::Link(_))
|| text.contains("://")
|| text.starts_with("www.");
if is_monospace_font(&item.font) && !looks_like_link {
monospace_chars += chars;
}
}
total_chars > 0 && monospace_chars * 10 >= total_chars * 9
}
/// Check if font name indicates monospace
pub(crate) fn is_monospace_font(font_name: &str) -> bool {
let lower = font_name.to_lowercase();
// "Monotype" is a foundry prefix on proportional faces (Monotype
// Corsiva, Monotype Garamond) — it must not satisfy the generic "mono"
// token below.
if lower.contains("monotype") {
return false;
}
let patterns = [
"courier",
"consolas",
@@ -230,6 +263,17 @@ pub(crate) fn is_monospace_font(font_name: &str) -> bool {
mod tests {
use super::*;
#[test]
fn monotype_foundry_faces_are_not_monospace() {
// "Monotype" is a foundry prefix on proportional faces; the generic
// "mono" token must not classify them as code fonts.
assert!(!is_monospace_font("MonotypeCorsiva"));
assert!(!is_monospace_font("ABCDEF+Monotype-Garamond"));
assert!(is_monospace_font("RobotoMono-Regular"));
assert!(is_monospace_font("PTMono"));
assert!(is_monospace_font("Courier"));
}
#[test]
fn format_list_item_plain_bullet() {
assert_eq!(format_list_item("● Item"), "- Item");
+52 -28
View File
@@ -11,9 +11,7 @@ use super::analysis::{
detect_header_level, font_size_rarity, has_dot_leaders, is_heading_fragment, is_toc_entry_line,
is_toc_marker_heading,
};
use super::classify::{
format_list_item, is_caption_line, is_list_item, is_monospace_font, starts_with_bullet_marker,
};
use super::classify::{format_list_item, is_caption_line, is_list_item, starts_with_bullet_marker};
use super::heading::classify_heading_sequences;
use super::postprocess::clean_markdown;
use super::preprocess::{merge_drop_caps, merge_heading_lines};
@@ -771,7 +769,27 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
let mut in_list = false;
let mut in_paragraph = false;
let mut last_list_x: Option<f32> = None;
// Code lines accumulate here and the fence is emitted only when the
// block flushes with content — an empty ``` ``` pair can never appear.
fn flush_code_block(output: &mut String, pending_code: &mut String) {
let trimmed = pending_code.trim();
// A fragment too short to be code — a lone ® or stray glyph set in
// a mono face — reads better as plain text than as a fenced block.
if trimmed.chars().count() < 3 {
if !trimmed.is_empty() {
output.push_str(trimmed);
output.push_str("\n\n");
}
} else {
output.push_str("```\n");
output.push_str(pending_code);
output.push_str("```\n");
}
pending_code.clear();
}
let mut in_code_block = false;
let mut pending_code = String::new();
let mut prev_had_dot_leaders = false;
let mut paragraph_in_wrapped_bold_run = false;
let mut toc_suppress_page: Option<u32> = None;
@@ -805,7 +823,7 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
// Flush current page's remaining tables and images
if current_page > 0 {
if in_code_block {
output.push_str("```\n");
flush_code_block(&mut output, &mut pending_code);
in_code_block = false;
}
flush_page_tables_and_images(
@@ -867,6 +885,14 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
PositionedBlockKind::Image => inserted_images.contains(&(current_page, idx)),
};
if positioned_block_precedes_line(block, line) && !already_inserted {
// Code lines buffer until their block closes; flush them
// first so this block cannot jump ahead of code that
// precedes it in reading order. A code line after the
// block reopens a new fence naturally.
if in_code_block {
flush_code_block(&mut output, &mut pending_code);
in_code_block = false;
}
if in_paragraph {
output.push_str("\n\n");
in_paragraph = false;
@@ -937,15 +963,22 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
// These should be on their own line followed by a paragraph break
let struct_role = struct_roles.and_then(|roles| resolve_line_struct_role(line, roles));
// Determine if this line is code (struct-tree or font-based) for block accumulation
// Determine if this line is code (struct-tree or font-based) for
// block accumulation. Font-based detection only opens a block at a
// paragraph boundary: a mono-set line that continues an open prose
// paragraph is the producer smearing an inline code literal's style
// across a wrapped line (HTML-to-PDF exports do this), and fencing
// it would cut the sentence in three.
let is_code_line = struct_role
.as_ref()
.is_some_and(|r| matches!(r, StructRole::Code))
|| (options.detect_code && line.items.iter().any(|i| is_monospace_font(&i.font)));
|| (options.detect_code
&& (in_code_block || !in_paragraph)
&& super::classify::line_is_monospace(line));
// Close code block when transitioning to non-code
if in_code_block && !is_code_line {
output.push_str("```\n");
flush_code_block(&mut output, &mut pending_code);
in_code_block = false;
}
@@ -1179,12 +1212,9 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
in_paragraph = false;
paragraph_in_wrapped_bold_run = false;
}
if !in_code_block {
output.push_str("```\n");
in_code_block = true;
}
output.push_str(plain_trimmed);
output.push('\n');
in_code_block = true;
pending_code.push_str(plain_trimmed);
pending_code.push('\n');
continue;
}
@@ -1209,7 +1239,7 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
// Close any trailing code block
if in_code_block {
output.push_str("```\n");
flush_code_block(&mut output, &mut pending_code);
}
// Flush current page and any remaining pages with tables/images
@@ -1370,7 +1400,7 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
&& !is_toc_entry_line(plain_trimmed)
&& !is_heading_fragment(plain_trimmed)
&& toc_suppress_page != Some(line.page)
&& !(options.detect_code && line.items.iter().any(|i| is_monospace_font(&i.font)))
&& !(options.detect_code && super::classify::line_is_monospace(line))
{
let line_font_size = line.items.first().map(|i| i.font_size).unwrap_or(base_size);
if let Some(header_level) = detect_header_level(
@@ -1471,19 +1501,13 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
}
}
// Detect code blocks by font
if options.detect_code {
let is_mono = line.items.iter().any(|i| is_monospace_font(&i.font));
if is_mono {
if in_paragraph {
output.push_str("\n\n");
in_paragraph = false;
paragraph_in_wrapped_bold_run = false;
}
// Use plain text for code blocks
output.push_str(&format!("```\n{}\n```\n", plain_trimmed));
continue;
}
// Detect code blocks by font. Only at a paragraph boundary — a
// mono-set line continuing an open prose paragraph is an inline
// code literal's style smeared across a wrapped line, not code.
if options.detect_code && !in_paragraph && super::classify::line_is_monospace(line) {
// Use plain text for code blocks
output.push_str(&format!("```\n{}\n```\n", plain_trimmed));
continue;
}
// Regular text - join lines within same paragraph with space
File diff suppressed because it is too large Load Diff
+48 -8
View File
@@ -9,6 +9,7 @@
pub(crate) mod analysis;
mod classify;
mod convert;
mod furniture;
mod heading;
mod postprocess;
mod preprocess;
@@ -634,7 +635,12 @@ fn is_parallel_prose_table(table: &crate::tables::Table) -> bool {
}
}
let is_parallel = !has_compact_header
// A compact header row is evidence for a real table — unless cross-row
// prose continuations outnumber the rows, which no genuine table
// produces: the "header" is then just two short line fragments at the
// top of parallel prose columns.
let header_blocks = has_compact_header && continuation_fragments <= table.cells.len();
let is_parallel = !header_blocks
&& non_empty >= 5
// Independent prose columns have asynchronous line/paragraph breaks;
// a fully populated grid is positive evidence for a real descriptive
@@ -1149,7 +1155,7 @@ pub(crate) fn strip_repeated_header_footer_lines(
lines: Vec<crate::types::TextLine>,
page_count: u32,
) -> Vec<crate::types::TextLine> {
preprocess::strip_repeated_lines(lines, page_count)
furniture::strip_header_footer_lines(lines, page_count)
}
/// Convert positioned text items to markdown with structure detection
@@ -1374,7 +1380,6 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
chart_page_prose_column_split(&page_layout_items)
.filter(|&split_x| chart_spans_prose_split(region, split_x))
});
let chart_prose_columns = chart_prose_split.is_some();
// Check for side-by-side table layout using the original items. Sparse
// numeric cells need table context before they can be distinguished
@@ -1615,10 +1620,16 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
if subset_items.len() < min_items {
return;
}
// Keep body-font detection available on chart pages: a real
// table can share the prose anchors. Reject only candidates
// whose cells prove they are parallel prose fragments.
let reject_parallel_prose = chart_prose_columns && !was_split;
// Reject candidates whose cells prove they are parallel
// prose fragments — the shape produced when the body-font
// pass projects a multi-column text page onto one table
// grid (two-column reference sections are the classic
// case). The check needs internal transition evidence
// (unterminated cells flowing into lowercase starts in
// the same column), so genuine tables with long cells
// pass. Band-split retries stay exempt: they exist for
// tables that only assemble after recombining bands.
let reject_parallel_prose = !was_split;
let tables = detect_tables_with_page_width(
subset_items,
base_size,
@@ -2106,7 +2117,7 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
// Strip repeated headers/footers before conversion
let lines = if options.strip_headers_footers {
preprocess::strip_repeated_lines(lines, document_page_count)
furniture::strip_header_footer_lines(lines, document_page_count)
} else {
lines
};
@@ -2673,6 +2684,35 @@ mod tests {
);
assert!(!is_parallel_prose_table(&data));
// A compact header row atop parallel prose columns: cross-row prose
// continuations outnumber the rows, so the header cannot save the
// candidate — this is page prose with two short fragments on top.
let headed_parallel_prose = crate::tables::Table::new(
vec![90.0, 340.0],
vec![340.0, 320.0, 300.0, 280.0, 260.0],
vec![
vec!["June 2023".into(), "Page 5".into()],
vec![
"the committee reviewed the proposal and decided that the".into(),
"funding for the second phase would continue subject to the".into(),
],
vec![
"implementation schedule should be extended by another".into(),
"quarterly reviews established during the first phase of the".into(),
],
vec![
"six months to accommodate the revised procurement rules".into(),
"".into(),
],
vec![
"adopted at the previous meeting of the governing board".into(),
"participating institutions across the partner regions".into(),
],
],
(0..10).collect(),
);
assert!(is_parallel_prose_table(&headed_parallel_prose));
let headed_text_table = crate::tables::Table::new(
vec![90.0, 340.0],
vec![320.0, 300.0, 280.0],
+1 -384
View File
@@ -1,6 +1,6 @@
//! Line preprocessing: heading merging, drop cap handling, and repeated line removal.
use std::collections::{HashMap, HashSet};
use std::collections::HashMap;
use crate::structure_tree::StructRole;
use crate::types::{TextItem, TextLine};
@@ -229,342 +229,6 @@ pub(crate) fn merge_drop_caps(lines: Vec<TextLine>, base_size: f32) -> Vec<TextL
result
}
/// Normalize whitespace in a string for comparison: trim and collapse internal runs of whitespace.
fn normalize_whitespace(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// Normalize text for frequency comparison: collapse whitespace and strip leading/trailing
/// digit sequences (page numbers). E.g., "Chapter 3 — Page 5" and "Chapter 3 — Page 6"
/// both normalize to "Chapter 3 — Page".
fn normalize_for_comparison(s: &str) -> String {
let ws = normalize_whitespace(s);
let trimmed = ws
.trim_start_matches(|c: char| c.is_ascii_digit())
.trim_start();
let trimmed = trimmed
.trim_end_matches(|c: char| c.is_ascii_digit())
.trim_end();
trimmed.to_string()
}
/// Returns true if the line looks like a list item or heading (should not be stripped).
fn is_structural_line(text: &str) -> bool {
let t = text.trim_start();
t.starts_with('#')
|| t.starts_with("- ")
|| t.starts_with("* ")
|| t.starts_with("")
|| t.chars()
.next()
.map(|c| c.is_ascii_digit())
.unwrap_or(false)
&& (t.contains(". ") || t.contains(") "))
}
/// Returns true if a line consists entirely of a single repeated character
/// (e.g., "----------", "**************", "============").
fn is_decorative_separator(text: &str) -> bool {
let mut chars = text.chars();
let first = match chars.next() {
Some(c) => c,
None => return false,
};
chars.all(|c| c == first)
}
/// Strip lines that repeat on many distinct pages (running headers/footers).
///
/// A line is considered a repeated header/footer if:
/// 1. Its normalized text appears on `>= max(3, page_count * 30%)` distinct pages
/// 2. It is at least 10 characters long
/// 3. It doesn't look like a structural element (heading, list item)
/// 4. It consistently appears in the top or bottom N distinct Y positions
/// 5. Its Y positions across pages have low variance (consistent placement),
/// distinguishing true headers/footers from table content that happens to
/// land near page margins
/// 6. It is not a decorative separator (repeated single character)
///
/// Additionally, TextLines at the same Y position on a page are grouped into
/// "Y-bands." When any member of a Y-band is stripped, all siblings in that
/// band are also stripped. This handles split column headers where individual
/// fragments may not independently meet the frequency threshold.
///
/// Page numbers are stripped from line text before comparison, so headers like
/// "Chapter 3 — Page 5" and "Chapter 3 — Page 6" are treated as the same text.
pub(crate) fn strip_repeated_lines(lines: Vec<TextLine>, page_count: u32) -> Vec<TextLine> {
if lines.is_empty() || page_count < 3 {
return lines;
}
// Compute Y range per page (min_y, max_y)
let mut page_y_range: HashMap<u32, (f32, f32)> = HashMap::new();
for line in &lines {
let entry = page_y_range.entry(line.page).or_insert((line.y, line.y));
if line.y < entry.0 {
entry.0 = line.y;
}
if line.y > entry.1 {
entry.1 = line.y;
}
}
// Build sorted Y values per page, so we can check line rank (position from edge)
let mut page_sorted_ys: HashMap<u32, Vec<f32>> = HashMap::new();
for line in &lines {
page_sorted_ys.entry(line.page).or_default().push(line.y);
}
for ys in page_sorted_ys.values_mut() {
ys.sort_by(|a, b| a.total_cmp(b));
ys.dedup();
}
// A line is in the page margin if it's among the first or last N distinct
// Y positions on that page. This is more robust than a percentage-based zone
// because it catches actual edge lines regardless of how much content fills
// the page. N=5 accommodates multi-line headers/footers and repeated form
// column headers (e.g., 5-row IRS form headers) that sit just inside the
// page margin.
const EDGE_LINE_COUNT: usize = 5;
/// Returns true if the given Y position is among the first or last N distinct
/// Y positions on the specified page.
fn is_y_at_edge(y: f32, page: u32, page_sorted_ys: &HashMap<u32, Vec<f32>>, n: usize) -> bool {
let ys = match page_sorted_ys.get(&page) {
Some(ys) => ys,
None => return false,
};
if ys.len() <= n * 2 {
// Page has very few lines — everything is near the edge
return true;
}
// Check if this Y is among the first or last N
let pos = match ys.iter().position(|&py| (py - y).abs() < 0.1) {
Some(p) => p,
None => return false,
};
pos < n || pos >= ys.len() - n
}
// Average page span for normalizing Y variance
let avg_span = {
let total: f32 = page_y_range.values().map(|(lo, hi)| hi - lo).sum();
if page_y_range.is_empty() {
1.0
} else {
(total / page_y_range.len() as f32).max(1.0)
}
};
// Build Y-bands: group line indices by (page, quantized_y).
// Lines at the same Y position (within ~0.1pt) on the same page form a band.
let mut y_bands: HashMap<(u32, i32), Vec<usize>> = HashMap::new();
for (idx, line) in lines.iter().enumerate() {
let y_bucket = (line.y * 10.0).round() as i32;
y_bands.entry((line.page, y_bucket)).or_default().push(idx);
}
// Build frequency maps using normalize_for_comparison.
// Individual line text -> distinct pages
let mut freq: HashMap<String, HashSet<u32>> = HashMap::new();
let mut y_positions: HashMap<String, Vec<f32>> = HashMap::new();
for line in &lines {
if !is_y_at_edge(line.y, line.page, &page_sorted_ys, EDGE_LINE_COUNT) {
continue;
}
let text = line.text();
let normalized = normalize_for_comparison(&text);
if normalized.len() < 10 || is_decorative_separator(&normalized) {
continue;
}
freq.entry(normalized.clone())
.or_default()
.insert(line.page);
y_positions.entry(normalized).or_default().push(line.y);
}
// Coalesced row text -> distinct pages (for multi-member Y-bands).
// This catches split column headers where individual fragments don't meet
// the frequency threshold but the combined row does.
let mut band_freq: HashMap<String, HashSet<u32>> = HashMap::new();
let mut band_y_positions: HashMap<String, Vec<f32>> = HashMap::new();
for (&(page, _), indices) in &y_bands {
if indices.len() < 2 {
continue; // single-line bands are already in the individual map
}
let band_y = lines[indices[0]].y;
if !is_y_at_edge(band_y, page, &page_sorted_ys, EDGE_LINE_COUNT) {
continue;
}
let mut sorted_indices = indices.clone();
sorted_indices.sort();
let coalesced: String = sorted_indices
.iter()
.map(|&i| lines[i].text())
.collect::<Vec<_>>()
.join(" ");
let normalized = normalize_for_comparison(&coalesced);
if normalized.len() < 10 || is_decorative_separator(&normalized) {
continue;
}
band_freq
.entry(normalized.clone())
.or_default()
.insert(page);
band_y_positions.entry(normalized).or_default().push(band_y);
}
// Compute threshold
let threshold = 3u32.max(page_count * 30 / 100);
// Check Y-position consistency: headers/footers appear at the same position
// on every page, table content varies. Require normalized stddev < 5% of
// average page span.
let has_consistent_y = |text: &str, positions: &HashMap<String, Vec<f32>>| -> bool {
let pos = match positions.get(text) {
Some(p) if p.len() >= 2 => p,
_ => return true, // single occurrence — allow
};
let n = pos.len() as f32;
let mean = pos.iter().sum::<f32>() / n;
let variance = pos.iter().map(|y| (y - mean).powi(2)).sum::<f32>() / n;
let stddev = variance.sqrt();
stddev / avg_span < 0.05
};
// Identify candidates from individual frequency map
let candidates: HashSet<String> = freq
.into_iter()
.filter(|(text, pages)| {
pages.len() as u32 >= threshold
&& !is_structural_line(text)
&& has_consistent_y(text, &y_positions)
})
.map(|(text, _)| text)
.collect();
// Identify candidates from coalesced band frequency map
let band_candidates: HashSet<String> = band_freq
.into_iter()
.filter(|(text, pages)| {
pages.len() as u32 >= threshold
&& !is_structural_line(text)
&& has_consistent_y(text, &band_y_positions)
})
.map(|(text, _)| text)
.collect();
if candidates.is_empty() && band_candidates.is_empty() {
return lines;
}
// Build removal set.
// A line is removed if it's at an edge position and:
// (a) its individual text matches a candidate, OR
// (b) its Y-band's coalesced text matches a band candidate, OR
// (c) any sibling in its Y-band was removed (propagation).
//
// The first occurrence (lowest page number) of each repeated header/footer
// is kept so that document titles, column headers, etc. appear once.
let mut removal_set: HashSet<usize> = HashSet::new();
// Track which page first shows each candidate (to preserve first occurrence)
let mut first_page_individual: HashMap<String, u32> = HashMap::new();
for (idx, line) in lines.iter().enumerate() {
if !is_y_at_edge(line.y, line.page, &page_sorted_ys, EDGE_LINE_COUNT) {
continue;
}
let text = line.text();
let normalized = normalize_for_comparison(&text);
if candidates.contains(&normalized) {
let first = first_page_individual.entry(normalized).or_insert(line.page);
if line.page > *first {
removal_set.insert(idx);
} else if line.page == *first {
// Keep this occurrence (first page)
}
}
}
// Track first page for band candidates
let mut first_page_band: HashMap<String, u32> = HashMap::new();
// First pass: find first page for each band candidate
for (&(page, _), indices) in &y_bands {
if indices.len() < 2 {
continue;
}
let band_y = lines[indices[0]].y;
if !is_y_at_edge(band_y, page, &page_sorted_ys, EDGE_LINE_COUNT) {
continue;
}
let mut sorted_indices = indices.clone();
sorted_indices.sort();
let coalesced: String = sorted_indices
.iter()
.map(|&i| lines[i].text())
.collect::<Vec<_>>()
.join(" ");
let normalized = normalize_for_comparison(&coalesced);
if band_candidates.contains(&normalized) {
let first = first_page_band.entry(normalized).or_insert(page);
if page < *first {
*first = page;
}
}
}
// Second pass: mark for removal (skip first page)
for (&(page, _), indices) in &y_bands {
if indices.len() < 2 {
continue;
}
let band_y = lines[indices[0]].y;
if !is_y_at_edge(band_y, page, &page_sorted_ys, EDGE_LINE_COUNT) {
continue;
}
let mut sorted_indices = indices.clone();
sorted_indices.sort();
let coalesced: String = sorted_indices
.iter()
.map(|&i| lines[i].text())
.collect::<Vec<_>>()
.join(" ");
let normalized = normalize_for_comparison(&coalesced);
if band_candidates.contains(&normalized) {
let first = first_page_band.get(&normalized).copied().unwrap_or(0);
if page > first {
for &idx in &sorted_indices {
removal_set.insert(idx);
}
}
}
}
// (c) Y-band sibling propagation: if any member is removed, remove all
// members (provided the band is at an edge position).
for (&(page, _), indices) in &y_bands {
let band_y = lines[indices[0]].y;
if !is_y_at_edge(band_y, page, &page_sorted_ys, EDGE_LINE_COUNT) {
continue;
}
if indices.iter().any(|idx| removal_set.contains(idx)) {
for &idx in indices {
removal_set.insert(idx);
}
}
}
if removal_set.is_empty() {
return lines;
}
lines
.into_iter()
.enumerate()
.filter(|(idx, _)| !removal_set.contains(idx))
.map(|(_, line)| line)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -679,53 +343,6 @@ mod tests {
assert_eq!(result.len(), 2, "should merge font-based heading lines");
}
#[test]
fn test_strip_repeated_keeps_first_occurrence() {
// Simulate a repeated page header on 10 pages.
// Each page has a running header at y=750 and many unique body lines.
let mut lines = Vec::new();
for page in 1..=10u32 {
// Header at top
lines.push(make_line(
"VOICE OF SOUTH MARION May fifteen twenty twenty five",
10.0,
page,
750.0,
None,
));
// Body content — unique text per line per page (no digits to strip)
for j in 0..20u32 {
lines.push(make_line(
&format!(
"parcel r-{:04}-{:03} owner smith address oak street",
page * 100 + j,
page
),
10.0,
page,
600.0 - j as f32 * 15.0,
None,
));
}
}
let result = strip_repeated_lines(lines, 10);
// The header should appear exactly once (page 1)
let header_count = result
.iter()
.filter(|l| l.text().contains("VOICE OF SOUTH MARION"))
.count();
assert_eq!(header_count, 1, "repeated header should be kept once");
// First occurrence should be on page 1
let first_header = result
.iter()
.find(|l| l.text().contains("VOICE OF SOUTH MARION"))
.unwrap();
assert_eq!(first_header.page, 1, "first occurrence should be on page 1");
}
fn make_bold_line(text: &str, page: u32, y: f32) -> TextLine {
let mut item = make_item(text, 12.0, None);
item.is_bold = true;
+296
View File
@@ -85,6 +85,107 @@ impl PyPageOcrReasons {
}
}
/// Exact OCR model identity retained in page provenance.
#[pyclass(name = "OcrModelIdentity")]
#[derive(Clone)]
pub struct PyOcrModelIdentity {
#[pyo3(get)]
pub name: String,
#[pyo3(get)]
pub revision: String,
}
/// Per-page OCR processing timings.
#[pyclass(name = "OcrTimings")]
#[derive(Clone)]
pub struct PyOcrTimings {
#[pyo3(get)]
pub render_ms: u64,
#[pyo3(get)]
pub ocr_ms: u64,
#[pyo3(get)]
pub assembly_ms: u64,
}
/// Source, model, confidence, and fallback metadata for one page.
#[pyclass(name = "OcrPageProvenance")]
#[derive(Clone)]
pub struct PyOcrPageProvenance {
/// 1-indexed page number.
#[pyo3(get)]
pub page_number: u32,
/// "native", "ocr", or "fused".
#[pyo3(get)]
pub source: String,
#[pyo3(get)]
pub ocr_model: Option<PyOcrModelIdentity>,
#[pyo3(get)]
pub render_dpi: Option<f32>,
#[pyo3(get)]
pub ocr_confidence: Option<f32>,
#[pyo3(get)]
pub timings: PyOcrTimings,
#[pyo3(get)]
pub warnings: Vec<String>,
#[pyo3(get)]
pub hosted_recommended: bool,
}
/// Final Markdown and provenance for one page.
#[pyclass(name = "OcrPageResult")]
#[derive(Clone)]
pub struct PyOcrPageResult {
/// 1-indexed page number.
#[pyo3(get)]
pub page_number: u32,
#[pyo3(get)]
pub markdown: String,
#[pyo3(get)]
pub provenance: PyOcrPageProvenance,
}
/// Complete native/OCR Markdown output.
#[pyclass(name = "OcrPdfResult")]
#[derive(Clone)]
pub struct PyOcrPdfResult {
#[pyo3(get)]
pub markdown: String,
#[pyo3(get)]
pub pages: Vec<PyOcrPageResult>,
#[pyo3(get)]
pub page_count: u32,
#[pyo3(get)]
pub pages_recommended_for_ocr: Vec<u32>,
#[pyo3(get)]
pub pages_routed_to_ocr: Vec<u32>,
#[pyo3(get)]
pub pages_recommending_hosted: Vec<u32>,
#[pyo3(get)]
pub ocr_reasons_by_page: Vec<PyPageOcrReasons>,
#[pyo3(get)]
pub pages_with_tables: Vec<u32>,
#[pyo3(get)]
pub pages_with_columns: Vec<u32>,
#[pyo3(get)]
pub is_complex: bool,
#[pyo3(get)]
pub processing_time_ms: u64,
#[pyo3(get)]
pub render_time_ms: u64,
#[pyo3(get)]
pub ocr_time_ms: u64,
}
#[pymethods]
impl PyOcrPdfResult {
fn __repr__(&self) -> String {
format!(
"OcrPdfResult(pages={}, routed_to_ocr={:?}, recommending_hosted={:?})",
self.page_count, self.pages_routed_to_ocr, self.pages_recommending_hosted
)
}
}
// ---------------------------------------------------------------------------
// Classification wrapper (lightweight)
// ---------------------------------------------------------------------------
@@ -362,6 +463,101 @@ fn to_py_err(e: crate::PdfError) -> PyErr {
PyValueError::new_err(e.to_string())
}
struct PythonOcrOptions {
mode: String,
page_numbers: Option<Vec<u32>>,
password: Option<String>,
dpi: f32,
minimum_confidence: f32,
hosted_recommendation_confidence: f32,
model_directory: Option<String>,
offline: bool,
}
fn build_ocr_options(binding: PythonOcrOptions) -> PyResult<crate::vision::OcrPdfOptions> {
let mode = match binding.mode.trim().to_ascii_lowercase().as_str() {
"off" => crate::vision::OcrMode::Off,
"auto" => crate::vision::OcrMode::Auto,
"force" => crate::vision::OcrMode::Force,
_ => {
return Err(PyValueError::new_err(
"mode must be 'off', 'auto', or 'force'",
));
}
};
let mut options = crate::vision::OcrPdfOptions::new().mode(mode);
options.render.dpi = binding.dpi;
options.ocr.minimum_confidence = binding.minimum_confidence;
options.hosted_recommendation_confidence = binding.hosted_recommendation_confidence;
if let Some(pages) = binding.page_numbers {
options = options.page_numbers(pages);
}
if let Some(password) = binding.password {
options = options.password(password);
}
if let Some(directory) = binding.model_directory {
options.ocr.model_directory = Some(directory.into());
}
if binding.offline {
options.ocr.model_downloads = crate::vision::ModelDownloadPolicy::Offline;
}
Ok(options)
}
fn page_content_source_str(source: crate::vision::PageContentSource) -> String {
match source {
crate::vision::PageContentSource::Native => "native".into(),
crate::vision::PageContentSource::Ocr => "ocr".into(),
crate::vision::PageContentSource::Fused => "fused".into(),
}
}
fn to_py_ocr_result(result: crate::vision::OcrPdfResult) -> PyOcrPdfResult {
PyOcrPdfResult {
markdown: result.markdown,
pages: result
.pages
.into_iter()
.map(|page| {
let provenance = page.provenance;
PyOcrPageResult {
page_number: page.page_number,
markdown: page.markdown,
provenance: PyOcrPageProvenance {
page_number: provenance.page_number,
source: page_content_source_str(provenance.source),
ocr_model: provenance.ocr_model.map(|model| PyOcrModelIdentity {
name: model.name,
revision: model.revision,
}),
render_dpi: provenance.render_dpi,
ocr_confidence: provenance.ocr_confidence,
timings: PyOcrTimings {
render_ms: provenance.timings.render_ms,
ocr_ms: provenance.timings.ocr_ms,
assembly_ms: provenance.timings.assembly_ms,
},
warnings: provenance.warnings,
hosted_recommended: provenance.hosted_recommended,
},
}
})
.collect(),
page_count: result.page_count,
pages_recommended_for_ocr: result.pages_recommended_for_ocr,
pages_routed_to_ocr: result.pages_routed_to_ocr,
pages_recommending_hosted: result.pages_recommending_hosted,
ocr_reasons_by_page: to_py_page_ocr_reasons(result.ocr_reasons_by_page),
pages_with_tables: result.pages_with_tables,
pages_with_columns: result.pages_with_columns,
is_complex: result.is_complex,
processing_time_ms: result.processing_time_ms,
render_time_ms: result.render_time_ms,
ocr_time_ms: result.ocr_time_ms,
}
}
fn item_type_str(t: &ItemType) -> String {
match t {
ItemType::Text => "text".into(),
@@ -502,6 +698,99 @@ fn process_pdf_bytes(data: &[u8], pages: Option<Vec<u32>>) -> PyResult<PyPdfResu
Ok(to_py_result(result))
}
/// Process a PDF file through native extraction and selective OCR.
///
/// OCR defaults to ``auto`` and only initializes its external runtime and
/// model when native quality signals route at least one page. Page numbers
/// are 1-indexed. The GIL is released for the complete processing call.
#[pyfunction]
#[pyo3(signature = (
path,
*,
mode="auto",
page_numbers=None,
password=None,
dpi=150.0,
minimum_confidence=0.0,
hosted_recommendation_confidence=0.5,
model_directory=None,
offline=false
))]
#[allow(clippy::too_many_arguments)]
fn process_pdf_with_ocr(
py: Python<'_>,
path: String,
mode: &str,
page_numbers: Option<Vec<u32>>,
password: Option<String>,
dpi: f32,
minimum_confidence: f32,
hosted_recommendation_confidence: f32,
model_directory: Option<String>,
offline: bool,
) -> PyResult<PyOcrPdfResult> {
let options = build_ocr_options(PythonOcrOptions {
mode: mode.to_string(),
page_numbers,
password,
dpi,
minimum_confidence,
hosted_recommendation_confidence,
model_directory,
offline,
})?;
let result = py
.allow_threads(move || crate::vision::process_pdf_with_ocr(path, options))
.map_err(|error| PyValueError::new_err(error.to_string()))?;
Ok(to_py_ocr_result(result))
}
/// Process PDF bytes through native extraction and selective OCR.
///
/// See [`process_pdf_with_ocr`] for options and result semantics.
#[pyfunction]
#[pyo3(signature = (
data,
*,
mode="auto",
page_numbers=None,
password=None,
dpi=150.0,
minimum_confidence=0.0,
hosted_recommendation_confidence=0.5,
model_directory=None,
offline=false
))]
#[allow(clippy::too_many_arguments)]
fn process_pdf_with_ocr_bytes(
py: Python<'_>,
data: &[u8],
mode: &str,
page_numbers: Option<Vec<u32>>,
password: Option<String>,
dpi: f32,
minimum_confidence: f32,
hosted_recommendation_confidence: f32,
model_directory: Option<String>,
offline: bool,
) -> PyResult<PyOcrPdfResult> {
let options = build_ocr_options(PythonOcrOptions {
mode: mode.to_string(),
page_numbers,
password,
dpi,
minimum_confidence,
hosted_recommendation_confidence,
model_directory,
offline,
})?;
let data = data.to_vec();
let result = py
.allow_threads(move || crate::vision::process_pdf_with_ocr_mem(&data, options))
.map_err(|error| PyValueError::new_err(error.to_string()))?;
Ok(to_py_ocr_result(result))
}
/// Fast detection only — no text extraction or markdown.
#[pyfunction]
fn detect_pdf(path: &str) -> PyResult<PyPdfResult> {
@@ -704,6 +993,11 @@ fn extract_structure_elements_bytes(
fn pdf_inspector(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyPdfResult>()?;
m.add_class::<PyPageOcrReasons>()?;
m.add_class::<PyOcrModelIdentity>()?;
m.add_class::<PyOcrTimings>()?;
m.add_class::<PyOcrPageProvenance>()?;
m.add_class::<PyOcrPageResult>()?;
m.add_class::<PyOcrPdfResult>()?;
m.add_class::<PyPdfClassification>()?;
m.add_class::<PyTextItem>()?;
m.add_class::<PyStructureElement>()?;
@@ -713,6 +1007,8 @@ fn pdf_inspector(m: &Bound<'_, PyModule>) -> PyResult<()> {
m.add_class::<PyPagesExtractionResult>()?;
m.add_function(wrap_pyfunction!(process_pdf, m)?)?;
m.add_function(wrap_pyfunction!(process_pdf_bytes, m)?)?;
m.add_function(wrap_pyfunction!(process_pdf_with_ocr, m)?)?;
m.add_function(wrap_pyfunction!(process_pdf_with_ocr_bytes, m)?)?;
m.add_function(wrap_pyfunction!(detect_pdf, m)?)?;
m.add_function(wrap_pyfunction!(detect_pdf_bytes, m)?)?;
m.add_function(wrap_pyfunction!(classify_pdf, m)?)?;
+7
View File
@@ -237,6 +237,13 @@ pub trait OcrEngine: Send + Sync {
pages: &[RenderedPage],
options: &OcrOptions,
) -> Result<Vec<OcrPage>, Self::Error>;
/// Number of pages this engine can process concurrently in one
/// `recognize` call. The pipeline sizes its page batches from this so a
/// parallel engine is not starved by small chunks; `1` means sequential.
fn preferred_page_concurrency(&self) -> usize {
1
}
}
#[cfg(test)]
+429 -36
View File
@@ -1,11 +1,14 @@
//! PP-OCRv6 Small implementation backed by OAR and ONNX Runtime.
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Instant;
use image::RgbImage;
use oar_ocr::core::config::onnx::OrtSessionConfig;
use oar_ocr::oarocr::{OAROCRBuilder, OAROCR};
use oar_ocr::domain::tasks::TextDetectionConfig;
use oar_ocr::oarocr::{EdgeProcessor, TextCroppingProcessor};
use oar_ocr::predictors::{TextDetectionPredictor, TextRecognitionPredictor};
use oar_ocr::processors::BoundingBox;
use thiserror::Error;
@@ -70,17 +73,170 @@ pub enum OarOcrError {
Backend(#[from] oar_ocr::core::OCRError),
}
/// CPU PP-OCRv6 Small engine using OAR's detection and recognition pipeline.
/// Standard detection input cap. PP-OCR detection resizes each page so its
/// longest side fits this before inference; it is the PaddleOCR default and
/// is sufficient for ordinary body text at 150 DPI.
const DETECTION_LIMIT_STANDARD: u32 = 960;
/// Escalated detection input cap for dense fine-print pages. Beyond this the
/// measured recall plateaus while inference cost keeps growing.
const DETECTION_LIMIT_ESCALATED: u32 = 2560;
/// Hard ceiling protecting detection from out-of-memory on giant renders.
const DETECTION_MAXIMUM_SIDE: u32 = 4000;
/// Escalate only for pages dense with small text: at least this many detected
/// regions in the standard pass...
const ESCALATION_MINIMUM_REGIONS: usize = 80;
/// ...whose median height, at detection scale, is below this. Calibrated at
/// `unclip_ratio` 2.0 (the expansion inflates measured heights, so this
/// constant is coupled to [`detection_config`]): dense fine-print pages that
/// gain from escalation measure 12.014.2 px with 144+ regions; the nearest
/// non-gaining page above the region gate (an engineering drawing) measures
/// 15.7 px, and prose/typewriter pages measure 14.5 px+ with too few
/// regions to qualify at all.
const ESCALATION_MAXIMUM_MEDIAN_HEIGHT: f32 = 15.0;
/// One worker's model sessions: a standard-limit detector plus a recognizer,
/// and that worker's own lazily built escalated-limit detector.
/// Staged (detect, crop, recognize as separate calls) rather than OAROCR's
/// combined `predict` so an escalated page replaces only its detection pass —
/// recognition runs exactly once, on the final region set.
struct OcrWorker {
detector: TextDetectionPredictor,
recognizer: TextRecognitionPredictor,
/// Built on this worker's first dense fine-print page. `None` inside the
/// cell records a failed build so it is not retried per page.
escalated: std::sync::OnceLock<Option<TextDetectionPredictor>>,
}
/// CPU PP-OCRv6 Small engine using OAR's detection and recognition components.
///
/// Construction accepts only [`ModelPaths`] that have already passed
/// pdf-inspector's manifest size and SHA-256 verification. OAR's independent
/// model auto-download feature is deliberately not enabled.
#[derive(Debug)]
pub struct OarOcrEngine {
pipeline: OAROCR,
workers: Vec<OcrWorker>,
detection_path: PathBuf,
intra_threads: usize,
/// Present only when more than one worker exists; sized to match.
pool: Option<rayon::ThreadPool>,
model: ModelIdentity,
}
impl std::fmt::Debug for OarOcrEngine {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OarOcrEngine")
.field("workers", &self.workers.len())
.field("parallel", &self.pool.is_some())
.field("model", &self.model)
.finish()
}
}
/// Pages processed concurrently: one OAROCR pipeline (and its ONNX sessions)
/// per worker, because oar-ocr serializes each session behind a mutex.
/// Measured on CPU: workers beyond 3 stop scaling (memory-bandwidth bound)
/// and each worker is fastest with 2 intra-op threads.
fn pipeline_concurrency() -> usize {
let cores = std::thread::available_parallelism()
.map(std::num::NonZeroUsize::get)
.unwrap_or(1);
(cores / 4).clamp(1, 3)
}
fn intra_threads_per_pipeline(concurrency: usize) -> usize {
let cores = std::thread::available_parallelism()
.map(std::num::NonZeroUsize::get)
.unwrap_or(1);
if concurrency > 1 {
2
} else {
cores.min(4)
}
}
/// True when a standard-limit detection pass over a downscaled page shows
/// dense, small text: the page deserves a second pass at the escalated limit.
fn should_escalate_detection(
median_detection_height: f32,
region_count: usize,
downscale: f32,
) -> bool {
downscale < 1.0
&& region_count >= ESCALATION_MINIMUM_REGIONS
&& median_detection_height < ESCALATION_MAXIMUM_MEDIAN_HEIGHT
}
/// Median detected-region height in detection-input pixels: original-image
/// heights multiplied by the downscale detection applied.
fn median_detection_height(heights: &mut [f32], downscale: f32) -> f32 {
if heights.is_empty() {
return f32::MAX;
}
heights.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
let middle = heights.len() / 2;
let median = if heights.len().is_multiple_of(2) {
(heights[middle - 1] + heights[middle]) / 2.0
} else {
heights[middle]
};
median * downscale
}
/// Detection preprocessing config at a given input cap.
///
/// Supplying an explicit config suppresses OAROCR's "general" text-type
/// overrides, so every field the override would have set must be pinned
/// here to match what the combined pipeline ran with before the staged
/// split: score 0.3 and box 0.6 (equal to [`TextDetectionConfig`]'s
/// defaults) and unclip 2.0 (the default is 1.5 — leaving it would
/// silently shrink detection-box expansion and risk clipping edge glyphs).
fn detection_config(detection_limit: u32) -> TextDetectionConfig {
TextDetectionConfig {
limit_side_len: Some(detection_limit),
limit_type: Some(oar_ocr::processors::LimitType::Max),
max_side_len: Some(DETECTION_MAXIMUM_SIDE),
unclip_ratio: 2.0,
..Default::default()
}
}
fn build_detector(
detection: &std::path::Path,
detection_limit: u32,
intra_threads: usize,
) -> Result<TextDetectionPredictor, OarOcrError> {
Ok(TextDetectionPredictor::builder()
.with_config(detection_config(detection_limit))
.with_ort_config(ocr_session_config(intra_threads))
.build(detection)?)
}
fn build_workers(
detection: &std::path::Path,
recognition: &std::path::Path,
dictionary: &std::path::Path,
count: usize,
intra_threads: usize,
) -> Result<Vec<OcrWorker>, OarOcrError> {
let mut workers = Vec::with_capacity(count);
for _ in 0..count {
let detector = build_detector(detection, DETECTION_LIMIT_STANDARD, intra_threads)?;
let recognizer = TextRecognitionPredictor::builder()
.dict_path(dictionary)
.with_ort_config(ocr_session_config(intra_threads))
.build(recognition)?;
workers.push(OcrWorker {
detector,
recognizer,
escalated: std::sync::OnceLock::new(),
});
}
Ok(workers)
}
impl OarOcrEngine {
/// Loads PP-OCRv6 Small from a resolved, verified model set.
pub fn from_models(models: &ModelPaths) -> Result<Self, OarOcrError> {
@@ -89,36 +245,169 @@ impl OarOcrEngine {
let recognition = required_model(models, ModelArtifactKind::TextRecognition)?;
let dictionary = required_model(models, ModelArtifactKind::CharacterDictionary)?;
let pipeline = OAROCRBuilder::new(detection, recognition, dictionary)
.ort_session(ocr_session_config())
// Document line crops often have very different widths. Keeping
// CPU recognition batches at one avoids padding every crop to the
// widest line, reducing both inference work and peak memory.
.region_batch_size(1)
.build()?;
let concurrency = pipeline_concurrency();
let intra_threads = intra_threads_per_pipeline(concurrency);
let workers = build_workers(
detection,
recognition,
dictionary,
concurrency,
intra_threads,
)?;
let pool = if concurrency > 1 {
rayon::ThreadPoolBuilder::new()
.num_threads(concurrency)
.build()
.ok()
} else {
None
};
let model = ModelIdentity::new(models.manifest_id(), models.revision());
Ok(Self { pipeline, model })
Ok(Self {
workers,
detection_path: detection.to_path_buf(),
intra_threads,
pool,
model,
})
}
/// This worker's escalated-limit detector, built on first use.
fn escalated_detector<'w>(&self, worker: &'w OcrWorker) -> Option<&'w TextDetectionPredictor> {
worker
.escalated
.get_or_init(|| {
match build_detector(
&self.detection_path,
DETECTION_LIMIT_ESCALATED,
self.intra_threads,
) {
Ok(detector) => Some(detector),
Err(error) => {
log::warn!(
"escalated OCR detection unavailable, keeping standard pass: {error}"
);
None
}
}
})
.as_ref()
}
/// Detects text regions for one page: a standard-limit pass first, then —
/// for pages the standard limit demonstrably under-resolves — a second
/// pass at the escalated limit whose boxes replace the first. Pages whose
/// render dwarfs even the escalated limit skip the standard pass outright.
fn detect_boxes(
&self,
page: &RenderedPage,
image: &Arc<RgbImage>,
worker: &OcrWorker,
) -> Result<Vec<BoundingBox>, OarOcrError> {
let longest_side = page.width().max(page.height()) as f32;
// A page more than twice the standard limit loses over half its
// resolution before detection even runs; go straight to the escalated
// detector instead of paying a doomed standard pass.
if longest_side > (DETECTION_LIMIT_STANDARD * 2) as f32 {
if let Some(escalated) = self.escalated_detector(worker) {
log::debug!(
"page {}: direct escalated detection (render {longest_side}px)",
page.page(),
);
match detect_with(escalated, image, page.page()) {
Ok(boxes) => return Ok(boxes),
Err(error) => {
// Same degradation as the adaptive branch below: a
// failing escalated pass falls back to standard
// detection instead of failing the page outright.
// Return the standard boxes directly — the adaptive
// trigger would only re-invoke the detector that
// just failed (repeating an OOM on a dense page).
log::warn!(
"page {}: direct escalated detection failed, using standard pass: {error}",
page.page()
);
return detect_with(&worker.detector, image, page.page());
}
}
}
}
let detections = detect_with(&worker.detector, image, page.page())?;
// Dense fine-print pages (broadsheets, pricing sheets) lose most of
// their text when detection downscales them to the standard limit.
// When the standard pass shows many regions of tiny detection-scale
// height, rerun detection at the escalated limit.
let downscale = (DETECTION_LIMIT_STANDARD as f32 / longest_side).min(1.0);
let mut heights: Vec<f32> = detections.iter().map(polygon_height).collect();
let median = median_detection_height(&mut heights, downscale);
log::trace!(
"page {}: standard pass {} regions, median height {:.1}px at detection scale",
page.page(),
detections.len(),
median
);
if should_escalate_detection(median, detections.len(), downscale) {
log::debug!(
"page {}: escalating detection ({} regions, median height {:.1}px at detection scale)",
page.page(),
detections.len(),
median
);
if let Some(escalated) = self.escalated_detector(worker) {
match detect_with(escalated, image, page.page()) {
Ok(escalated_boxes) => return Ok(escalated_boxes),
Err(error) => {
log::warn!(
"page {}: escalated detection failed, keeping standard pass: {error}",
page.page()
);
}
}
}
}
Ok(detections)
}
fn recognize_page(
&self,
page: &RenderedPage,
options: &OcrOptions,
worker: usize,
) -> Result<OcrPage, OarOcrError> {
let started = Instant::now();
let image = rendered_page_to_rgb(page)?;
let result = self
.pipeline
.predict(vec![image])?
.into_iter()
.next()
.ok_or(OarOcrError::MissingPageResult { page: page.page() })?;
let worker = &self.workers[worker % self.workers.len()];
let image = Arc::new(rendered_page_to_rgb(page)?);
let boxes = self.detect_boxes(page, &image, worker)?;
// Reading order, matching what the combined pipeline produced.
let boxes = oar_ocr::processors::sort_quad_boxes(&boxes);
let mut spans = Vec::with_capacity(result.text_regions.len());
// Same rotation-aware cropping the combined pipeline uses.
let crops =
TextCroppingProcessor::new(true).process((Arc::clone(&image), boxes.clone()))?;
drop(image);
let recognizer = &worker.recognizer;
let mut spans = Vec::with_capacity(boxes.len());
let mut invalid_geometry = 0usize;
let mut missing_recognition = 0usize;
for region in result.text_regions {
let (Some(text), Some(confidence)) = (region.text, region.confidence) else {
for (bounding_box, crop) in boxes.iter().zip(crops) {
let Some(crop) = crop else {
invalid_geometry += 1;
continue;
};
// One crop per call: document line crops often have very
// different widths, and batching pads every crop to the widest
// line. Measured on CPU, batched recognition (even width-sorted)
// is 23× slower than per-crop calls.
let crop = Arc::try_unwrap(crop).unwrap_or_else(|shared| (*shared).clone());
let recognized = recognizer.predict(vec![crop])?;
let (Some(text), Some(confidence)) = (
recognized.texts.into_iter().next(),
recognized.scores.into_iter().next(),
) else {
missing_recognition += 1;
continue;
};
@@ -131,16 +420,21 @@ impl OarOcrEngine {
continue;
}
let polygon = region.dt_poly.as_ref().unwrap_or(&region.bounding_box);
let Some(polygon) = bounding_box_to_quad(polygon, page.width(), page.height()) else {
let Some(polygon) = bounding_box_to_quad(bounding_box, page.width(), page.height())
else {
invalid_geometry += 1;
continue;
};
spans.push(OcrSpan {
text: text.to_string(),
text,
polygon,
confidence,
orientation_degrees: region.orientation_angle,
// The combined pipeline's orientation_angle came from the
// text-line-orientation classifier, a model this engine has
// never loaded — it was structurally None before the staged
// split too (the staged/combined A/B was byte-identical).
// Region rotation is still carried by the polygon itself.
orientation_degrees: None,
});
}
@@ -174,12 +468,42 @@ impl OarOcrEngine {
}
}
fn ocr_session_config() -> OrtSessionConfig {
let available = std::thread::available_parallelism()
.map(std::num::NonZeroUsize::get)
.unwrap_or(1);
/// Runs one detector over one page image and returns its region polygons.
fn detect_with(
detector: &TextDetectionPredictor,
image: &Arc<RgbImage>,
page_number: u32,
) -> Result<Vec<BoundingBox>, OarOcrError> {
let mut result = detector.predict(vec![(**image).clone()])?;
if result.detections.is_empty() {
return Err(OarOcrError::MissingPageResult { page: page_number });
}
Ok(result
.detections
.swap_remove(0)
.into_iter()
.map(|detection| detection.bbox)
.collect())
}
/// Vertical extent of a detection polygon in original-image pixels.
fn polygon_height(polygon: &BoundingBox) -> f32 {
let mut min_y = f32::MAX;
let mut max_y = f32::MIN;
for point in &polygon.points {
min_y = min_y.min(point.y);
max_y = max_y.max(point.y);
}
if max_y > min_y {
max_y - min_y
} else {
0.0
}
}
fn ocr_session_config(intra_threads: usize) -> OrtSessionConfig {
OrtSessionConfig::new()
.with_intra_threads(available.min(4))
.with_intra_threads(intra_threads.max(1))
.with_inter_threads(1)
.with_parallel_execution(false)
}
@@ -226,10 +550,33 @@ impl OcrEngine for OarOcrEngine {
) -> Result<Vec<OcrPage>, Self::Error> {
validate_options(options)?;
pages
.iter()
.map(|page| self.recognize_page(page, options))
.collect()
let Some(pool) = self.pool.as_ref().filter(|_| pages.len() > 1) else {
return pages
.iter()
.map(|page| self.recognize_page(page, options, 0))
.collect();
};
pool.install(|| {
use rayon::prelude::*;
pages
.par_iter()
.map(|page| {
let worker = rayon::current_thread_index().unwrap_or(0);
self.recognize_page(page, options, worker)
})
.collect()
})
}
fn preferred_page_concurrency(&self) -> usize {
// Without a pool, recognition runs sequentially regardless of worker
// count — report that honestly so the pipeline doesn't render
// oversized page batches for parallelism that isn't there.
if self.pool.is_some() {
self.workers.len()
} else {
1
}
}
}
@@ -376,10 +723,13 @@ mod tests {
#[test]
fn cpu_session_budget_is_bounded_for_small_ocr_models() {
let config = ocr_session_config();
let concurrency = pipeline_concurrency();
let config = ocr_session_config(intra_threads_per_pipeline(concurrency));
assert!((1..=4).contains(&config.intra_threads.unwrap()));
assert_eq!(config.inter_threads, Some(1));
assert_eq!(config.parallel_execution, Some(false));
// Zero requests are clamped so a session always has a thread.
assert_eq!(ocr_session_config(0).intra_threads, Some(1));
}
fn page(format: RenderPixelFormat, stride: usize, pixels: Vec<u8>) -> RenderedPage {
@@ -493,4 +843,47 @@ mod tests {
)
.is_ok());
}
#[test]
fn escalation_fires_for_dense_fine_print_pages() {
// Measured cases (at unclip 2.0) that gain from escalation: dense
// tiled ad pages (12.314.2px, 158286 regions) and a dense pricing
// sheet (12.0px, 144 regions), all downscaled by the standard limit.
assert!(should_escalate_detection(14.2, 186, 0.55));
assert!(should_escalate_detection(13.1, 286, 0.55));
assert!(should_escalate_detection(12.3, 158, 0.55));
assert!(should_escalate_detection(12.0, 144, 0.55));
}
#[test]
fn escalation_skips_ordinary_pages() {
// Academic prose: too few regions (and tall enough at unclip 2.0).
assert!(!should_escalate_detection(14.5, 47, 0.58));
// Engineering drawing: many regions but tall enough text.
assert!(!should_escalate_detection(15.7, 205, 0.58));
// Typewriter scan: tall text, few regions.
assert!(!should_escalate_detection(17.5, 77, 0.55));
// Page not downscaled at all: escalation cannot add pixels.
assert!(!should_escalate_detection(9.0, 300, 1.0));
}
#[test]
fn median_detection_height_scales_and_handles_empty() {
let mut heights = vec![30.0, 10.0, 20.0];
assert_eq!(median_detection_height(&mut heights, 0.5), 10.0);
// Even counts average the two middle values instead of picking the
// upper one, so borderline pages don't skew away from escalation.
let mut even = vec![10.0, 12.0, 14.0, 30.0];
assert_eq!(median_detection_height(&mut even, 1.0), 13.0);
let mut empty: Vec<f32> = Vec::new();
assert_eq!(median_detection_height(&mut empty, 0.5), f32::MAX);
}
#[test]
fn concurrency_derivations_stay_in_bounds() {
let concurrency = pipeline_concurrency();
assert!((1..=3).contains(&concurrency));
assert!(intra_threads_per_pipeline(2) == 2);
assert!((1..=4).contains(&intra_threads_per_pipeline(1)));
}
}
+25 -1
View File
@@ -31,6 +31,17 @@ use super::{
/// Bounds live rendered-page memory while preserving small OCR batches.
const OCR_PAGE_CHUNK_SIZE: usize = 4;
/// Pages rendered and held in memory per OCR batch. A parallel engine gets
/// three waves of work per batch so its workers are not starved at chunk
/// barriers; a sequential engine keeps the small memory-bounding default.
/// Engine-reported concurrency is a trait hook, so it is clamped before
/// sizing anything from it — this helper exists to bound rendered-page
/// memory and must not let an engine inflate it arbitrarily.
fn ocr_page_chunk_size(engine_concurrency: usize) -> usize {
const MAX_ENGINE_CONCURRENCY: usize = 8;
(engine_concurrency.clamp(1, MAX_ENGINE_CONCURRENCY) * 3).max(OCR_PAGE_CHUNK_SIZE)
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct OcrEngineCacheKey {
model_root: PathBuf,
@@ -443,7 +454,7 @@ where
let mut render_time_ms = 0u64;
let mut ocr_time_ms = 0u64;
for chunk in routed_pages.chunks(OCR_PAGE_CHUNK_SIZE) {
for chunk in routed_pages.chunks(ocr_page_chunk_size(engine.preferred_page_concurrency())) {
let native_chunk = chunk
.iter()
.map(|page_number| {
@@ -927,6 +938,19 @@ pub enum OcrPipelineError {
mod tests {
use super::*;
#[test]
fn chunk_size_scales_with_engine_concurrency() {
// Sequential engines keep the memory-bounding default.
assert_eq!(ocr_page_chunk_size(1), OCR_PAGE_CHUNK_SIZE);
// Parallel engines get three waves of work per batch.
assert_eq!(ocr_page_chunk_size(3), 9);
assert_eq!(ocr_page_chunk_size(2), 6);
// Engine-reported concurrency is untrusted: clamp before sizing so a
// misbehaving engine cannot inflate rendered-page memory or overflow.
assert_eq!(ocr_page_chunk_size(0), OCR_PAGE_CHUNK_SIZE);
assert_eq!(ocr_page_chunk_size(usize::MAX), 24);
}
struct TrackingRenderer {
batches: Mutex<Vec<Vec<u32>>>,
}
+22
View File
@@ -3495,6 +3495,28 @@ fn test_extract_pages_markdown_basic() {
assert!(!result.pages[0].needs_ocr);
}
#[test]
fn test_extract_pages_markdown_keeps_line_based_tables() {
// The per-page path (used by every `--ocr auto` run) once passed an
// empty line slice to markdown conversion, silently dropping every
// table that only the line-based detector finds. This fixture's table
// is rule-anchored: it must survive the pages API exactly as it does
// the whole-document API.
let buf = std::fs::read("tests/fixtures/bits_pilani_feedback.pdf").unwrap();
let result = extract_pages_markdown_mem(&buf, None).unwrap();
let all_markdown: String = result
.pages
.iter()
.map(|p| p.markdown.as_str())
.collect::<Vec<_>>()
.join("\n");
assert!(
all_markdown.contains("|BIO|"),
"line-based table rows missing from pages API output"
);
}
#[test]
fn test_extract_pages_markdown_uses_document_wide_folio_context() {
let pdf = make_recurring_contextual_folio_pdf();
+16 -4
View File
@@ -2,9 +2,19 @@
# BePriced?
*Commercial real estate pricing* **C O M M E R C I A L R E A L E S T A T E** pricingisliketheweather:everyonetalks *needs disciplined and systematic*about it, but few understand it. Most observers base “appropriate” real estate *analysis of the data.* pricing on historical norms. The cap rate—anindicatorofvaluerelativetosta- bilized net operating income (NOI) before capital expenditures, tenant improvement,andleasingcommissions— isthemostcommonlyusedmetricofreal estate pricing. But cap rates have been largelyunresponsivetoalternativeratesof return available to investors, with the **P E T E R L I N N E M A N** exception of BBB bonds, throughout
*Commercial real estate pricing*
8 4 Z E L L / L U R I E R E A L E S T A T E C E N T E R
*needs disciplined and systematic*
*analysis of the data.*
**C O M M E R C I A L R E A L E S T A T E** pricingisliketheweather:everyonetalks about it, but few understand it. Most observers base “appropriate” real estate pricing on historical norms. The cap rate—anindicatorofvaluerelativetosta- bilized net operating income (NOI) before capital expenditures, tenant improvement,andleasingcommissions— isthemostcommonlyusedmetricofreal estate pricing. But cap rates have been largelyunresponsivetoalternativeratesof return available to investors, with the exception of BBB bonds, throughout
C E N T E R
**P E T E R L I N N E M A N**
8 4 Z E L L / L U R I E R E A L E S T A T E
**Table I:** Cap rate correlations **Cap Rate Correlation With:*** **BBB Corp** **10-Year Bond Yield S&P Dividend** **Treasury (10-15 yr) Yield** Multifamily 0.187 0.771 0.068 Industrial-0.221 0.748-0.307 CBD Office-0.449 0.694-0.458 Retail-0.181 0.649-02.58
@@ -13,9 +23,11 @@
12 10 8 Percent 6 4 2 1982 1986 1990 1994 1998 2002 2006
Apartment Retail ndustrial 10-yr reasury CBD Office
most of the past twenty-five years (Table presented in Figure 2 with an eighteen-
most of the past twenty-five years (Table
I). Such a relationship defies investment theory,asrealestatepricingshouldchange as property risks and the returns of alter- nativeinvestmentschange. Figure1displaysNCREIFcapratesby property type compared to the ten-year Treasury yield. Because the National Council of Real Estate Investment Fiduciaries (NCREIF) cap rate data is seriouslyflawedduetoappraisallags,itis
presented in Figure 2 with an eighteen- monthlag.Thisdataprovidesanoverview ofthepricingofinstitutionalqualityreal estate.Figure2reflectsthesecapratesnet of the ten-year Treasury yield. Since cap rate spreads are highly correlated across propertytypes(TableII),wecanspeakof “cap rates” without reference to property type with little loss of insight. Cap rate spreadswerenegativeintheearlytomid- 1980s, when purchasing real estate was
I). Such a relationship defies investment monthlag.Thisdataprovidesanoverview theory,asrealestatepricingshouldchange ofthepricingofinstitutionalqualityreal as property risks and the returns of alter-estate.Figure2reflectsthesecapratesnet nativeinvestmentschange. of the ten-year Treasury yield. Since cap Figure1displaysNCREIFcapratesby rate spreads are highly correlated across property type compared to the ten-year propertytypes(TableII),wecanspeakof Treasury yield. Because the National “cap rates” without reference to property Council of Real Estate Investment type with little loss of insight. Cap rate Fiduciaries (NCREIF) cap rate data is spreadswerenegativeintheearlytomid- seriouslyflawedduetoappraisallags,itis 1980s, when purchasing real estate was
R E V I E W 8 5
**Figure 2:** Capratespreadsover10-yearTreasury
+44
View File
@@ -77,6 +77,50 @@ class TestProcessPdfBytes:
assert result.markdown is not None
# ---------------------------------------------------------------------------
# process_pdf_with_ocr / process_pdf_with_ocr_bytes
# ---------------------------------------------------------------------------
class TestProcessPdfWithOcr:
def test_off_mode_has_full_provenance_without_external_runtimes(self):
result = pdf_inspector.process_pdf_with_ocr(
fixture_path("thermo-freon12.pdf"), mode="off"
)
assert result.page_count == 3
assert len(result.pages) == 3
assert result.pages_routed_to_ocr == []
assert all(page.provenance.source == "native" for page in result.pages)
assert all(page.provenance.ocr_model is None for page in result.pages)
assert result.markdown
assert "OcrPdfResult" in repr(result)
def test_auto_mode_skips_external_runtimes_for_clean_text(self):
result = pdf_inspector.process_pdf_with_ocr_bytes(
fixture_bytes("thermo-freon12.pdf")
)
assert result.pages_routed_to_ocr == []
assert result.render_time_ms == 0
assert result.ocr_time_ms == 0
def test_selected_pages_are_one_indexed(self):
result = pdf_inspector.process_pdf_with_ocr(
fixture_path("thermo-freon12.pdf"), mode="off", page_numbers=[2]
)
assert [page.page_number for page in result.pages] == [2]
def test_rejects_invalid_options(self):
with pytest.raises(ValueError, match="mode must be"):
pdf_inspector.process_pdf_with_ocr(
fixture_path("thermo-freon12.pdf"), mode="sometimes"
)
with pytest.raises(ValueError, match="page 0"):
pdf_inspector.process_pdf_with_ocr(
fixture_path("thermo-freon12.pdf"),
mode="off",
page_numbers=[0],
)
# ---------------------------------------------------------------------------
# detect_pdf / detect_pdf_bytes
# ---------------------------------------------------------------------------
+2 -2
View File
@@ -724,7 +724,7 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
[[package]]
name = "pdf-inspector"
version = "1.14.2"
version = "1.15.0"
dependencies = [
"env_logger",
"include_dir",
@@ -740,7 +740,7 @@ dependencies = [
[[package]]
name = "pdf-inspector-wasm"
version = "1.14.2"
version = "1.15.0"
dependencies = [
"console_error_panic_hook",
"js-sys",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "pdf-inspector-wasm"
version = "1.14.2"
version = "1.15.0"
edition = "2021"
authors = ["Firecrawl Team"]
description = "Browser WebAssembly bindings for pdf-inspector"