Compare commits

...
Author SHA1 Message Date
Abimael Martell ca7994d686 Revert "docs(site): add hero language playground"
This reverts commit 16df5fcf07.
2026-07-16 22:52:42 -07:00
Abimael Martell 16df5fcf07 docs(site): add hero language playground 2026-07-16 22:34:17 -07:00
Abimael Martell 14fb1a9296 docs(site): feature rust core in hero 2026-07-16 21:45:07 -07:00
Abimael Martell dcb143eee3 docs(site): keep hero install command inline 2026-07-16 21:41:30 -07:00
Abimael Martell d2b9b6559a docs(site): remove hero terminal scrollbar 2026-07-16 21:38:31 -07:00
Abimael Martell 5262c5fb6a docs(site): align footer wordmark 2026-07-16 21:35:05 -07:00
Abimael Martell 06724435a0 docs(site): emphasize package registries 2026-07-16 18:08:25 -07:00
Abimael Martell d17bba0780 docs(site): lead with node and cli 2026-07-16 18:01:51 -07:00
Abimael Martell 9fc674c127 docs(site): redesign open-source project page 2026-07-16 17:54:11 -07:00
Abimael Martell a910b7df1d docs(benchmark): refresh parser comparison (#178)
* docs: refresh benchmark comparison

* docs(site): refresh benchmark section

* docs: reframe benchmark positioning

* docs: focus benchmark positioning on best fit
2026-07-16 17:43:53 -07:00
Abimael Martell 15c0b22093 test(bench): probe optional backend evidence (#176)
* test(bench): probe optional backend evidence

* fix(bench): accept native stext pages
2026-07-16 15:25:37 -07:00
Abimael Martell 0c06dac976 test(bench): compare OpenDataLoader builds (#175)
* test(bench): compare OpenDataLoader builds

* docs(bench): keep reference comparisons generic

* fix(bench): keep regression gates complete

* fix(bench): clarify missing reference gates

* fix(bench): validate nonnegative limits

* fix(bench): isolate prediction runs

* chore(bench): refresh review
2026-07-16 14:25:25 -07:00
Abimael Martell 64a0930f9f feat(layout): order image-anchored regions (#174)
* feat(layout): order image-anchored regions

* fix(layout): preserve region flow boundaries

* fix(layout): gate image-backed column flows
2026-07-16 12:37:54 -07:00
Abimael Martell c726a92435 feat(tables): score competing line-table candidates (#177)
* feat(tables): score competing line-table candidates

* fix(tables): validate competing hypotheses

* fix(tables): preserve multiline open-edge headers

* fix(tables): recover two-column open-edge grids

* fix(tables): retain multiple open-edge grids
2026-07-16 11:32:37 -07:00
Abimael Martell 1b5ec414a4 feat(tables): compete normalized table and chart evidence (#172)
* feat(tables): compete normalized table and chart evidence

* fix(tables): preserve normalized hypotheses
2026-07-16 03:15:05 -07:00
Abimael Martell a0a6a445bf feat(tables): infer columns from horizontal-rule text anchors (#171)
* feat(tables): infer columns from horizontal-rule text anchors

* fix(tables): scope sparse-rule vertical evidence

* fix(tables): validate sparse-rule evidence by content

* fix(tables): preserve independent line-table regions

* fix(tables): isolate sparse-rule regions
2026-07-16 01:58:21 -07:00
Abimael Martell 4c71feb2f1 feat(markdown): classify document heading sequences (#170)
* feat(markdown): classify document heading sequences

* fix(markdown): harden heading sequence evidence

* fix(markdown): validate fixed-size sidebar evidence

* fix(markdown): harden sidebar sequence guards
2026-07-15 23:07:59 -07:00
Abimael Martell 660ffe4a0c feat(markdown): gate chart pages before table detection (#169)
* feat(markdown): add chart-aware layout gating

* fix(markdown): keep chart labels in separator zones

* fix(markdown): harden chart layout gating

* fix(markdown): order chart page blocks by stream

* fix(markdown): narrow parallel prose rejection

* fix(markdown): require cross-row prose evidence

* fix(markdown): classify chart labels by geometry

* fix(markdown): harden chart block ordering

* fix(markdown): cache chart blocks and retry tables

* fix(markdown): preserve chart order with physical bands
2026-07-15 21:08:58 -07:00
Abimael Martell b084769fda feat(markdown): add fidelity output profile (#168) 2026-07-15 17:18:16 -07:00
Abimael MartellandClaude Fable 5 f741e49dec fix(headings): all-bold single words qualify as headings when standalone (#167)
Single-word bold section headings ('Replace', 'Trash', 'Instructions')
required a paragraph break before AND after, but headings hug their
section's first paragraph — the break-after almost never exists. A
standalone all-bold single word (>=4 chars, paragraph break before or
page top) now classifies; mixed bold lead-ins ('Note: ...') stay
excluded via all_bold.

opendataloader-bench: 0.8567 -> 0.8575, MHS 0.773 -> 0.776; docs 145
+0.118, 069 +0.112 (net of one cover-page layout shuffle at -0.066
where the new output is semantically closer to GT). pdf-evals: 66
snapshots, composite 0.5864 -> 0.5883, sole >0.02 mover positive.
p1244/thermo fixture snapshots regenerated ('Instructions' un-fuses
from its body paragraph — the intended behavior).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 14:44:46 -07:00
Abimael MartellandClaude Fable 5 0f898a18fb fix(headings): digit-only lines do not define heading tiers (#166)
* fix(headings): digit-only lines do not define heading tiers

A large bold page number (14pt folio over 11pt body) claimed tier 0:
every real heading demoted one level document-wide, and the bold-size
fallback (which requires an empty tier list) was blocked for documents
whose headings match body size.

Bench-neutral (MHS scores relative hierarchy); pdf-evals: 18 docs get
their heading levels back (#### -> ###), semantic composite +0.0006,
no percentile down.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(headings): exclude digit-only lines from the bold fallback tier pass too

The exclusion in the main pass wasn't enough: with the page-number
tier gone, the bold fallback re-collected the same bold folio. Also
regenerates the thermo-freon12 snapshot (cosmetic churn on the
scrambled legend fixture).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 13:42:41 -07:00
Abimael MartellandClaude Fable 5 c908e33b39 fix(fonts): remap misnamed TeXCMMathsSymbols glyphs to their true symbols (#165)
IntechOpen-family academic PDFs embed Computer Modern math symbol
subsets whose glyphs are misnamed after Latin lookalikes (equal →
/onequarter, plus → /thorn, parens → /eth //Thorn) — and the generated
ToUnicode faithfully propagates the wrong names, so formulas decode as
'S ¼ kB þ 1' instead of 'S = kB + 1'.

Remap the observed misnames, gated strictly on the TeXCMMathsSymbols
base font (subset prefix stripped) so genuine fractions and thorns in
text fonts are untouched. Known limitation: a sibling subset misnames
the slash as /onequarter too, so an occasional '/' renders as '=' —
still strictly better than the previous mojibake.

Affects 4 bench PDFs (028/031 +0.001-0.004 NID) and zero pdf-evals
snapshots.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 13:19:34 -07:00
Abimael MartellandClaude Fable 5 4aa2c0c208 fix(headings): rescue wrapped bold headings on interleaved column pages (#164)
* fix(headings): rescue wrapped bold headings on interleaved column pages

Report pages whose columns can't be detected (6pt gutters) interleave
both columns' lines, which breaks every whitespace signal the bold
heading heuristic relies on: para_threshold inflates to ~3x line
height and a wrapped heading's own internal line gap defeats isolation.
'9.5. Adapting to the New Normal: Changing / Business Models' merged
into the following paragraph.

Four changes:
- merge_wrapped_bold_heading_groups: 2-3 consecutive all-bold
  body-size lines merge into one line when the group is isolated
  (column-locally, judged by x-overlapping lines only) or starts with
  a section number.
- Section-numbered all-bold lines ('9.5. ...') classify as headings
  without the standalone/isolation score gate.
- Line unfusing extends to uppercase-start continuations, gated on a
  bold-style mismatch between the runs (a bold heading beside regular
  body text) — same-style label rows stay joined.
- The unfuse line-side wordiness requirement drops to 2 words so a
  wrapped heading's short last line ('Business Models') still splits
  from the neighboring column.

opendataloader-bench: overall 0.8554 -> 0.8576, MHS 0.769 -> 0.777;
docs 037 +0.161, 111 +0.157, 039 +0.091, 198 +0.028, none down.
pdf-evals: 63 snapshots, composite 0.5952 -> 0.5964, sole >0.02 mover
positive. thermo-freon12 snapshot regenerated (cosmetic churn on an
already-scrambled 3-column legend).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(headings): review follow-ups — multi-component section numbers, wholly-bold line gate

Single '1. ' prefixes are ordered list items and no longer bypass
isolation; the uppercase unfuse requires the whole line bold (a
heading), not merely its last run, so mixed bold-label/value rows
stay joined.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 13:00:54 -07:00
Abimael MartellandClaude Fable 5 31918ff62f fix(layout): unfuse independent column runs sharing a baseline (#163)
* fix(layout): unfuse independent column runs sharing a baseline

Two-column report pages with charts fused headings into the adjacent
column's body text: the columns' ~6pt gutter is below what histogram
valley detection can safely use, so the page grouped single-column and
same-baseline items from both columns joined into one line ('6.2.
Expectations for Re-Hiring Employees' + mid-sentence text, killing
MHS and NID on the whole survey-report doc family).

Three changes:
- Line grouping splits same-baseline runs separated by a wide void
  (>3x font size, >=30pt) when the incoming run starts lowercase
  (mid-sentence continuation from another column) and both sides are
  multi-word prose. TOC page numbers, dot leaders, and table cells
  (numbered/capitalized) stay joined.
- Column detection is blind to chart-region text (tight 2pt bounds —
  wider padding ate rows adjacent to charts), via a chart-aware line
  grouping variant wired from the markdown pipeline.
- validate_and_build_columns computes its vertical span from
  histogram-eligible items only, so full-width captions no longer sink
  the overlap ratio for partial-page column regions.

opendataloader-bench: overall 0.8532 -> 0.8554, MHS 0.761 -> 0.769;
doc 038 +0.434, no regressions. pdf-evals: 34 snapshots change,
semantic composite wash (0.5749 -> 0.5748), no per-doc mover >0.015.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(layout): review follow-ups — chart-aware band-split grouping, single chart scan per page

Band-split pages now route through the chart-aware grouping too, and
the band loop reuses the precomputed page_chart_map instead of
re-scanning the rect list per page.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 11:58:14 -07:00
Abimael MartellandClaude Fable 5 4c3330f93a fix(headings): bold-size fallback tiers when nothing clears the ratio gate (#162)
* fix(headings): bold-size fallback tiers when nothing clears the ratio gate

Books often set section headings barely above body size (11pt bold
over 10pt text). Nothing cleared the 1.2x heading-tier ratio gate, so
tiers stayed empty and every bold heading defaulted to H2 — H1 was
unreachable for the whole document.

When no size clears the gate, build tiers from bold line sizes >=1.05x
body, and let tier matches through detect_header_level down to that
ratio. Documents with real (>=1.2x) tiers are untouched.

Bench-neutral by construction (the MHS metric scores relative
hierarchy, not absolute levels); pdf-evals semantic composite +0.004
on the 11 affected docs with all percentiles up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(headings): require boldness for sub-gate tier matches

Review follow-up: fallback tiers come from bold lines, so honoring
them for non-bold text at the same size would promote captions.
detect_header_level now takes is_bold and only matches tiers below
the 1.2x gate for bold lines; >=1.2x matches stay bold-agnostic.
Also restores the >=1.2x tier-match loop the refactor dropped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(headings): judge line boldness by character mass

Review follow-up: a heading with an unbold section-number prefix
('4. ' + bold title) failed the first-item boldness test. Judge the
line by bold character mass instead, at all three call sites.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 10:14:37 -07:00
Abimael MartellandClaude Fable 5 7d108cdff1 fix(extractor): use center-y for off-box link filtering (#161)
Link items carry an annotation rect, so y is a box edge — unlike text
items, where y is a baseline. Testing rect-bottom dropped partially
visible links whose bottom edge dipped past the tolerance. Follow-up
to a #160 review comment.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 01:14:38 -07:00
Abimael MartellandClaude Fable 5 c4cbf49f44 fix(extractor): clip page content to the visible page box (#160)
* fix(extractor): clip page content to the visible page box

Single-page extracts and imposed spreads keep neighboring pages'
content in the stream, positioned outside the CropBox. Extracting it
appends invisible sections to the page, scrambles NID, and poisons
font statistics (heading tiers built from off-page text).

Clip items (by center), and — only when off-page text was actually
found — rects and lines (by overlap) to CropBox-else-MediaBox, walking
page-tree inheritance. Rotated pages are left unclipped: their item
coordinates are already transformed out of box space. Degenerate boxes
(<1 inch) are ignored.

opendataloader-bench: overall 0.8445 -> 0.8537, NID +0.008,
MHS +0.013; six docs up (best +0.426), none down.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(extractor): guard page-box clipping with coherence and straddle checks

Two real-document counterexamples: curved display text leaves short
glyph fragments with artifact coordinates outside the box (judge by
character mass, not item count), and some PDFs compute inflated
coordinates for visible body text (an off-page item continuing an
on-page baseline means our transform model is wrong there — skip).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(extractor): clip off-box link annotations when page text was clipped

Review follow-up: annotations from the neighboring page bypassed the
filter. Form fields are left as-is — they're document-scoped and rare
on imposed spreads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 01:07:52 -07:00
Abimael MartellandClaude Fable 5 2ed152e49b fix(tables): recognize chart-bar clusters and mask their text from table detection (#159)
Bar charts drawn as filled rects read as cell rects or aligned text:
the cell-rect fallback gridded their axis labels into phantom tables,
and when rect paths rejected them, hint regions and the gap-histogram
heuristic re-gridded the same text. On survey-report pages this
scrambled reading order and swallowed section headings.

Detection (is_chart_bar_cluster): the dominant equal-breadth rect
family arranged in >=2 spaced positions (bars; cell rects touch),
data-driven extent variation (>=1.3x), no same-offset/same-extent
partners across positions (grid rows pair up, chart segments don't),
and only numeric labels inside. Mirrored predicate covers horizontal
bar charts.

Chart clusters are skipped in detect_tables_from_rects (no table, no
hint), and a new detect_chart_regions pass lets the markdown pipeline
pre-claim chart items so heuristic/line/column detection and the
merged-band retry all skip them — the text flows out as plain lines.

opendataloader-bench: overall 0.8389 -> 0.8446, TEDS 0.699 -> 0.708,
MHS 0.742 -> 0.750, NID 0.889 -> 0.894; 7 docs up (best +0.459), none
down. pdf-evals changed-set composite +0.013, TEDS +0.036.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 00:06:17 -07:00
Abimael MartellandClaude Fable 5 e48e34dfe9 docs(tables): document the stacked-box 6-rect precision gate (#158)
* docs(tables): document the stacked-box 6-rect precision gate

Review follow-up on #157: 3-5 box stacks never reach the stacked-box
fallback (the main loop needs >=6-rect clusters on a >=6-rect page).
Routing smaller clusters through the detector was implemented and
measured: zero opendataloader-bench movement and four pdf-evals
regressions (striped bullet lists, wrapped regulation text, stats-table
columns) across three guard iterations — with 3-5 boxes the anti-prose
guards have too little signal. Keep the gate, document it at the call
site, and pin the behavior with an end-to-end test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* test: exercise the cluster gate, not just the page gate

Review follow-up: the pinned test's 3-rect page exited at the 6-rect
page gate before reaching the cluster minimum it documents. Scattered
unrelated rects now push the page past the page gate while the 3-box
stack stays below the cluster minimum.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 22:52:49 -07:00
Abimael MartellandClaude Fable 5 3244547b3f feat(tables): detect single-column stacked-box tables (#157)
A framework list drawn as a vertical stack of boxes (one short title
per box) scored TEDS 0 and worse, ran into the surrounding prose as a
single paragraph: the grid path rejects one-column rect structures by
design (needs >=3 x-edges).

Add a stacked-box fallback after grid and row-stripe detection: >=3
x-aligned, same-width, same-height boxes forming a contiguous vertical
stack, each holding one short text run, become a single-column table.

Guards against striped prose and grid fragments (all unit-tested):
- boxes flanked by rects or text at their y-level are one column of a
  wider structure — bail to the grid/cell-rect paths
- multiple separated text runs per box = striped multi-column content
- prose rows: function-word-dense cells averaging >60 chars
- sentence continuation across rows (trailing comma / open + lowercase)
- numbered/lettered list items stay lists

opendataloader-bench: overall 0.8362 -> 0.8389, TEDS 0.675 -> 0.699,
target doc +0.553, no other doc moved.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 22:26:22 -07:00
Abimael MartellandClaude Fable 5 2f5a8923dd fix(layout): veto side-by-side split that cleaves a rect table (#156)
A 4-column compliance table (labels + Small/Medium/Large) was emitted
as two separate tables: split_side_by_side read the text gap between
the ruled and remaining columns as a page-layout gutter, and each band
then detected its own fragment.

Before accepting a side-by-side split, check rect clusters near the
boundary: if a table-shaped cluster spans it (or ends at it with
cell-like text row-aligned beyond), and those table rows account for
the majority of far-side text, the split runs through a table — veto
it. The majority guard keeps legitimate splits on pages where a figure
spans two prose columns.

opendataloader-bench: overall 0.8306 -> 0.8362, TEDS 0.656 -> 0.675,
target doc +0.506, no regressions.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 21:46:38 -07:00
Abimael MartellandClaude Fable 5 20bb22aa3c fix(tables): flatten page-number tables of contents instead of gridding (#142)
* fix(tables): flatten page-number tables of contents instead of gridding

A title-based contents page ("About the Publisher  vii", "Experiment #1
… 3") with no dot leaders and no section numbers was detected as a
2-column data table and rendered as a markdown grid, scrambling the
linear reading order (a top cause of NID loss on affected docs) and
scoring 0 on table structure.

Add is_page_number_toc: a narrow (2-3 col) list whose last column is
mostly page numbers (short integers or roman numerals) that are mostly
non-decreasing, with a text-title first column and NO header row (a
TOC's first row is already an entry). Such tables now route through the
existing flat-list TOC renderer.

The no-header + narrow-width + monotonic guards keep real data tables
intact — e.g. a 4-column regional table, or a 2-column "Mineral | CEC"
table with a header row and ascending values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs: bump reading-order (NID) benchmark to 0.89

Reflects the phantom-TOC fix in this PR: NID 0.88 -> 0.89 on the
200-doc benchmark. Other cells are unchanged at 2-decimal precision.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* review: canonical roman validation, real-first-row header check, roman page cells

- page_number_value now requires a *canonical* roman numeral (re-encode
  and compare), so words like "civil"/"mix"/"ill" are no longer parsed
  as page numbers.
- The no-header guard checks the actual first row's last cell instead of
  the first non-empty one, so a blank header cell ("Category | ") still
  rejects the TOC heuristic.
- format::is_page_number_cell recognizes canonical roman numerals, so
  roman front-matter pages (vii, ix) get proper title/page separation in
  the flat TOC list.
- Fix the non-monotonic test to use 5 rows so it exercises the
  monotonicity guard rather than the row-count early return; add
  roman-lookalike and blank-header rejection tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* review: share roman helper, widen length to 8, require page-span for TOC

- Extract canonical_roman_value + to_roman_lower into tables/mod.rs and
  use them from both the TOC detector and the formatter, removing the
  duplicated mapping/loop and keeping them in sync. The shared helper
  accepts ≤8 chars, so longer front-matter numerals (xxxviii) flatten
  consistently on both sides.
- Add a page-span guard to is_page_number_toc: real page numbers skip
  through the document (range >> entry count), so a dense consecutive
  ordinal/rank/ID column (1,2,3,…) is rejected — monotonicity alone did
  not separate those data tables from contents.

Costs ~0.001 aggregate on the benchmark (NID 0.888->0.887) for the added
precision; still a clear win over baseline (NID 0.883, TEDS unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* review: recover consecutive-page TOCs via a title signal

The strict page-span rule rejected legitimate one-page-per-entry TOCs
(range ~= entry count). Relax it: accept any page sequence with a gap
(nearly all real contents). Only a *perfectly dense* consecutive run —
which rank/ID/ordinal columns produce, but a chapter-per-page TOC can
too — falls back to a title signal: flatten when the first-column
entries average multi-word headings, keep as a table when they are the
short single-word labels typical of leaderboards/ID lists.

Recovers the ~0.001 the range-only rule cost (NID back to 0.888) while
still rejecting dense ordinal data tables.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 21:09:45 -07:00
Abimael MartellandClaude Fable 5 673fbe998f docs(registries): add Features and benchmark to crates.io/PyPI/npm pages (#155)
Concise Features list and the opendataloader-bench comparison table on
each registry readme, adapted per ecosystem. Bump all three versions
(crate 0.1.6, python 0.2.5, npm 1.11.1) to republish the pages.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 20:32:29 -07:00
Abimael MartellandClaude Fable 5 c80bedf4bd feat(npm): split platform binaries into optionalDependencies (1.11.0) (#154)
* feat(npm): split platform binaries into optionalDependencies (1.11.0)

The single package bundled all three .node binaries (17.6 MB unpacked)
so every install downloaded every platform. Publish one package per
platform (@firecrawl/pdf-inspector-{linux-x64-gnu,darwin-arm64,
win32-x64-msvc}) holding just its binary; the napi-generated loader
already falls back to exactly these names. Main package drops *.node
from files (8.5 kB tarball) and pins the platform packages as
optionalDependencies, re-stamped to the exact version at publish time.

Publish workflow gains a workflow_dispatch fallback and per-package
already-published checks so partial releases can be retried.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* docs(npm): document Windows support and platform packages; drop stale napi.package.name

Review follow-ups: the README claimed only linux-x64 and macOS ARM64
despite the win32-x64-msvc binary shipping, and napi.package.name
(@firecrawl/pdf-inspector-js) contradicts the real platform package
prefix — the loader and workflow derive it from the root package name.
Verified the generated loader is unchanged without the config.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 20:11:19 -07:00
Abimael MartellandClaude Fable 5 cffe253d1d fix(pypi): slim sdist inherited from crate allowlist; keep type stub (0.2.4) (#153)
The 0.2.3 sdist was 10.09 MiB (packaged tests/fixtures). maturin derives
the sdist file list from Cargo's include allowlist, so it's now 1.35 MiB
— but the allowlist dropped pdf_inspector.pyi, which would strip type
hints from wheels built from the sdist. Add it back and bump to 0.2.4.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 19:33:39 -07:00
Abimael MartellandClaude Fable 5 2cd1cf1b23 fix(crate): allowlist package contents to fit crates.io size cap (#152)
cargo publish of 0.1.5 failed with 413: the crate packaged everything
(260 files, 10.1MiB compressed) and tests/fixtures alone is 10.2MB.
Add an explicit include list (src, external/bcmaps which tounicode.rs
loads at runtime, readme, license) — 1.3MiB compressed.

Also add a workflow_dispatch fallback to publish-crate.yml so a failed
publish can be retried without a version bump (0.1.5 is already on
main, so a re-push won't register as a version change).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 19:24:40 -07:00
Abimael MartellandClaude Fable 5 2c97f4979e docs(crate): Rust-specific readme for crates.io (0.1.5) (#151)
crates.io showed the repo README, which leads with Python/Node quick
starts and repo-relative links. Point the crate readme at
docs/rust-api.md, refreshed with an intro, crates.io install, and CLI
install instructions. Bump to 0.1.5 to republish.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 19:15:42 -07:00
Abimael MartellandClaude Fable 5 60cb953284 docs: add PyPI version badge to README (#150)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 19:04:00 -07:00
Abimael MartellandClaude Fable 5 0d80efa84a docs(pypi): reformat Types section as stub-style code block (0.2.3) (#149)
The bold-label + comma-list paragraphs render as cramped walls of
inline code on PyPI. A python code block mirroring pdf_inspector.pyi
renders cleanly everywhere and adds field types plus the missing
is_underline/is_strikeout TextItem fields.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 18:54:39 -07:00
Abimael MartellandClaude Fable 5 f3e3129a9a feat(pypi): add package readme and project URLs (0.2.2) (#148)
PyPI showed an empty description because pyproject.toml declared no
readme. Point it at docs/python.md (refreshed with pip install now that
wheels exist) and add sidebar URLs. Bump to 0.2.2 to republish.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 18:36:45 -07:00
Abimael MartellandClaude Fable 5 d7e697fc5e chore(napi): bump @firecrawl/pdf-inspector to 1.10.4 (#147)
Ships #145: exclusive item->region assignment in extract_text_in_regions
(overlapping layout regions no longer double-extract shared items —
duplicated lines on 21% of a 2,078-doc bench corpus, with occasional
content loss when downstream dedup kept the wrong variant).


Claude-Session: https://claude.ai/code/session_01L3U6BKYCS73DVA83odAfYB

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 18:26:32 -07:00
38 changed files with 9230 additions and 655 deletions
+3
View File
@@ -25,6 +25,9 @@ jobs:
- name: Run tests
run: cargo test --verbose
- name: Test developer scripts
run: python3 -m unittest discover -s scripts/tests
fmt:
name: Format
runs-on: ubuntu-latest
+24 -8
View File
@@ -4,6 +4,9 @@ on:
push:
branches: [main]
paths: ['Cargo.toml']
# Manual fallback: retry a publish that failed after the version was
# already merged (a plain re-push won't register as a version change).
workflow_dispatch:
permissions:
contents: read
@@ -14,6 +17,10 @@ env:
jobs:
check-version:
name: Check version change
# Guard manual dispatches: crates.io trusted publishing matches
# repo+workflow+environment but NOT branch, so without this a
# workflow_dispatch from any branch could publish unmerged code.
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.check.outputs.changed }}
@@ -28,17 +35,26 @@ jobs:
id: check
run: |
NEW_VERSION=$(python3 -c 'import pathlib, tomllib; print(tomllib.loads(pathlib.Path("Cargo.toml").read_text())["package"]["version"])')
OLD_VERSION=$(git show HEAD~1:Cargo.toml | python3 -c 'import sys, tomllib; print(tomllib.loads(sys.stdin.read())["package"]["version"])')
echo "old=$OLD_VERSION new=$NEW_VERSION"
echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
if [ "$NEW_VERSION" = "$OLD_VERSION" ]; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "published=false" >> "$GITHUB_OUTPUT"
exit 0
fi
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
# Manual dispatch publishes the current version regardless of the
# previous commit; the crates.io check below still prevents
# double-publishing an already-released version.
echo "manual dispatch: publishing v$NEW_VERSION"
echo "changed=true" >> "$GITHUB_OUTPUT"
else
OLD_VERSION=$(git show HEAD~1:Cargo.toml | python3 -c 'import sys, tomllib; print(tomllib.loads(sys.stdin.read())["package"]["version"])')
echo "old=$OLD_VERSION new=$NEW_VERSION"
echo "changed=true" >> "$GITHUB_OUTPUT"
if [ "$NEW_VERSION" = "$OLD_VERSION" ]; then
echo "changed=false" >> "$GITHUB_OUTPUT"
echo "published=false" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "changed=true" >> "$GITHUB_OUTPUT"
fi
HTTP_STATUS=$(curl --silent --show-error --output /tmp/crate-version.json --write-out "%{http_code}" \
-H "User-Agent: firecrawl/pdf-inspector publish workflow (https://github.com/firecrawl/pdf-inspector)" \
+89 -5
View File
@@ -4,6 +4,9 @@ on:
push:
branches: [main]
paths: ['napi/package.json']
# Manual fallback: retry a publish that failed partway (per-package
# already-published checks make re-runs idempotent).
workflow_dispatch:
permissions:
contents: read
@@ -12,6 +15,10 @@ permissions:
jobs:
check-version:
name: Check version change
# Guard manual dispatches: npm trusted publishing matches
# repo+workflow+environment but NOT branch, so without this a
# workflow_dispatch from any branch could publish unmerged code.
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
outputs:
changed: ${{ steps.check.outputs.changed }}
@@ -25,11 +32,21 @@ jobs:
id: check
run: |
NEW_VERSION=$(node -p "require('./napi/package.json').version")
echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
# Manual dispatch rebuilds and publishes the current version; the
# per-package already-published checks in the publish job skip
# anything that made it out in a previous partial run.
echo "manual dispatch: publishing v$NEW_VERSION"
echo "changed=true" >> "$GITHUB_OUTPUT"
exit 0
fi
OLD_VERSION=$(git show HEAD~1:napi/package.json | node -p "JSON.parse(require('fs').readFileSync('/dev/stdin','utf8')).version")
echo "old=$OLD_VERSION new=$NEW_VERSION"
if [ "$NEW_VERSION" != "$OLD_VERSION" ]; then
echo "changed=true" >> "$GITHUB_OUTPUT"
echo "version=$NEW_VERSION" >> "$GITHUB_OUTPUT"
else
echo "changed=false" >> "$GITHUB_OUTPUT"
fi
@@ -115,14 +132,81 @@ jobs:
with:
path: napi/artifacts
- name: Collect binaries and publish
- name: Publish platform packages
working-directory: napi
run: |
cp artifacts/bindings-*/*.node .
VERSION="${{ needs.check-version.outputs.version }}"
for node_file in artifacts/bindings-*/pdf-inspector.*.node; do
base=$(basename "$node_file")
suffix=${base#pdf-inspector.}
suffix=${suffix%.node}
pkg="@firecrawl/pdf-inspector-$suffix"
if npm view "$pkg@$VERSION" version >/dev/null 2>&1; then
echo "$pkg@$VERSION already published — skipping"
continue
fi
dir="npm-dist/$suffix"
mkdir -p "$dir"
cp "$node_file" "$dir/"
node -e '
const [suffix, version] = process.argv.slice(1)
const meta = {
"linux-x64-gnu": { os: ["linux"], cpu: ["x64"], libc: ["glibc"] },
"darwin-arm64": { os: ["darwin"], cpu: ["arm64"] },
"win32-x64-msvc": { os: ["win32"], cpu: ["x64"] },
}[suffix]
if (!meta) {
console.error(`unknown platform suffix: ${suffix} — add it to the meta map`)
process.exit(1)
}
const pkg = {
name: `@firecrawl/pdf-inspector-${suffix}`,
version,
description: `Prebuilt ${suffix} binary for @firecrawl/pdf-inspector`,
main: `pdf-inspector.${suffix}.node`,
files: [`pdf-inspector.${suffix}.node`],
license: "MIT",
engines: { node: ">= 10" },
repository: { type: "git", url: "https://github.com/firecrawl/pdf-inspector" },
publishConfig: { access: "public" },
...meta,
}
require("fs").writeFileSync(`npm-dist/${suffix}/package.json`, JSON.stringify(pkg, null, 2) + "\n")
' "$suffix" "$VERSION"
echo "=== $pkg@$VERSION ==="
ls -la "$dir"
(cd "$dir" && npm publish --provenance --access public)
done
- name: Publish main package
working-directory: napi
run: |
VERSION="${{ needs.check-version.outputs.version }}"
if npm view "@firecrawl/pdf-inspector@$VERSION" version >/dev/null 2>&1; then
echo "@firecrawl/pdf-inspector@$VERSION already published — skipping"
exit 0
fi
cp artifacts/js-bindings/index.js .
cp artifacts/js-bindings/index.d.ts .
echo "=== Package contents ==="
ls -la *.node index.js index.d.ts
# Stamp optionalDependencies to this exact version so the platform
# pins can never drift from the main package version.
node -e '
const fs = require("fs")
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"))
for (const dep of Object.keys(pkg.optionalDependencies ?? {})) {
pkg.optionalDependencies[dep] = pkg.version
}
fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2) + "\n")
'
echo "=== Main package contents ==="
npm pack --dry-run
npm publish --provenance --access public
+14 -1
View File
@@ -1,12 +1,25 @@
[package]
name = "pdf-inspector"
version = "0.1.4"
version = "0.1.6"
edition = "2021"
autobins = false
authors = ["Firecrawl Team"]
description = "Fast PDF inspection, classification, and text extraction with smart scanned vs text-based detection"
license = "MIT"
repository = "https://github.com/firecrawl/pdf-inspector"
readme = "docs/rust-api.md"
# Explicit allowlist: crates.io caps uploads at 10 MiB and tests/fixtures
# alone exceeds that. external/bcmaps ships in the crate — tounicode.rs
# loads it at runtime relative to CARGO_MANIFEST_DIR.
include = [
"src/**",
"external/bcmaps/**",
"docs/rust-api.md",
"LICENSE",
# maturin derives the sdist file list from this allowlist; the stub must
# ship so wheels built from the sdist keep their type hints.
"pdf_inspector.pyi",
]
[lib]
name = "pdf_inspector"
+15 -8
View File
@@ -2,6 +2,7 @@
[![Crates.io](https://img.shields.io/crates/v/pdf-inspector.svg)](https://crates.io/crates/pdf-inspector)
[![npm](https://img.shields.io/npm/v/@firecrawl/pdf-inspector.svg)](https://www.npmjs.com/package/@firecrawl/pdf-inspector)
[![PyPI](https://img.shields.io/pypi/v/pdf-inspector.svg)](https://pypi.org/project/pdf-inspector/)
[![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE)
Fast Rust library for PDF classification and text extraction. Detects whether a PDF is text-based or scanned, extracts text with position awareness, and converts to clean Markdown — all without OCR. Includes bindings for [Python](docs/python.md) and [Node.js](napi/README.md).
@@ -22,20 +23,23 @@ Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in
## Benchmark
Evaluated on the [opendataloader-bench](https://github.com/opendataloader-project/opendataloader-bench) corpus (200 PDFs). Only direct text extraction engines are shown — no OCR, no ML models. Scores are 0-1, higher is better.
Evaluated on the [opendataloader-bench](https://github.com/opendataloader-project/opendataloader-bench) corpus (200 PDFs). Only local engines without model-based PDF parsing are shown; OCR was disabled. Scores are 0-1, higher is better.
| Engine | Overall | Reading Order (NID) | Tables (TEDS) | Headings (MHS) | Speed (200 docs) |
|---|---|---|---|---|---|
| pdf-inspector | 0.83 | 0.88 | 0.66 | 0.74 | 4s |
| opendataloader | 0.84 | 0.91 | 0.49 | 0.74 | 11s |
| pymupdf4llm | 0.73 | 0.89 | 0.40 | 0.41 | 18s |
| markitdown | 0.58 | 0.88 | 0.00 | 0.00 | 8s |
| pdf-inspector | **0.875** | **0.915** | **0.814** | 0.788 | **2.8s** |
| liteparse | 0.870 | 0.908 | 0.693 | **0.811** | 13.9s |
| opendataloader | 0.843 | 0.912 | 0.489 | 0.760 | 9.8s |
| pymupdf4llm | 0.735 | 0.886 | 0.401 | 0.424 | 15.5s |
| markitdown | 0.583 | 0.879 | 0.000 | 0.000 | 6.7s |
For context, engines that use OCR/ML (docling, marker, mineru) score 0.83-0.88 overall but take 2-180 minutes on the same corpus — pdf-inspector reaches the low end of that range without any OCR, in 4 seconds.
Results were refreshed on July 16, 2026, on an Apple M4 Pro. Engine versions were pdf-inspector 0.1.6, LiteParse 2.6.0, OpenDataLoader 2.1.1, PyMuPDF4LLM 0.2.0, and MarkItDown 0.1.4. Speed is the median of three complete corpus runs.
**Where we do well:** Speed (fastest of all engines), the best table detection of any engine shown, and heading detection now on par with opendataloader. Overall lands within 0.01 of opendataloader at roughly 2.5× the speed.
For context, engines that use OCR or model-based document parsing (docling, marker, mineru) score 0.83-0.88 overall but take 2-180 minutes on the same corpus — pdf-inspector reaches the top of that range without either, in 2.8 seconds.
**Where we lag:** Reading order still trails opendataloader slightly, and table structure trails OCR-based engines that can see visual layout.
**Best fit:** Native-text PDFs where speed, reading order, and table structure matter. pdf-inspector delivered the highest overall, reading-order, and table scores, along with the fastest complete run in this benchmark. That makes it a strong local default for reports, research papers, financial documents, invoices, and legal PDFs that need clean, structured Markdown without adding OCR latency or infrastructure.
Use the [paired benchmark harness](docs/benchmarking.md) to compare two local builds against the exact same corpus and evaluator revision.
## Quick start
@@ -118,6 +122,9 @@ pdf2md document.pdf --items-json
# Raw markdown only (no headers)
pdf2md document.pdf --raw
# Token-efficient output (collapses long dot leaders and similar source padding)
pdf2md document.pdf --compact
# Insert page break markers (<!-- Page N -->)
pdf2md document.pdf --pages
+59
View File
@@ -0,0 +1,59 @@
# Benchmarking against OpenDataLoader
The paired harness runs two `pdf2md` binaries through the same local
OpenDataLoader corpus, evaluates both outputs, and reports aggregate and
per-document deltas. This avoids comparing results produced from different
corpus revisions or evaluator versions.
Build a candidate and provide a released or worktree build as the baseline:
```bash
cargo build --release
python3 scripts/bench_opendataloader.py \
--bench-dir ../opendataloader-bench \
--baseline ../pdf-inspector-main/target/release/pdf2md \
--candidate target/release/pdf2md \
--max-document-regression 0.02 \
--json-output /tmp/pdf-inspector-benchmark.json
```
Pass `--reference-evaluation path/to/evaluation.json` to report the candidate
delta against another evaluation, and add `--require-reference-lead` to make a
negative reference delta fail the run. By default, the candidate must not
regress the baseline overall score or introduce missing predictions. Use
`--min-overall-delta` to require a specific aggregate gain.
The OpenDataLoader repository is external and keeps its normal
`prediction/pdf-inspector` output. Paired evaluation copies each run into a
temporary directory before evaluating it, so the baseline and candidate cannot
overwrite one another.
## Published comparison protocol
The public benchmark table was refreshed on July 16, 2026, on an Apple M4 Pro
using pdf-inspector 0.1.6, LiteParse 2.6.0, OpenDataLoader 2.1.1,
PyMuPDF4LLM 0.2.0, and MarkItDown 0.1.4. Every engine processed the same 200
PDFs with OCR disabled. Reported speed is the median of three complete corpus
runs; quality scores come from the benchmark evaluator over all 200 outputs.
## Optional backend evidence probe
The evidence probe compares positioned `pdf2md` items with MuPDF structured
text on the same pages. It is intended to find deterministic extraction or
layout evidence that could justify a future native implementation; it does not
merge MuPDF output into Markdown, invoke OCR, or add a runtime dependency.
Install MuPDF's `mutool`, build `pdf2md`, then run:
```bash
python3 scripts/probe_backend_evidence.py document.pdf \
--pdf2md target/release/pdf2md \
--json-output /tmp/backend-evidence.json
```
The report flags pages when MuPDF exposes a material net token gain, repeated
alignment anchors absent from local evidence, or additional image blocks. The
JSON includes bounded token samples and page-level counts so promising cases
can be inspected without treating backend disagreement as automatically
correct. Thresholds are configurable with `--min-token-gain`,
`--min-alternate-only-ratio`, and `--min-anchor-gain`.
+75 -10
View File
@@ -1,9 +1,39 @@
# Python API
# pdf-inspector
Python bindings via [PyO3](https://pyo3.rs). Requires Rust toolchain for building from source.
Fast PDF classification and text extraction. Detects whether a PDF is text-based or scanned, extracts text with position awareness, and converts to clean Markdown — all without OCR. Python bindings via [PyO3](https://pyo3.rs) for the [pdf-inspector](https://github.com/firecrawl/pdf-inspector) Rust library.
Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in under 200ms, skipping expensive OCR services for the ~54% of PDFs that don't need them.
## Features
- **Smart classification** — `text_based` / `scanned` / `image_based` / `mixed` in ~1050ms, with a confidence score and per-page OCR routing.
- **Markdown conversion** — headings, lists, code blocks, bold/italic, URL linking, and dual-mode table detection (PDF drawing ops + text-alignment heuristics).
- **Layout-aware extraction** — multi-column reading order, position and font info per text item, RTL support.
- **Robust text decoding** — CID/Type0 fonts via ToUnicode CMaps, plus automatic flagging of broken encodings so callers can fall back to OCR.
- **Lightweight** — native Rust core, no ML models, no external services; ships type stubs.
## Benchmark
[opendataloader-bench](https://github.com/opendataloader-project/opendataloader-bench) corpus (200 PDFs), local engines without model-based PDF parsing; OCR disabled. Scores 01, higher is better:
| Engine | Overall | Reading order | Tables (TEDS) | Headings | Speed |
|---|---|---|---|---|---|
| **pdf-inspector** | **0.875** | **0.915** | **0.814** | 0.788 | **2.8s** |
| liteparse | 0.870 | 0.908 | 0.693 | **0.811** | 13.9s |
| opendataloader | 0.843 | 0.912 | 0.489 | 0.760 | 9.8s |
| pymupdf4llm | 0.735 | 0.886 | 0.401 | 0.424 | 15.5s |
| markitdown | 0.583 | 0.879 | 0.000 | 0.000 | 6.7s |
Refreshed July 16, 2026, on Apple M4 Pro; speed is the median of three complete corpus runs. Full methodology and versions are in the [repo README](https://github.com/firecrawl/pdf-inspector#benchmark).
## Install
```bash
pip install pdf-inspector
```
Prebuilt wheels cover CPython ≥3.8 on Linux (x86_64, aarch64), macOS (Intel, Apple Silicon), and Windows (x64). Other platforms build from source, which requires a Rust toolchain. For local development in a repo checkout:
```bash
pip install maturin
maturin develop --release
@@ -73,16 +103,51 @@ result = pdf_inspector.extract_pages_markdown("document.pdf", pages=[0, 2])
## Types
**`PdfResult` fields:** `pdf_type`, `markdown`, `page_count`, `processing_time_ms`, `pages_needing_ocr`, `title`, `confidence`, `is_complex_layout`, `pages_with_tables`, `pages_with_columns`, `has_encoding_issues`
Type stubs (`pdf_inspector.pyi`) ship with the package. Result types at a glance:
**`PdfClassification` fields:** `pdf_type`, `page_count`, `pages_needing_ocr` (0-indexed), `confidence`
```python
class PdfResult: # process_pdf / detect_pdf
pdf_type: str # "text_based" | "scanned" | "image_based" | "mixed"
markdown: str | None # extracted Markdown (None for detect_pdf)
page_count: int
processing_time_ms: int
pages_needing_ocr: list[int]
title: str | None
confidence: float # 0.0 - 1.0
is_complex_layout: bool
pages_with_tables: list[int]
pages_with_columns: list[int]
has_encoding_issues: bool # broken font encodings — consider OCR fallback
**`TextItem` fields:** `text`, `x`, `y`, `width`, `height`, `font`, `font_size`, `page`, `is_bold`, `is_italic`, `item_type`
class PdfClassification: # classify_pdf
pdf_type: str
page_count: int
pages_needing_ocr: list[int] # 0-indexed
confidence: float
**`RegionText` fields:** `text`, `needs_ocr`
class TextItem: # extract_text_with_positions
text: str
x: float
y: float
width: float
height: float
font: str
font_size: float
page: int
is_bold: bool
is_italic: bool
is_underline: bool
is_strikeout: bool
item_type: str
**`PageRegionTexts` fields:** `page` (0-indexed), `regions` (list of RegionText)
class PageRegionTexts: # extract_text_in_regions
page: int # 0-indexed
regions: list[RegionText] # RegionText: text: str, needs_ocr: bool
**`PageMarkdown` fields:** `page` (0-indexed), `markdown`, `needs_ocr`
**`PagesExtractionResult` fields:** `pages` (list of PageMarkdown), `pages_with_tables` (1-indexed), `pages_with_columns` (1-indexed), `pages_needing_ocr` (1-indexed), `is_complex`
class PagesExtractionResult: # extract_pages_markdown
pages: list[PageMarkdown] # PageMarkdown: page (0-indexed), markdown, needs_ocr
pages_with_tables: list[int] # 1-indexed
pages_with_columns: list[int] # 1-indexed
pages_needing_ocr: list[int] # 1-indexed
is_complex: bool # any page has tables or multi-column layout
```
+40 -2
View File
@@ -1,12 +1,50 @@
# Rust API
# pdf-inspector
Add to your `Cargo.toml`:
Fast PDF classification and text extraction. Detects whether a PDF is text-based or scanned, extracts text with position awareness, and converts to clean Markdown — all without OCR. Pure Rust, no ML models, no external services; the only PDF dependency is [lopdf](https://crates.io/crates/lopdf). Also available for [Python](https://pypi.org/project/pdf-inspector/) and [Node.js](https://www.npmjs.com/package/@firecrawl/pdf-inspector).
Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in under 200ms, skipping expensive OCR services for the ~54% of PDFs that don't need them.
## Features
- **Smart classification** — TextBased / Scanned / ImageBased / Mixed in ~1050ms, with a confidence score and per-page OCR routing.
- **Markdown conversion** — headings, lists, code blocks, bold/italic, URL linking, and dual-mode table detection (PDF drawing ops + text-alignment heuristics).
- **Layout-aware extraction** — multi-column reading order, position and font info per text item, RTL support.
- **Robust text decoding** — CID/Type0 fonts via ToUnicode CMaps, plus automatic flagging of broken encodings so callers can fall back to OCR.
- **Lightweight** — pure Rust, no ML models, no external services; single PDF dependency ([lopdf](https://crates.io/crates/lopdf)).
## Benchmark
[opendataloader-bench](https://github.com/opendataloader-project/opendataloader-bench) corpus (200 PDFs), local engines without model-based PDF parsing; OCR disabled. Scores 01, higher is better:
| Engine | Overall | Reading order | Tables (TEDS) | Headings | Speed |
|---|---|---|---|---|---|
| **pdf-inspector** | **0.875** | **0.915** | **0.814** | 0.788 | **2.8s** |
| liteparse | 0.870 | 0.908 | 0.693 | **0.811** | 13.9s |
| opendataloader | 0.843 | 0.912 | 0.489 | 0.760 | 9.8s |
| pymupdf4llm | 0.735 | 0.886 | 0.401 | 0.424 | 15.5s |
| markitdown | 0.583 | 0.879 | 0.000 | 0.000 | 6.7s |
Refreshed July 16, 2026, on Apple M4 Pro; speed is the median of three complete corpus runs. Full methodology and versions are in the [repo README](https://github.com/firecrawl/pdf-inspector#benchmark).
## Install
```bash
cargo add pdf-inspector
```
For the latest unreleased changes, use the git dependency instead:
```toml
[dependencies]
pdf-inspector = { git = "https://github.com/firecrawl/pdf-inspector" }
```
The crate also ships CLI binaries — `pdf2md` (PDF → Markdown, with `--json`, `--pages`, `--select-pages`, and the opt-in token-saving `--compact` profile) and `detect-pdf` (classification, with `--analyze --json`):
```bash
cargo install pdf-inspector
```
## Usage
Detect and extract in one call:
+1 -1
View File
@@ -830,7 +830,7 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "pdf-inspector"
version = "0.1.4"
version = "0.1.6"
dependencies = [
"env_logger",
"log",
+30 -5
View File
@@ -4,6 +4,28 @@ Fast PDF classification and region-based text extraction for Node.js/Bun. Native
Built by [Firecrawl](https://firecrawl.dev) for hybrid OCR pipelines — extract text from PDF structure where possible, fall back to OCR only when needed.
## Features
- **Smart classification** — text-based / scanned / image-based / mixed in ~1050ms, with a confidence score and per-page OCR routing.
- **Region-based extraction** — pull text from bounding boxes with per-region quality checks (`needsOcr`).
- **Layout-aware** — multi-column reading order, position and font info per text item, RTL support.
- **Robust text decoding** — CID/Type0 fonts via ToUnicode CMaps, plus automatic flagging of broken encodings so callers can fall back to OCR.
- **Lightweight** — native Rust core via napi-rs, no ML models, no external services; ~56 MB platform binary, TypeScript definitions included.
## Benchmark
[opendataloader-bench](https://github.com/opendataloader-project/opendataloader-bench) corpus (200 PDFs), local engines without model-based PDF parsing; OCR disabled. Scores 01, higher is better:
| Engine | Overall | Reading order | Tables (TEDS) | Headings | Speed |
|---|---|---|---|---|---|
| **pdf-inspector** | **0.875** | **0.915** | **0.814** | 0.788 | **2.8s** |
| liteparse | 0.870 | 0.908 | 0.693 | **0.811** | 13.9s |
| opendataloader | 0.843 | 0.912 | 0.489 | 0.760 | 9.8s |
| pymupdf4llm | 0.735 | 0.886 | 0.401 | 0.424 | 15.5s |
| markitdown | 0.583 | 0.879 | 0.000 | 0.000 | 6.7s |
Refreshed July 16, 2026, on Apple M4 Pro; speed is the median of three complete corpus runs. Full methodology and versions are in the [repo README](https://github.com/firecrawl/pdf-inspector#benchmark).
## Install
```bash
@@ -12,7 +34,7 @@ npm install @firecrawl/pdf-inspector
bun add @firecrawl/pdf-inspector
```
Prebuilt binaries included for **linux-x64** and **macOS ARM64**. No Rust toolchain needed.
Prebuilt binaries for **Linux x64**, **macOS ARM64**, and **Windows x64** — npm installs only the one matching your platform. No Rust toolchain needed.
## API
@@ -90,10 +112,13 @@ interface RegionText {
## Platforms
| Platform | Architecture | Supported |
|----------|-------------|-----------|
| Linux | x64 | Yes |
| macOS | ARM64 | Yes |
Prebuilt binaries ship as platform-specific packages installed automatically via `optionalDependencies`:
| Platform | Architecture | Package |
|----------|-------------|---------|
| Linux | x64 (glibc) | `@firecrawl/pdf-inspector-linux-x64-gnu` |
| macOS | ARM64 | `@firecrawl/pdf-inspector-darwin-arm64` |
| Windows | x64 | `@firecrawl/pdf-inspector-win32-x64-msvc` |
## License
+5
View File
@@ -7,6 +7,11 @@
"devDependencies": {
"@napi-rs/cli": "^3.4.1",
},
"optionalDependencies": {
"@firecrawl/pdf-inspector-darwin-arm64": "1.11.0",
"@firecrawl/pdf-inspector-linux-x64-gnu": "1.11.0",
"@firecrawl/pdf-inspector-win32-x64-msvc": "1.11.0",
},
},
},
"packages": {
+7 -6
View File
@@ -1,6 +1,6 @@
{
"name": "@firecrawl/pdf-inspector",
"version": "1.10.3",
"version": "1.11.1",
"description": "Fast PDF classification and text extraction. Detect text-based vs scanned PDFs, extract text by region with quality checks. Native Rust performance via napi-rs.",
"main": "index.js",
"types": "index.d.ts",
@@ -22,7 +22,6 @@
"files": [
"index.js",
"index.d.ts",
"*.node",
"bin/",
"README.md"
],
@@ -40,10 +39,7 @@
"x86_64-unknown-linux-gnu",
"aarch64-apple-darwin",
"x86_64-pc-windows-msvc"
],
"package": {
"name": "@firecrawl/pdf-inspector-js"
}
]
},
"scripts": {
"build": "napi build --platform --release",
@@ -51,5 +47,10 @@
},
"devDependencies": {
"@napi-rs/cli": "^3.4.1"
},
"optionalDependencies": {
"@firecrawl/pdf-inspector-linux-x64-gnu": "1.11.1",
"@firecrawl/pdf-inspector-darwin-arm64": "1.11.1",
"@firecrawl/pdf-inspector-win32-x64-msvc": "1.11.1"
}
}
+7 -1
View File
@@ -6,8 +6,9 @@ build-backend = "maturin"
name = "pdf-inspector"
# Bump this to publish to PyPI — CI publishes automatically when the version
# changes on main (same flow as napi/package.json for npm).
version = "0.2.1"
version = "0.2.5"
description = "Fast PDF inspection, classification, and text extraction with smart scanned vs text-based detection"
readme = "docs/python.md"
license = { text = "MIT" }
requires-python = ">=3.8"
classifiers = [
@@ -19,5 +20,10 @@ classifiers = [
"Topic :: Text Processing",
]
[project.urls]
Homepage = "https://github.com/firecrawl/pdf-inspector"
Repository = "https://github.com/firecrawl/pdf-inspector"
Documentation = "https://github.com/firecrawl/pdf-inspector/blob/main/docs/python.md"
[tool.maturin]
features = ["python"]
+351
View File
@@ -0,0 +1,351 @@
#!/usr/bin/env python3
"""Run a paired pdf-inspector OpenDataLoader benchmark and report deltas."""
from __future__ import annotations
import argparse
import json
import math
import os
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any
SCORE_KEYS = (
"overall_mean",
"nid_mean",
"nid_s_mean",
"teds_mean",
"teds_s_mean",
"mhs_mean",
"mhs_s_mean",
)
def _non_negative_int(value: str) -> int:
parsed = int(value)
if parsed < 0:
raise argparse.ArgumentTypeError("must be non-negative")
return parsed
def _non_negative_float(value: str) -> float:
parsed = float(value)
if not math.isfinite(parsed) or parsed < 0.0:
raise argparse.ArgumentTypeError("must be finite and non-negative")
return parsed
def _finite_float(value: str) -> float:
parsed = float(value)
if not math.isfinite(parsed):
raise argparse.ArgumentTypeError("must be finite")
return parsed
def _scores(evaluation: dict[str, Any]) -> dict[str, float]:
score = evaluation.get("metrics", {}).get("score", {})
return {key: float(score[key]) for key in SCORE_KEYS if score.get(key) is not None}
def _documents(evaluation: dict[str, Any]) -> dict[str, float]:
documents: dict[str, float] = {}
for document in evaluation.get("documents", []):
overall = document.get("scores", {}).get("overall")
if overall is not None:
documents[str(document["document_id"])] = float(overall)
return documents
def compare_evaluations(
baseline: dict[str, Any],
candidate: dict[str, Any],
reference: dict[str, Any] | None = None,
*,
top: int = 10,
) -> dict[str, Any]:
"""Build aggregate and per-document deltas from evaluator JSON payloads."""
baseline_scores = _scores(baseline)
candidate_scores = _scores(candidate)
metric_deltas = {
key: candidate_scores[key] - baseline_scores[key]
for key in SCORE_KEYS
if key in baseline_scores and key in candidate_scores
}
baseline_documents = _documents(baseline)
candidate_documents = _documents(candidate)
shared = sorted(baseline_documents.keys() & candidate_documents.keys())
document_deltas = [
{
"document_id": document_id,
"baseline": baseline_documents[document_id],
"candidate": candidate_documents[document_id],
"delta": candidate_documents[document_id] - baseline_documents[document_id],
}
for document_id in shared
]
epsilon = 1e-12
improvements = sorted(document_deltas, key=lambda item: item["delta"], reverse=True)
regressions = sorted(document_deltas, key=lambda item: item["delta"])
result: dict[str, Any] = {
"baseline": baseline_scores,
"candidate": candidate_scores,
"deltas": metric_deltas,
"missing_predictions": {
"baseline": int(baseline.get("metrics", {}).get("missing_predictions", 0)),
"candidate": int(candidate.get("metrics", {}).get("missing_predictions", 0)),
},
"documents": {
"shared": len(shared),
"improved": sum(item["delta"] > epsilon for item in document_deltas),
"regressed": sum(item["delta"] < -epsilon for item in document_deltas),
"unchanged": sum(abs(item["delta"]) <= epsilon for item in document_deltas),
"largest_improvements": [
item for item in improvements if item["delta"] > epsilon
][:top],
"largest_regressions": [
item for item in regressions if item["delta"] < -epsilon
][:top],
"worst_regression": next(
(item for item in regressions if item["delta"] < -epsilon), None
),
},
}
if reference is not None:
reference_scores = _scores(reference)
result["reference"] = reference_scores
result["candidate_vs_reference"] = {
key: candidate_scores[key] - reference_scores[key]
for key in SCORE_KEYS
if key in candidate_scores and key in reference_scores
}
return result
def evaluate_gates(
comparison: dict[str, Any],
*,
min_overall_delta: float,
max_document_regression: float | None,
max_missing: int,
require_reference_lead: bool,
) -> list[str]:
"""Return human-readable gate failures; an empty list means pass."""
failures: list[str] = []
overall_delta = comparison["deltas"].get("overall_mean")
if overall_delta is None or overall_delta < min_overall_delta:
failures.append(
f"overall delta {overall_delta!r} is below {min_overall_delta:+.6f}"
)
candidate_missing = comparison["missing_predictions"]["candidate"]
if candidate_missing > max_missing:
failures.append(
f"candidate has {candidate_missing} missing predictions (maximum {max_missing})"
)
if max_document_regression is not None:
regression = comparison["documents"].get("worst_regression")
if regression is not None and regression["delta"] < -max_document_regression:
failures.append(
"largest document regression "
f"{regression['document_id']}={regression['delta']:+.6f} "
f"exceeds {-max_document_regression:+.6f}"
)
if require_reference_lead:
reference_delta = comparison.get("candidate_vs_reference", {}).get("overall_mean")
if reference_delta is None:
failures.append("reference overall score is unavailable")
elif reference_delta < 0.0:
failures.append(
f"candidate trails reference overall by {reference_delta!r}"
)
return failures
def _run(command: list[str], *, cwd: Path, env: dict[str, str] | None = None) -> None:
print("+", " ".join(command), flush=True)
subprocess.run(command, cwd=cwd, env=env, check=True)
def _run_engine(
*,
bench_dir: Path,
python: Path,
binary: Path,
label: str,
scratch_root: Path,
) -> dict[str, Any]:
env = os.environ.copy()
env["PDF_INSPECTOR_BINARY"] = str(binary)
source = bench_dir / "prediction" / "pdf-inspector"
if source.exists():
if source.is_dir():
shutil.rmtree(source)
else:
source.unlink()
_run(
[
str(python),
"src/pdf_parser.py",
"--engine",
"pdf-inspector",
"--log-level",
"WARNING",
],
cwd=bench_dir,
env=env,
)
if not source.is_dir():
raise RuntimeError(f"parser did not produce predictions: {source}")
destination = scratch_root / label
shutil.copytree(source, destination)
_run(
[
str(python),
"src/evaluator.py",
"--prediction-root",
str(scratch_root),
"--engine",
label,
"--log-level",
"WARNING",
],
cwd=bench_dir,
)
with (destination / "evaluation.json").open(encoding="utf-8") as handle:
return json.load(handle)
def _print_report(comparison: dict[str, Any]) -> None:
print("\nMetric baseline candidate delta")
print("-------------------- ---------- ---------- ----------")
for key in SCORE_KEYS:
if key not in comparison["deltas"]:
continue
print(
f"{key:<20} {comparison['baseline'][key]:>10.6f} "
f"{comparison['candidate'][key]:>10.6f} "
f"{comparison['deltas'][key]:>+10.6f}"
)
if "reference" in comparison:
delta = comparison["candidate_vs_reference"].get("overall_mean")
reference = comparison["reference"].get("overall_mean")
reference_display = f"{reference:.6f}" if reference is not None else "n/a"
delta_display = f"{delta:+.6f}" if delta is not None else "n/a"
print(f"\nReference overall: {reference_display}; candidate delta: {delta_display}")
documents = comparison["documents"]
print(
"\nDocuments: "
f"{documents['improved']} improved, {documents['regressed']} regressed, "
f"{documents['unchanged']} unchanged ({documents['shared']} shared)"
)
for heading, key in (
("Largest improvements", "largest_improvements"),
("Largest regressions", "largest_regressions"),
):
print(f"\n{heading}:")
rows = documents[key]
if not rows:
print(" none")
for row in rows:
print(
f" {row['document_id']}: {row['delta']:+.6f} "
f"({row['baseline']:.6f} -> {row['candidate']:.6f})"
)
def _arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--bench-dir", type=Path, required=True)
parser.add_argument("--baseline", type=Path, required=True)
parser.add_argument("--candidate", type=Path, required=True)
parser.add_argument("--python", type=Path)
parser.add_argument("--reference-evaluation", type=Path)
parser.add_argument("--json-output", type=Path)
parser.add_argument("--top", type=_non_negative_int, default=10)
parser.add_argument("--min-overall-delta", type=_finite_float, default=0.0)
parser.add_argument("--max-document-regression", type=_non_negative_float)
parser.add_argument("--max-missing", type=_non_negative_int, default=0)
parser.add_argument("--require-reference-lead", action="store_true")
return parser.parse_args(argv)
def main(argv: list[str] | None = None) -> int:
args = _arguments(argv)
bench_dir = args.bench_dir.resolve()
baseline = args.baseline.resolve()
candidate = args.candidate.resolve()
# Keep the virtualenv launcher path intact. Resolving its symlink would
# invoke the underlying system interpreter without the benchmark's site
# packages.
python = (args.python or bench_dir / ".venv" / "bin" / "python").absolute()
for path, description in (
(bench_dir / "src" / "pdf_parser.py", "OpenDataLoader parser"),
(bench_dir / "src" / "evaluator.py", "OpenDataLoader evaluator"),
(baseline, "baseline binary"),
(candidate, "candidate binary"),
(python, "Python interpreter"),
):
if not path.exists():
raise SystemExit(f"{description} not found: {path}")
with tempfile.TemporaryDirectory(prefix="pdf-inspector-opendataloader-") as temporary:
scratch_root = Path(temporary)
baseline_evaluation = _run_engine(
bench_dir=bench_dir,
python=python,
binary=baseline,
label="baseline",
scratch_root=scratch_root,
)
candidate_evaluation = _run_engine(
bench_dir=bench_dir,
python=python,
binary=candidate,
label="candidate",
scratch_root=scratch_root,
)
reference = None
if args.reference_evaluation is not None:
with args.reference_evaluation.resolve().open(encoding="utf-8") as handle:
reference = json.load(handle)
comparison = compare_evaluations(
baseline_evaluation,
candidate_evaluation,
reference,
top=args.top,
)
_print_report(comparison)
if args.json_output is not None:
args.json_output.resolve().write_text(
json.dumps(comparison, indent=2) + "\n", encoding="utf-8"
)
failures = evaluate_gates(
comparison,
min_overall_delta=args.min_overall_delta,
max_document_regression=args.max_document_regression,
max_missing=args.max_missing,
require_reference_lead=args.require_reference_lead,
)
if failures:
print("\nBenchmark gate failed:", file=sys.stderr)
for failure in failures:
print(f" - {failure}", file=sys.stderr)
return 1
print("\nBenchmark gate passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+351
View File
@@ -0,0 +1,351 @@
#!/usr/bin/env python3
"""Compare pdf-inspector evidence with optional MuPDF structured text.
This is an experiment and diagnostic tool, not an extraction fallback. It runs
MuPDF's deterministic ``stext.json`` backend without OCR and highlights pages
where that backend exposes materially different text or layout evidence.
"""
from __future__ import annotations
import argparse
from collections import Counter
import json
from pathlib import Path
import re
import shutil
import subprocess
import sys
from typing import Any, Iterable
TOKEN_PATTERN = re.compile(r"[^\W_]+(?:[\u2019'][^\W_]+)*", re.UNICODE)
def _tokens(texts: Iterable[str]) -> Counter[str]:
tokens: Counter[str] = Counter()
for text in texts:
for token in TOKEN_PATTERN.findall(text.casefold()):
# Lone letters are frequently bullets, chart labels, or fragmented
# glyphs. Digits remain useful even when they are one character.
if len(token) > 1 or token.isdigit():
tokens[token] += 1
return tokens
def _repeated_x_anchors(xs: Iterable[float], *, tolerance: float = 4.0) -> int:
buckets = Counter(round(float(x) / tolerance) for x in xs)
return sum(count >= 3 for count in buckets.values())
def local_pages(payload: dict[str, Any]) -> dict[int, dict[str, Any]]:
"""Summarize positioned ``pdf2md --items-json`` evidence by page."""
pages: dict[int, dict[str, Any]] = {}
for item in payload.get("items", []):
page_number = int(item["page"])
page = pages.setdefault(
page_number,
{"texts": [], "xs": [], "text_items": 0, "image_items": 0},
)
if item.get("item_type") == "image":
page["image_items"] += 1
continue
text = str(item.get("text", ""))
if text.strip():
page["texts"].append(text)
page["xs"].append(float(item.get("x", 0.0)))
page["text_items"] += 1
return pages
def alternate_pages(
payload: dict[str, Any] | list[dict[str, Any]],
) -> dict[int, dict[str, Any]]:
"""Summarize MuPDF ``stext.json`` evidence by page."""
pages: dict[int, dict[str, Any]] = {}
raw_pages = payload if isinstance(payload, list) else payload.get("pages", [])
for index, raw_page in enumerate(raw_pages, start=1):
page_number = int(raw_page.get("number", index))
page = {
"texts": [],
"xs": [],
"text_blocks": 0,
"text_lines": 0,
"image_blocks": 0,
}
for block in raw_page.get("blocks", []):
if block.get("type") == "image":
page["image_blocks"] += 1
continue
if block.get("type") != "text":
continue
page["text_blocks"] += 1
for line in block.get("lines", []):
text = str(line.get("text", ""))
if text.strip():
page["texts"].append(text)
bbox = line.get("bbox", {})
page["xs"].append(float(bbox.get("x", line.get("x", 0.0))))
page["text_lines"] += 1
pages[page_number] = page
return pages
def compare_page(
local: dict[str, Any],
alternate: dict[str, Any],
*,
min_token_gain: int,
min_alternate_only_ratio: float,
min_anchor_gain: int,
) -> dict[str, Any]:
"""Compare semantic and coarse layout evidence for one page."""
local_tokens = _tokens(local.get("texts", []))
alternate_tokens = _tokens(alternate.get("texts", []))
shared = local_tokens & alternate_tokens
alternate_only = alternate_tokens - local_tokens
local_only = local_tokens - alternate_tokens
local_total = sum(local_tokens.values())
alternate_total = sum(alternate_tokens.values())
shared_total = sum(shared.values())
alternate_only_total = sum(alternate_only.values())
local_only_total = sum(local_only.values())
net_token_gain = alternate_total - local_total
alternate_only_ratio = alternate_only_total / max(alternate_total, 1)
local_anchors = _repeated_x_anchors(local.get("xs", []))
alternate_anchors = _repeated_x_anchors(alternate.get("xs", []))
anchor_gain = alternate_anchors - local_anchors
image_gain = int(alternate.get("image_blocks", 0)) - int(
local.get("image_items", 0)
)
reasons: list[str] = []
if local_total == 0 and alternate_total >= max(5, min_token_gain // 2):
reasons.append("local_text_empty")
elif (
net_token_gain >= min_token_gain
and alternate_only_ratio >= min_alternate_only_ratio
):
reasons.append("alternate_has_more_text")
if anchor_gain >= min_anchor_gain:
reasons.append("alternate_has_more_alignment_anchors")
if image_gain > 0:
reasons.append("alternate_has_more_image_blocks")
if reasons:
classification = "investigate_alternate_evidence"
elif local_total - alternate_total >= min_token_gain:
classification = "local_has_more_text"
elif alternate_only_total + local_only_total:
classification = "different_segmentation_or_decoding"
else:
classification = "equivalent_text_evidence"
return {
"classification": classification,
"reasons": reasons,
"tokens": {
"local": local_total,
"alternate": alternate_total,
"shared": shared_total,
"net_alternate_gain": net_token_gain,
"alternate_only": alternate_only_total,
"local_only": local_only_total,
"alternate_only_ratio": alternate_only_ratio,
"alternate_only_sample": sorted(alternate_only)[:12],
"local_only_sample": sorted(local_only)[:12],
},
"layout": {
"local_text_items": int(local.get("text_items", 0)),
"local_image_items": int(local.get("image_items", 0)),
"local_repeated_x_anchors": local_anchors,
"alternate_text_blocks": int(alternate.get("text_blocks", 0)),
"alternate_text_lines": int(alternate.get("text_lines", 0)),
"alternate_image_blocks": int(alternate.get("image_blocks", 0)),
"alternate_repeated_x_anchors": alternate_anchors,
},
}
def compare_documents(
local_payload: dict[str, Any],
alternate_payload: dict[str, Any] | list[dict[str, Any]],
*,
min_token_gain: int = 20,
min_alternate_only_ratio: float = 0.15,
min_anchor_gain: int = 2,
) -> dict[str, Any]:
"""Return a page-level evidence report for already extracted payloads."""
local = local_pages(local_payload)
alternate = alternate_pages(alternate_payload)
page_numbers = sorted(local.keys() | alternate.keys())
pages = []
for page_number in page_numbers:
result = compare_page(
local.get(page_number, {}),
alternate.get(page_number, {}),
min_token_gain=min_token_gain,
min_alternate_only_ratio=min_alternate_only_ratio,
min_anchor_gain=min_anchor_gain,
)
result["page"] = page_number
pages.append(result)
flagged = [
page
for page in pages
if page["classification"] == "investigate_alternate_evidence"
]
return {
"summary": {
"pages": len(pages),
"flagged_pages": len(flagged),
"flagged_page_numbers": [page["page"] for page in flagged],
"local_tokens": sum(page["tokens"]["local"] for page in pages),
"alternate_tokens": sum(page["tokens"]["alternate"] for page in pages),
"alternate_only_tokens": sum(
page["tokens"]["alternate_only"] for page in pages
),
},
"pages": pages,
}
def _json_command(command: list[str]) -> Any:
try:
completed = subprocess.run(
command,
check=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
except subprocess.CalledProcessError as error:
detail = error.stderr.strip() or error.stdout.strip() or "no diagnostic output"
raise RuntimeError(f"command failed: {' '.join(command)}\n{detail}") from error
try:
return json.loads(completed.stdout)
except json.JSONDecodeError as error:
raise RuntimeError(
f"command did not return JSON: {' '.join(command)}: {error}"
) from error
def probe_pdf(
pdf: Path,
*,
pdf2md: Path,
mutool: Path,
min_token_gain: int,
min_alternate_only_ratio: float,
min_anchor_gain: int,
) -> dict[str, Any]:
local_payload = _json_command([str(pdf2md), str(pdf), "--items-json"])
# `stext.json` is MuPDF's native structured text output. The OCR formats
# are intentionally not used so this remains a deterministic no-model
# comparison.
alternate_payload = _json_command(
[str(mutool), "draw", "-q", "-F", "stext.json", "-o", "-", str(pdf)]
)
report = compare_documents(
local_payload,
alternate_payload,
min_token_gain=min_token_gain,
min_alternate_only_ratio=min_alternate_only_ratio,
min_anchor_gain=min_anchor_gain,
)
report["pdf"] = str(pdf)
return report
def _arguments(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("pdf", type=Path, nargs="+")
parser.add_argument("--pdf2md", type=Path, default=Path("target/release/pdf2md"))
parser.add_argument("--mutool", type=Path)
parser.add_argument("--json-output", type=Path)
parser.add_argument("--min-token-gain", type=int, default=20)
parser.add_argument("--min-alternate-only-ratio", type=float, default=0.15)
parser.add_argument("--min-anchor-gain", type=int, default=2)
return parser.parse_args(argv)
def _print_report(result: dict[str, Any]) -> None:
summary = result["summary"]
print(f"\n{result['pdf']}")
print(
f" {summary['flagged_pages']}/{summary['pages']} pages flagged; "
f"tokens local={summary['local_tokens']} alternate={summary['alternate_tokens']} "
f"alternate-only={summary['alternate_only_tokens']}"
)
for page in result["pages"]:
if page["classification"] != "investigate_alternate_evidence":
continue
reasons = ", ".join(page["reasons"])
tokens = page["tokens"]
print(
f" page {page['page']}: {reasons}; "
f"net tokens={tokens['net_alternate_gain']:+d}, "
f"alternate-only={tokens['alternate_only']}"
)
def main(argv: list[str] | None = None) -> int:
args = _arguments(argv)
pdf2md = args.pdf2md.absolute()
mutool = args.mutool or (Path(found) if (found := shutil.which("mutool")) else None)
if not pdf2md.is_file():
print(f"error: pdf2md binary not found: {pdf2md}", file=sys.stderr)
return 2
if mutool is None or not mutool.is_file():
print("error: mutool not found; install MuPDF or pass --mutool", file=sys.stderr)
return 2
if (
args.min_token_gain < 0
or args.min_anchor_gain < 0
or not 0.0 <= args.min_alternate_only_ratio <= 1.0
):
print("error: thresholds must be non-negative and ratio must be in [0, 1]", file=sys.stderr)
return 2
results = []
for pdf in args.pdf:
path = pdf.absolute()
if not path.is_file():
print(f"error: PDF not found: {path}", file=sys.stderr)
return 2
try:
result = probe_pdf(
path,
pdf2md=pdf2md,
mutool=mutool,
min_token_gain=args.min_token_gain,
min_alternate_only_ratio=args.min_alternate_only_ratio,
min_anchor_gain=args.min_anchor_gain,
)
except RuntimeError as error:
print(f"error: {error}", file=sys.stderr)
return 1
results.append(result)
_print_report(result)
payload = {
"schema_version": 1,
"experiment": "optional_mupdf_stext_evidence",
"ocr": False,
"thresholds": {
"min_token_gain": args.min_token_gain,
"min_alternate_only_ratio": args.min_alternate_only_ratio,
"min_anchor_gain": args.min_anchor_gain,
},
"documents": results,
}
if args.json_output:
args.json_output.parent.mkdir(parents=True, exist_ok=True)
args.json_output.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+203
View File
@@ -0,0 +1,203 @@
import io
import json
import sys
import tempfile
import unittest
from contextlib import redirect_stderr, redirect_stdout
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from bench_opendataloader import (
_arguments,
_print_report,
_run_engine,
compare_evaluations,
evaluate_gates,
)
def evaluation(overall, documents, *, missing=0):
return {
"metrics": {
"score": {
"overall_mean": overall,
"nid_mean": overall + 0.01,
},
"missing_predictions": missing,
},
"documents": [
{
"document_id": document_id,
"scores": {"overall": score},
}
for document_id, score in documents.items()
],
}
class ComparisonTests(unittest.TestCase):
def test_reports_metric_and_document_deltas(self):
baseline = evaluation(0.80, {"a": 0.8, "b": 0.6, "c": 0.7})
candidate = evaluation(0.82, {"a": 0.9, "b": 0.5, "c": 0.7})
result = compare_evaluations(baseline, candidate, top=1)
self.assertAlmostEqual(result["deltas"]["overall_mean"], 0.02)
self.assertEqual(result["documents"]["improved"], 1)
self.assertEqual(result["documents"]["regressed"], 1)
self.assertEqual(result["documents"]["unchanged"], 1)
self.assertEqual(
result["documents"]["largest_improvements"][0]["document_id"], "a"
)
self.assertEqual(
result["documents"]["largest_regressions"][0]["document_id"], "b"
)
def test_reference_delta_is_reported(self):
baseline = evaluation(0.80, {})
candidate = evaluation(0.82, {})
reference = evaluation(0.81, {})
result = compare_evaluations(baseline, candidate, reference)
self.assertAlmostEqual(
result["candidate_vs_reference"]["overall_mean"], 0.01
)
def test_gates_cover_aggregate_document_missing_and_reference(self):
comparison = compare_evaluations(
evaluation(0.80, {"a": 0.8}),
evaluation(0.79, {"a": 0.7}, missing=1),
evaluation(0.81, {}),
)
failures = evaluate_gates(
comparison,
min_overall_delta=0.0,
max_document_regression=0.05,
max_missing=0,
require_reference_lead=True,
)
self.assertEqual(len(failures), 4)
def test_regression_gate_is_independent_of_report_limit(self):
comparison = compare_evaluations(
evaluation(0.80, {"a": 0.8}),
evaluation(0.80, {"a": 0.7}),
top=0,
)
failures = evaluate_gates(
comparison,
min_overall_delta=0.0,
max_document_regression=0.05,
max_missing=0,
require_reference_lead=False,
)
self.assertEqual(len(failures), 1)
self.assertIn("largest document regression", failures[0])
def test_report_handles_reference_without_overall_score(self):
result = compare_evaluations(
evaluation(0.80, {}),
evaluation(0.82, {}),
{"metrics": {"score": {"nid_mean": 0.81}}},
)
output = io.StringIO()
with redirect_stdout(output):
_print_report(result)
self.assertIn("Reference overall: n/a; candidate delta: n/a", output.getvalue())
def test_reference_gate_reports_missing_score_as_unavailable(self):
comparison = compare_evaluations(
evaluation(0.80, {}),
evaluation(0.82, {}),
)
failures = evaluate_gates(
comparison,
min_overall_delta=0.0,
max_document_regression=None,
max_missing=0,
require_reference_lead=True,
)
self.assertEqual(failures, ["reference overall score is unavailable"])
def test_arguments_reject_negative_counts_and_allow_zero_top(self):
required = [
"--bench-dir",
".",
"--baseline",
"baseline",
"--candidate",
"candidate",
]
self.assertEqual(_arguments(required + ["--top", "0"]).top, 0)
for option in ("--top", "--max-document-regression", "--max-missing"):
with self.subTest(option=option), redirect_stderr(io.StringIO()):
with self.assertRaises(SystemExit):
_arguments(required + [option, "-1"])
def test_arguments_reject_nonfinite_float_thresholds(self):
required = [
"--bench-dir",
".",
"--baseline",
"baseline",
"--candidate",
"candidate",
]
for option in ("--min-overall-delta", "--max-document-regression"):
for value in ("nan", "inf", "-inf"):
with self.subTest(option=option, value=value), redirect_stderr(
io.StringIO()
):
with self.assertRaises(SystemExit):
_arguments(required + [option, value])
def test_run_engine_clears_stale_predictions_before_parser(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
bench_dir = root / "bench"
source = bench_dir / "prediction" / "pdf-inspector"
source.mkdir(parents=True)
(source / "stale.md").write_text("stale", encoding="utf-8")
scratch = root / "scratch"
scratch.mkdir()
def fake_run(command, *, cwd, env=None):
if any(part.endswith("pdf_parser.py") for part in command):
self.assertFalse(source.exists())
(source / "markdown").mkdir(parents=True)
(source / "markdown" / "new.md").write_text(
"new", encoding="utf-8"
)
else:
destination = scratch / "candidate"
(destination / "evaluation.json").write_text(
json.dumps(evaluation(0.82, {})), encoding="utf-8"
)
with patch("bench_opendataloader._run", side_effect=fake_run):
result = _run_engine(
bench_dir=bench_dir,
python=Path("python"),
binary=Path("pdf2md"),
label="candidate",
scratch_root=scratch,
)
self.assertEqual(result["metrics"]["score"]["overall_mean"], 0.82)
self.assertFalse((source / "stale.md").exists())
self.assertFalse((scratch / "candidate" / "stale.md").exists())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,103 @@
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from probe_backend_evidence import compare_documents
def local_payload(items):
return {"items": items}
def item(page, text, x=10, item_type="text"):
return {"page": page, "text": text, "x": x, "item_type": item_type}
def alternate_payload(pages):
return {"pages": pages}
def page(lines, *, images=0):
blocks = [
{
"type": "text",
"lines": [
{"text": text, "bbox": {"x": x, "y": index * 10, "w": 80, "h": 8}}
for index, (text, x) in enumerate(lines)
],
}
]
blocks.extend({"type": "image"} for _ in range(images))
return {"blocks": blocks}
class EvidenceComparisonTests(unittest.TestCase):
def test_accepts_real_top_level_page_array(self):
local = local_payload([item(1, "alpha beta")])
alternate = [page([("alpha beta gamma", 10)])]
result = compare_documents(local, alternate)["pages"][0]
self.assertEqual(result["tokens"]["alternate"], 3)
self.assertEqual(result["tokens"]["net_alternate_gain"], 1)
def test_flags_material_alternate_text_gain(self):
local = local_payload([item(1, "alpha beta")])
alternate = alternate_payload(
[page([("alpha beta gamma delta epsilon zeta", 10)])]
)
report = compare_documents(
local,
alternate,
min_token_gain=3,
min_alternate_only_ratio=0.2,
)
result = report["pages"][0]
self.assertEqual(result["classification"], "investigate_alternate_evidence")
self.assertIn("alternate_has_more_text", result["reasons"])
self.assertEqual(result["tokens"]["net_alternate_gain"], 4)
def test_repeated_alignment_and_image_evidence_are_reported(self):
local = local_payload([item(1, "one two", 10)])
alternate = alternate_payload(
[
page(
[
("one two", 10),
("row three", 100),
("row four", 100),
("row five", 100),
],
images=1,
)
]
)
result = compare_documents(
local,
alternate,
min_token_gain=99,
min_anchor_gain=1,
)["pages"][0]
self.assertIn("alternate_has_more_alignment_anchors", result["reasons"])
self.assertIn("alternate_has_more_image_blocks", result["reasons"])
self.assertEqual(result["layout"]["alternate_repeated_x_anchors"], 1)
def test_token_segmentation_difference_does_not_imply_more_evidence(self):
local = local_payload([item(1, "Revenue 2025")])
alternate = alternate_payload([page([("Revenue 2024", 10)])])
result = compare_documents(local, alternate, min_token_gain=2)["pages"][0]
self.assertEqual(result["classification"], "different_segmentation_or_decoding")
self.assertEqual(result["reasons"], [])
self.assertEqual(result["tokens"]["alternate_only_sample"], ["2024"])
if __name__ == "__main__":
unittest.main()
+677 -358
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -206,6 +206,9 @@ fn main() {
eprintln!(" --json Output result as JSON");
eprintln!(" --items-json Output positioned TextItem JSON");
eprintln!(" --raw Output only markdown (no headers)");
eprintln!(
" --compact Collapse token-heavy source formatting such as dot leaders"
);
eprintln!(" --pages Insert page break markers (<!-- Page N -->)");
eprintln!(" --select-pages N Only process specified pages (e.g. 1,3,5-10)");
eprintln!(" --password PW Password for an encrypted PDF");
@@ -218,6 +221,7 @@ fn main() {
let json_output = args.iter().any(|a| a == "--json");
let items_json_output = args.iter().any(|a| a == "--items-json");
let raw_output = args.iter().any(|a| a == "--raw");
let compact_output = args.iter().any(|a| a == "--compact");
let page_numbers = args.iter().any(|a| a == "--pages");
let detect_only = args.iter().any(|a| a == "--detect-only");
let analyze = args.iter().any(|a| a == "--analyze");
@@ -276,6 +280,9 @@ fn main() {
};
let mut options = PdfOptions::new().mode(process_mode);
if compact_output {
options.markdown.profile = pdf_inspector::MarkdownProfile::Compact;
}
options.markdown.include_page_numbers = page_numbers;
if let Some(pages) = page_filter {
options.page_filter = Some(pages);
+42
View File
@@ -1187,10 +1187,37 @@ pub(crate) fn extract_text_from_operand(
})();
result.map(|text| {
let text = clean_symbol_pua(text);
let text = remap_texcm_math_symbols(text, base_font_name);
normalize_cp1252_controls(text, use_cp1252_fallback)
})
}
/// Fix a known producer bug in "TeXCMMathsSymbols" subset fonts (IntechOpen
/// and sibling academic pipelines): the Computer Modern symbol glyphs are
/// misnamed after Latin lookalikes (equal → /onequarter, plus → /thorn, …)
/// and the generated ToUnicode faithfully propagates the wrong names. The
/// remap applies only to text decoded from that font, keyed on the glyphs'
/// observed misnames.
fn remap_texcm_math_symbols(text: String, base_font_name: Option<&str>) -> String {
let is_texcm = base_font_name.is_some_and(|n| {
let n = n.rsplit_once('+').map_or(n, |(_, s)| s);
n.eq_ignore_ascii_case("TeXCMMathsSymbols")
});
if !is_texcm {
return text;
}
text.chars()
.map(|c| match c {
'¼' => '=',
'½' => '-',
'þ' => '+',
'ð' => '(',
'Þ' => ')',
_ => c,
})
.collect()
}
fn decode_single_byte_fallback(bytes: &[u8], use_cp1252_fallback: bool) -> String {
bytes
.iter()
@@ -1409,6 +1436,21 @@ fn score_text(text: &str) -> i32 {
#[cfg(test)]
mod tests {
#[test]
fn texcm_math_symbols_remap() {
assert_eq!(
super::remap_texcm_math_symbols("S ¼ kB þ 1".into(), Some("EEKVNO+TeXCMMathsSymbols")),
"S = kB + 1"
);
// Other fonts keep their genuine fractions/thorns.
assert_eq!(
super::remap_texcm_math_symbols("¼ cup þorn".into(), Some("Times-Roman")),
"¼ cup þorn"
);
assert_eq!(super::remap_texcm_math_symbols("¼".into(), None), "¼");
}
use super::*;
use lopdf::dictionary;
+199 -5
View File
@@ -674,9 +674,24 @@ fn validate_and_build_columns(
page: u32,
center_assign: bool,
) -> Vec<ColumnRegion> {
// Compute Y range of the page
let y_min = page_items.iter().map(|i| i.y).fold(f32::INFINITY, f32::min);
let y_max = page_items
// Compute the Y range from column-eligible items only — the same items
// the histogram counted. Spanning items (full-width captions, titles)
// are excluded from the projection, so letting them stretch the page's
// vertical extent here would sink the overlap ratio for column regions
// that legitimately occupy only part of the page (e.g. two-column text
// below a figure).
let x_span = page_items
.iter()
.map(|i| i.x + effective_width(i))
.fold(f32::NEG_INFINITY, f32::max)
- page_items.iter().map(|i| i.x).fold(f32::INFINITY, f32::min);
let narrow: Vec<&&TextItem> = page_items
.iter()
.filter(|i| effective_width(i) <= x_span * 0.6)
.collect();
let span_items: &[&&TextItem] = if narrow.is_empty() { &[] } else { &narrow };
let y_min = span_items.iter().map(|i| i.y).fold(f32::INFINITY, f32::min);
let y_max = span_items
.iter()
.map(|i| i.y)
.fold(f32::NEG_INFINITY, f32::max);
@@ -722,6 +737,10 @@ fn validate_and_build_columns(
(right_items.len(), left_items.len())
};
if larger < min_items || smaller < 3 {
debug!(
" valley rejected: counts smaller={} larger={}",
smaller, larger
);
continue;
}
@@ -735,6 +754,7 @@ fn validate_and_build_columns(
&right_items
};
if is_list_marker_column(smaller_items) {
debug!(" valley rejected: list-marker column");
continue;
}
@@ -759,6 +779,13 @@ fn validate_and_build_columns(
let overlap = (overlap_max - overlap_min).max(0.0);
if overlap / y_range < min_vertical_span {
debug!(
" valley rejected: overlap {:.0}/{:.0} = {:.2} < {:.2}",
overlap,
y_range,
overlap / y_range,
min_vertical_span
);
continue;
}
}
@@ -1133,6 +1160,41 @@ pub(crate) fn group_into_lines_with_thresholds(
items: Vec<TextItem>,
page_thresholds: &HashMap<u32, f32>,
table_pages: &HashSet<u32>,
) -> Vec<TextLine> {
group_into_lines_with_thresholds_and_charts(
items,
page_thresholds,
table_pages,
&HashMap::new(),
)
}
/// Like `group_into_lines_with_thresholds`, but items inside chart regions
/// are excluded from column detection: chart text scattered across the page
/// fills the gutter in the projection histogram, so two-column pages read as
/// one column and same-baseline items from both columns fuse into one line
/// (headings absorbed into the neighboring column's body text).
pub(crate) fn group_into_lines_with_thresholds_and_charts(
items: Vec<TextItem>,
page_thresholds: &HashMap<u32, f32>,
table_pages: &HashSet<u32>,
chart_regions: &HashMap<u32, Vec<(f32, f32, f32, f32)>>,
) -> Vec<TextLine> {
group_into_lines_with_thresholds_and_regions(
items,
page_thresholds,
table_pages,
chart_regions,
&HashMap::new(),
)
}
pub(crate) fn group_into_lines_with_thresholds_and_regions(
items: Vec<TextItem>,
page_thresholds: &HashMap<u32, f32>,
table_pages: &HashSet<u32>,
chart_regions: &HashMap<u32, Vec<(f32, f32, f32, f32)>>,
image_regions: &HashMap<u32, Vec<super::reading_order::ImageRegion>>,
) -> Vec<TextLine> {
if items.is_empty() {
return Vec::new();
@@ -1159,8 +1221,67 @@ pub(crate) fn group_into_lines_with_thresholds(
// Non-Canva pages use the default 0.10 threshold.
let adaptive_threshold = page_thresholds.get(&page).copied().unwrap_or(0.10);
// Detect columns for this page
let columns = detect_columns(&page_items, page, table_pages.contains(&page));
// Image-backed region graphs recover local/asymmetric column flows
// that a whole-page projection cannot represent. Charts already have
// their own positioned-region ordering and therefore stay on that path.
if !chart_regions.contains_key(&page) {
let preliminary_columns =
detect_columns(&page_items, page, table_pages.contains(&page));
let detected_split =
(preliminary_columns.len() == 2).then_some(preliminary_columns[0].x_max);
if let Some(band) = image_regions.get(&page).and_then(|regions| {
super::reading_order::infer_image_anchored_flow(
&page_items,
regions,
detected_split,
)
}) {
debug!(
"page {}: image-anchored region graph split={:.1} y=[{:.1}..{:.1}]",
page, band.split_x, band.y_bottom, band.y_top
);
for node in super::reading_order::build_region_graph(page_items, band) {
debug!(
"page {}: region node {:?} items={}",
page,
node.kind,
node.items.len()
);
all_lines.extend(group_single_column(node.items, adaptive_threshold));
}
continue;
}
}
// Detect columns for this page, blind to chart text.
debug!(
"page {}: grouping chart-aware={} regions={:?}",
page,
chart_regions.contains_key(&page),
chart_regions.get(&page).map(|v| v
.iter()
.map(|&(a, b, c, d)| (a as i32, b as i32, c as i32, d as i32))
.collect::<Vec<_>>())
);
let columns = match chart_regions.get(&page).filter(|r| !r.is_empty()) {
Some(regions) => {
let col_input: Vec<TextItem> = page_items
.iter()
.filter(|it| {
let cx = it.x + it.width / 2.0;
// Tight bounds: this only blinds the histogram to
// chart-internal text; rows adjacent to the chart
// belong to the column layout.
!regions.iter().any(|&(x0, y0, x1, y1)| {
cx >= x0 - 2.0 && cx <= x1 + 2.0 && it.y >= y0 - 2.0 && it.y <= y1 + 2.0
})
})
.cloned()
.collect();
detect_columns(&col_input, page, table_pages.contains(&page))
}
None => detect_columns(&page_items, page, table_pages.contains(&page)),
};
if columns.len() <= 1 {
// Single column - use simple sorting
@@ -1446,6 +1567,56 @@ fn group_single_column(items: Vec<TextItem>, adaptive_threshold: f32) -> Vec<Tex
}
}
}
// Same baseline, but separated by a wide void, with the incoming
// run starting alphabetic: the neighboring column's body text
// sharing a y with this line, in gutters too narrow for column
// detection. Both sides must be multi-word prose — TOC page
// numbers, dot leaders, and outline-numbered table cells (which
// start with digits) stay joined.
if let Some(last_item) = last_line.items.last() {
let gap = item.x - (last_item.x + last_item.width);
if gap > (item.font_size.max(last_item.font_size) * 3.0).max(30.0)
&& item
.text
.trim()
.chars()
.next()
.is_some_and(|c| c.is_alphabetic())
{
// The incoming run must be substantial prose; the line
// side may be short (a wrapped heading's last words).
let incoming_wordy = {
let t = item.text.trim();
t.split_whitespace().count() >= 3
&& t.chars().filter(|c| c.is_alphabetic()).count() >= 10
};
let line_text = last_line
.items
.iter()
.map(|i| i.text.trim())
.collect::<Vec<_>>()
.join(" ");
let line_wordy = line_text.split_whitespace().count() >= 2
&& line_text.chars().filter(|c| c.is_alphabetic()).count() >= 8;
// Lowercase starts are mid-sentence continuations and
// split on prose signals alone. Uppercase starts also
// need a bold-style mismatch between the runs — a bold
// heading beside regular body text — otherwise same-style
// label rows (feature tiles, legends) would shatter.
let starts_lower = item
.text
.trim()
.chars()
.next()
.is_some_and(|c| c.is_lowercase());
// The whole line must be bold (a heading), not merely
// its last run — mixed bold-label/value rows stay joined.
let style_mismatch = last_line.items.iter().all(|i| i.is_bold) && !item.is_bold;
if line_wordy && incoming_wordy && (starts_lower || style_mismatch) {
return false;
}
}
}
true
});
@@ -1519,6 +1690,29 @@ mod tests {
items
}
#[test]
fn same_baseline_wide_gap_lowercase_continuation_splits() {
// Heading in the left column, mid-sentence body text from the right
// column at the same y, separated by a wide void: two lines.
let items = vec![
make_item(1, 94.0, 242.0, "6.2. Expectations for Re-Hiring Staff"),
make_item(1, 380.0, 242.0, "they had no plans to re-hire and more"),
];
let lines = group_single_column(items, 0.10);
assert_eq!(lines.len(), 2, "independent column runs must not fuse");
}
#[test]
fn same_baseline_wide_gap_table_label_stays_joined() {
// Outline-numbered cell content to the right: table-ish, keep joined.
let items = vec![
make_item(1, 94.0, 242.0, "2. Embracing complexity in"),
make_item(1, 380.0, 242.0, "2.1 Systems thinking and practice"),
];
let lines = group_single_column(items, 0.10);
assert_eq!(lines.len(), 1, "numbered table cells stay on one line");
}
#[test]
fn three_zone_layout_detected() {
// Left months (x=15..330), right months (x=345..660), sidebar (x=675..800)
+138 -9
View File
@@ -6,6 +6,7 @@ pub(crate) mod content_stream;
mod fonts;
mod layout;
mod links;
mod reading_order;
pub(crate) mod underline;
mod xobjects;
@@ -28,6 +29,8 @@ pub(crate) use fonts::FontStyleCache;
pub(crate) use layout::detect_columns;
pub use layout::group_into_lines;
pub(crate) use layout::group_into_lines_with_thresholds;
pub(crate) use layout::group_into_lines_with_thresholds_and_charts;
pub(crate) use layout::group_into_lines_with_thresholds_and_regions;
pub(crate) use layout::is_newspaper_layout;
pub(crate) use layout::ColumnRegion;
@@ -176,14 +179,89 @@ fn extract_positioned_text_impl(
continue;
}
}
let ((mut items, rects, lines), has_gid_fonts, _coords_rotated) = extract_page_text_items(
doc,
page_id,
*page_num,
font_cmaps,
include_invisible,
&mut style_cache,
)?;
let ((mut items, mut rects, mut lines), has_gid_fonts, coords_rotated) =
extract_page_text_items(
doc,
page_id,
*page_num,
font_cmaps,
include_invisible,
&mut style_cache,
)?;
// Clip to the visible page box: single-page extracts and imposed
// spreads keep neighboring pages' content in the stream, positioned
// outside the CropBox. Extracting it interleaves invisible text into
// the page and poisons font statistics. Rotated pages are left alone
// — their item coordinates are already transformed out of box space.
let mut clipped_box: Option<(f32, f32, f32, f32)> = None;
if !coords_rotated {
if let Some((bx0, by0, bx1, by1)) = get_page_box(doc, page_id) {
const TOL: f32 = 6.0;
let outside = |it: &TextItem| {
let cx = it.x + it.width / 2.0;
!(cx >= bx0 - TOL && cx <= bx1 + TOL && it.y >= by0 - TOL && it.y <= by1 + TOL)
};
// Only clip when the off-page material reads as coherent text
// (neighboring-page paragraphs). Curved/rotated display text
// leaves short glyph fragments with artifact coordinates
// outside the box, and those must stay.
let off: Vec<&TextItem> = items.iter().filter(|it| outside(it)).collect();
// Judge by character mass: paragraphs are dominated by long
// word runs even when interleaved with short math fragments,
// while glyph-confetti is short items through and through.
let total_chars: usize = off.iter().map(|it| it.text.trim().chars().count()).sum();
let wordy_chars: usize = off
.iter()
.map(|it| it.text.trim().chars().count())
.filter(|&n| n >= 4)
.sum();
// Genuine neighboring-page content is cleanly separated from
// on-page text. When an off-page item continues an on-page
// line (same baseline, near-adjacent x), the coordinates are
// artifacts of transforms we mis-model — don't clip those.
let straddles = off.iter().any(|o| {
items.iter().any(|i| {
!outside(i)
&& (i.y - o.y).abs() <= 2.0
&& (o.x - (i.x + i.width)).abs() <= 10.0
})
});
let coherent =
off.len() >= 10 && wordy_chars * 2 >= total_chars.max(1) && !straddles;
if bx1 - bx0 >= 72.0 && by1 - by0 >= 72.0 && coherent {
let before = items.len();
items.retain(|it| !outside(it));
if items.len() < before {
debug!(
"page {}: clipped {} items outside page box ({:.0},{:.0})-({:.0},{:.0})",
page_num,
before - items.len(),
bx0,
by0,
bx1,
by1
);
// Only prune off-page geometry when off-page text
// existed — same neighboring-page content.
let overlaps = |x: f32, y: f32, w: f32, h: f32| {
let (x0, x1) = if w < 0.0 { (x + w, x) } else { (x, x + w) };
let (y0, y1) = if h < 0.0 { (y + h, y) } else { (y, y + h) };
x0 < bx1 + TOL && x1 > bx0 - TOL && y0 < by1 + TOL && y1 > by0 - TOL
};
rects.retain(|r| overlaps(r.x, r.y, r.width, r.height));
clipped_box = Some((bx0, by0, bx1, by1));
lines.retain(|l| {
overlaps(
l.x1.min(l.x2),
l.y1.min(l.y2),
(l.x2 - l.x1).abs(),
(l.y2 - l.y1).abs(),
)
});
}
}
}
}
if has_gid_fonts {
gid_encoded_pages.insert(*page_num);
}
@@ -223,7 +301,18 @@ fn extract_positioned_text_impl(
all_lines.extend(lines);
// Extract hyperlinks from page annotations
let links = extract_page_links(doc, page_id, *page_num);
let mut links = extract_page_links(doc, page_id, *page_num);
// Annotations from the neighboring page are off-box too.
if let Some((bx0, by0, bx1, by1)) = clipped_box {
links.retain(|it| {
let cx = it.x + it.width / 2.0;
// Center-y, not it.y: link items carry an annotation rect,
// so y is a box edge — unlike text items, where y is a
// baseline and testing it directly is the natural semantics.
let cy = it.y + it.height / 2.0;
cx >= bx0 - 6.0 && cx <= bx1 + 6.0 && cy >= by0 - 6.0 && cy <= by1 + 6.0
});
}
all_items.extend(links);
}
@@ -967,6 +1056,46 @@ pub(crate) fn get_number(obj: &Object) -> Option<f32> {
}
}
/// Visible page box: CropBox if present, else MediaBox, walking page-tree
/// inheritance (both attributes are inheritable). Returns normalized
/// (x0, y0, x1, y1) in PDF space.
fn get_page_box(doc: &Document, page_id: ObjectId) -> Option<(f32, f32, f32, f32)> {
fn find_box(doc: &Document, page_id: ObjectId, key: &[u8]) -> Option<Vec<f32>> {
let mut id = page_id;
for _ in 0..32 {
let dict = doc.get_dictionary(id).ok()?;
if let Ok(obj) = dict.get(key) {
let arr = match obj {
Object::Array(a) => Some(a.clone()),
Object::Reference(r) => match doc.get_object(*r) {
Ok(Object::Array(a)) => Some(a.clone()),
_ => None,
},
_ => None,
};
if let Some(arr) = arr {
let vals: Vec<f32> = arr.iter().filter_map(get_number).collect();
if vals.len() >= 4 {
return Some(vals);
}
}
}
match dict.get(b"Parent") {
Ok(Object::Reference(p)) => id = *p,
_ => return None,
}
}
None
}
let v = find_box(doc, page_id, b"CropBox").or_else(|| find_box(doc, page_id, b"MediaBox"))?;
Some((
v[0].min(v[2]),
v[1].min(v[3]),
v[0].max(v[2]),
v[1].max(v[3]),
))
}
#[cfg(test)]
mod tests {
use super::*;
+591
View File
@@ -0,0 +1,591 @@
//! Region-graph evidence for page reading order.
//!
//! Whole-page column histograms fail when images or spanning captions occupy
//! only part of a page. This module turns image geometry and repeated row
//! gutters into a small directed acyclic graph: content above a local column
//! band, the left flow, the right flow, and content below it. The graph is
//! deliberately evidence-gated; ordinary pages keep the established layout
//! path.
use crate::text_utils::{effective_width, is_cjk_char, is_rtl_text};
use crate::types::TextItem;
const MIN_IMAGE_WIDTH: f32 = 60.0;
const MIN_IMAGE_HEIGHT: f32 = 40.0;
const MIN_ROW_GUTTER: f32 = 8.0;
const SPLIT_CLUSTER_TOLERANCE: f32 = 20.0;
const MIN_ALIGNED_ROWS: usize = 4;
pub(crate) type ImageRegion = (f32, f32, f32, f32);
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct ColumnFlowBand {
pub(crate) split_x: f32,
pub(crate) y_bottom: f32,
pub(crate) y_top: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RegionKind {
FullWidth,
Column,
}
#[derive(Debug)]
pub(crate) struct RegionNode {
pub(crate) kind: RegionKind,
pub(crate) items: Vec<TextItem>,
}
#[derive(Debug)]
struct Row<'a> {
y: f32,
items: Vec<&'a TextItem>,
}
fn page_x_bounds(items: &[TextItem], images: &[ImageRegion]) -> Option<(f32, f32)> {
let text_min = items
.iter()
.map(|item| item.x)
.fold(f32::INFINITY, f32::min);
let text_max = items
.iter()
.map(|item| item.x + effective_width(item))
.fold(f32::NEG_INFINITY, f32::max);
let image_min = images
.iter()
.map(|region| region.0.min(region.2))
.fold(f32::INFINITY, f32::min);
let image_max = images
.iter()
.map(|region| region.0.max(region.2))
.fold(f32::NEG_INFINITY, f32::max);
let x_min = text_min.min(image_min);
let x_max = text_max.max(image_max);
(x_min.is_finite() && x_max.is_finite() && x_max > x_min).then_some((x_min, x_max))
}
fn group_rows(items: &[TextItem]) -> Vec<Row<'_>> {
const Y_TOLERANCE: f32 = 3.0;
let mut sorted: Vec<&TextItem> = items.iter().collect();
sorted.sort_by(|left, right| right.y.total_cmp(&left.y));
let mut rows: Vec<Row<'_>> = Vec::new();
for item in sorted {
if let Some(row) = rows
.last_mut()
.filter(|row| (row.y - item.y).abs() <= Y_TOLERANCE)
{
row.items.push(item);
row.y = row.items.iter().map(|member| member.y).sum::<f32>() / row.items.len() as f32;
} else {
rows.push(Row {
y: item.y,
items: vec![item],
});
}
}
for row in &mut rows {
row.items.sort_by(|left, right| left.x.total_cmp(&right.x));
}
rows
}
fn side_is_prose(items: &[&TextItem]) -> bool {
let text = items
.iter()
.map(|item| item.text.trim())
.collect::<Vec<_>>()
.join(" ");
let alphabetic_count = text
.chars()
.filter(|character| character.is_alphabetic())
.count();
let cjk_count = text
.chars()
.filter(|character| is_cjk_char(*character))
.count();
(text.split_whitespace().count() >= 3 || cjk_count >= 10) && alphabetic_count >= 10
}
fn aligned_row_split(row: &Row<'_>, x_min: f32, x_max: f32) -> Option<f32> {
if row.items.len() < 2 {
return None;
}
let page_width = x_max - x_min;
let center_low = x_min + page_width * 0.25;
let center_high = x_min + page_width * 0.75;
row.items
.windows(2)
.filter_map(|pair| {
let left_end = pair[0].x + effective_width(pair[0]);
let right_start = pair[1].x;
let gap = right_start - left_end;
let split_x = (left_end + right_start) / 2.0;
if gap < MIN_ROW_GUTTER || split_x < center_low || split_x > center_high {
return None;
}
let left: Vec<&TextItem> = row
.items
.iter()
.copied()
.filter(|item| item.x + effective_width(item) / 2.0 < split_x)
.collect();
let right: Vec<&TextItem> = row
.items
.iter()
.copied()
.filter(|item| item.x + effective_width(item) / 2.0 >= split_x)
.collect();
(side_is_prose(&left) && side_is_prose(&right)).then_some((split_x, gap))
})
.max_by(|left, right| left.1.total_cmp(&right.1))
.map(|candidate| candidate.0)
}
fn local_flow_below_full_width_image(
items: &[TextItem],
images: &[ImageRegion],
x_min: f32,
x_max: f32,
) -> Option<ColumnFlowBand> {
let page_width = x_max - x_min;
let full_width_images: Vec<ImageRegion> = images
.iter()
.copied()
.filter(|&(x0, y0, x1, y1)| {
let width = (x1 - x0).abs();
let height = (y1 - y0).abs();
width >= page_width * 0.65 && height >= 60.0
})
.collect();
// A local column flow below an image is only unambiguous for a single,
// nearly square hero/figure. Wide report banners and full-page artwork
// frequently sit above unrelated page furniture whose aligned labels can
// mimic prose columns.
if full_width_images.len() != 1 {
return None;
}
let (image_x0, _, image_x1, _) = full_width_images[0];
let anchor_width = (image_x1 - image_x0).abs();
let anchor_height = (full_width_images[0].3 - full_width_images[0].1).abs();
if anchor_width < page_width * 0.85
|| anchor_height < anchor_width * 0.85
|| anchor_height > anchor_width * 1.2
{
return None;
}
let image_bottom = full_width_images
.iter()
.map(|&(_, y0, _, y1)| y0.min(y1))
.fold(f32::NEG_INFINITY, f32::max);
if !image_bottom.is_finite() {
return None;
}
let below: Vec<TextItem> = items
.iter()
.filter(|item| item.y < image_bottom && item.y >= image_bottom - 220.0)
.cloned()
.collect();
let candidates: Vec<(f32, f32)> = group_rows(&below)
.into_iter()
.filter_map(|row| aligned_row_split(&row, x_min, x_max).map(|split| (split, row.y)))
.collect();
if candidates.len() < MIN_ALIGNED_ROWS {
return None;
}
let mut clusters: Vec<Vec<(f32, f32)>> = Vec::new();
for candidate in candidates {
if let Some(cluster) = clusters.iter_mut().find(|cluster| {
let mean = cluster.iter().map(|entry| entry.0).sum::<f32>() / cluster.len() as f32;
(mean - candidate.0).abs() <= SPLIT_CLUSTER_TOLERANCE
}) {
cluster.push(candidate);
} else {
clusters.push(vec![candidate]);
}
}
let dominant = clusters.into_iter().max_by_key(Vec::len)?;
if dominant.len() < MIN_ALIGNED_ROWS {
return None;
}
let split_x = dominant.iter().map(|entry| entry.0).sum::<f32>() / dominant.len() as f32;
let y_top = dominant
.iter()
.map(|entry| entry.1)
.fold(f32::NEG_INFINITY, f32::max)
+ 3.0;
let image_gap = image_bottom - y_top;
if !(60.0..=120.0).contains(&image_gap) {
return None;
}
let y_bottom = dominant
.iter()
.map(|entry| entry.1)
.fold(f32::INFINITY, f32::min)
- 3.0;
if y_top - y_bottom > 130.0 {
return None;
}
log::debug!(
"page {}: full-width image flow images={} aligned_rows={} split={:.1} page=[{:.1}..{:.1}] image_bottom={:.1} y=[{:.1}..{:.1}] full_width={:?}",
items.first().map_or(0, |item| item.page),
images.len(),
dominant.len(),
split_x,
x_min,
x_max,
image_bottom,
y_bottom,
y_top,
full_width_images
);
Some(ColumnFlowBand {
split_x,
y_bottom,
y_top,
})
}
fn paired_column_images(
items: &[TextItem],
images: &[ImageRegion],
split_x: f32,
x_min: f32,
x_max: f32,
) -> Option<ColumnFlowBand> {
let page_width = x_max - x_min;
if split_x < x_min + page_width * 0.4 || split_x > x_min + page_width * 0.6 {
return None;
}
let qualifying: Vec<ImageRegion> = images
.iter()
.copied()
.filter(|&(x0, y0, x1, y1)| {
let image_left = x0.min(x1);
let image_right = x0.max(x1);
let confined_to_one_column = image_right <= split_x || image_left >= split_x;
confined_to_one_column
&& (x1 - x0).abs() >= MIN_IMAGE_WIDTH
&& (y1 - y0).abs() >= MIN_IMAGE_HEIGHT
})
.collect();
let wide_images: Vec<ImageRegion> = qualifying
.iter()
.copied()
.filter(|(x0, _, x1, _)| (x1 - x0).abs() >= page_width * 0.35)
.collect();
if qualifying.len() < 3 || wide_images.len() < 3 {
return None;
}
let has_left = qualifying
.iter()
.any(|&(x0, _, x1, _)| (x0 + x1) / 2.0 < split_x);
let has_right = qualifying
.iter()
.any(|&(x0, _, x1, _)| (x0 + x1) / 2.0 >= split_x);
if !has_left || !has_right {
return None;
}
// A meaningful image-backed column flow spans multiple vertical panels.
// Three same-row header/logo images can otherwise satisfy the image count
// and send an ordinary asymmetric page through sequential column order.
let image_y_min = wide_images
.iter()
.map(|region| region.1.min(region.3))
.fold(f32::INFINITY, f32::min);
let image_y_max = wide_images
.iter()
.map(|region| region.1.max(region.3))
.fold(f32::NEG_INFINITY, f32::max);
let has_vertical_stack = wide_images.iter().enumerate().any(|(index, left)| {
wide_images.iter().skip(index + 1).any(|right| {
let same_side =
((left.0 + left.2) / 2.0 < split_x) == ((right.0 + right.2) / 2.0 < split_x);
let left_center = (left.1 + left.3) / 2.0;
let right_center = (right.1 + right.3) / 2.0;
let left_height = (left.3 - left.1).abs();
let right_height = (right.3 - right.1).abs();
let vertical_gap = if left.1.max(left.3) < right.1.min(right.3) {
right.1.min(right.3) - left.1.max(left.3)
} else if right.1.max(right.3) < left.1.min(left.3) {
left.1.min(left.3) - right.1.max(right.3)
} else {
0.0
};
same_side
&& (left_center - right_center).abs() >= left_height.min(right_height) * 0.5
&& vertical_gap <= left_height.max(right_height) * 0.5
})
});
if image_y_max - image_y_min < page_width * 0.45 || !has_vertical_stack {
return None;
}
let y_top = qualifying
.iter()
.map(|region| region.1.max(region.3))
.fold(f32::NEG_INFINITY, f32::max)
+ 3.0;
// Only column-confined text proves the lower extent of the flow. A
// spanning heading or caption below the columns must become the trailing
// full-width node rather than stretching the column band to the page foot.
let y_bottom = items
.iter()
.filter(|item| {
let item_right = item.x + effective_width(item);
item.y <= y_top && (item_right <= split_x || item.x >= split_x)
})
.map(|item| item.y)
.fold(f32::INFINITY, f32::min)
- 3.0;
if !y_bottom.is_finite() {
return None;
}
let distinct_rows = |right: bool| {
let mut ys: Vec<f32> = items
.iter()
.filter(|item| {
item.y <= y_top && (item.x + effective_width(item) / 2.0 >= split_x) == right
})
.map(|item| item.y)
.collect();
ys.sort_by(|left, right| left.total_cmp(right));
ys.dedup_by(|left, right| (*left - *right).abs() <= 3.0);
ys.len()
};
let left_rows = distinct_rows(false);
let right_rows = distinct_rows(true);
let line_balance = left_rows.min(right_rows) as f32 / left_rows.max(right_rows).max(1) as f32;
(left_rows >= 5 && right_rows >= 5 && line_balance < 0.55).then(|| {
log::debug!(
"page {}: paired-image flow qualifying_images={} rows={}/{} split={:.1} page=[{:.1}..{:.1}] y=[{:.1}..{:.1}] images={:?}",
items.first().map_or(0, |item| item.page),
qualifying.len(),
left_rows,
right_rows,
split_x,
x_min,
x_max,
y_bottom,
y_top,
qualifying
);
ColumnFlowBand {
split_x,
y_bottom,
y_top,
}
})
}
pub(crate) fn infer_image_anchored_flow(
items: &[TextItem],
images: &[ImageRegion],
detected_split: Option<f32>,
) -> Option<ColumnFlowBand> {
if items.is_empty() || images.is_empty() {
return None;
}
let (x_min, x_max) = page_x_bounds(items, images)?;
detected_split
.and_then(|split_x| paired_column_images(items, images, split_x, x_min, x_max))
.or_else(|| local_flow_below_full_width_image(items, images, x_min, x_max))
}
/// Partition a page into the topological order `above -> left -> right -> below`.
/// These edges encode the reading-order DAG; empty nodes are omitted.
pub(crate) fn build_region_graph(items: Vec<TextItem>, band: ColumnFlowBand) -> Vec<RegionNode> {
let mut above = Vec::new();
let mut left = Vec::new();
let mut right = Vec::new();
let mut below = Vec::new();
for item in items {
if item.y > band.y_top {
above.push(item);
} else if item.y < band.y_bottom {
below.push(item);
} else if item.x + effective_width(&item) / 2.0 < band.split_x {
left.push(item);
} else {
right.push(item);
}
}
let rtl = is_rtl_text(left.iter().chain(right.iter()).map(|item| &item.text));
let mut ordered = vec![(RegionKind::FullWidth, above)];
if rtl {
ordered.push((RegionKind::Column, right));
ordered.push((RegionKind::Column, left));
} else {
ordered.push((RegionKind::Column, left));
ordered.push((RegionKind::Column, right));
}
ordered.push((RegionKind::FullWidth, below));
ordered
.into_iter()
.filter_map(|(kind, items)| (!items.is_empty()).then_some(RegionNode { kind, items }))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::ItemType;
fn item(text: &str, x: f32, y: f32, width: f32) -> TextItem {
TextItem {
text: text.into(),
x,
y,
width,
height: 11.0,
font: "F1".into(),
font_size: 11.0,
page: 1,
is_bold: false,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: ItemType::Text,
mcid: None,
}
}
#[test]
fn full_width_image_anchors_local_two_column_flow() {
let mut items = vec![
item("A full width caption", 55.0, 230.0, 430.0),
item("A trailing full width heading", 55.0, 80.0, 430.0),
];
for index in 0..5 {
let y = 170.0 - index as f32 * 14.0;
items.push(item("left column prose words", 55.0, y, 210.0));
items.push(item("right column prose words", 280.0, y, 210.0));
}
let images = vec![(55.0, 250.0, 490.0, 680.0)];
let band = infer_image_anchored_flow(&items, &images, None).unwrap();
assert!((band.split_x - 272.5).abs() < 2.0);
let graph = build_region_graph(items, band);
assert_eq!(graph.len(), 4);
assert_eq!(graph[0].kind, RegionKind::FullWidth);
assert_eq!(graph[1].kind, RegionKind::Column);
assert_eq!(graph[2].kind, RegionKind::Column);
assert_eq!(graph[3].kind, RegionKind::FullWidth);
assert_eq!(graph[3].items[0].text, "A trailing full width heading");
}
#[test]
fn full_width_image_anchors_cjk_column_flow() {
let mut items = Vec::new();
for index in 0..5 {
let y = 170.0 - index as f32 * 14.0;
items.push(item("左栏这是没有空格的正文内容", 55.0, y, 210.0));
items.push(item("右栏这是没有空格的正文内容", 280.0, y, 210.0));
}
let images = vec![(55.0, 250.0, 490.0, 680.0)];
assert!(infer_image_anchored_flow(&items, &images, None).is_some());
}
#[test]
fn paired_images_anchor_unbalanced_column_flows() {
let mut items = vec![
item("running header", 55.0, 700.0, 430.0),
item("trailing full width caption", 55.0, 300.0, 430.0),
];
for index in 0..5 {
items.push(item(
"left prose words",
55.0,
500.0 - index as f32 * 14.0,
200.0,
));
items.push(item(
"right prose words",
280.0,
520.0 - index as f32 * 14.0,
200.0,
));
}
for index in 5..12 {
items.push(item(
"right continuation prose words",
280.0,
520.0 - index as f32 * 14.0,
200.0,
));
}
let images = vec![
(55.0, 530.0, 255.0, 680.0),
(55.0, 380.0, 255.0, 530.0),
(280.0, 560.0, 490.0, 680.0),
];
let band = infer_image_anchored_flow(&items, &images, Some(270.0)).unwrap();
let graph = build_region_graph(items, band);
assert_eq!(graph[0].kind, RegionKind::FullWidth);
assert_eq!(graph[1].kind, RegionKind::Column);
assert_eq!(graph[2].kind, RegionKind::Column);
assert_eq!(graph[3].kind, RegionKind::FullWidth);
assert_eq!(graph[3].items[0].text, "trailing full width caption");
}
#[test]
fn rtl_region_graph_reads_right_column_first() {
let items = vec![
item("A long English report header", 55.0, 250.0, 430.0),
item("نص العمود الأيسر", 55.0, 150.0, 180.0),
item("نص العمود الأيمن", 300.0, 150.0, 180.0),
];
let graph = build_region_graph(
items,
ColumnFlowBand {
split_x: 270.0,
y_bottom: 100.0,
y_top: 200.0,
},
);
assert_eq!(graph.len(), 3);
assert_eq!(graph[0].kind, RegionKind::FullWidth);
assert!(graph[1].items[0].x > graph[2].items[0].x);
}
#[test]
fn paired_header_logos_do_not_anchor_page_columns() {
let mut items = Vec::new();
for index in 0..7 {
items.push(item(
"left prose words",
55.0,
700.0 - index as f32 * 14.0,
200.0,
));
}
for index in 0..30 {
items.push(item(
"right prose words",
280.0,
700.0 - index as f32 * 14.0,
200.0,
));
}
let images = vec![
(55.0, 720.0, 205.0, 770.0),
(60.0, 718.0, 210.0, 768.0),
(280.0, 720.0, 450.0, 770.0),
];
assert!(infer_image_anchored_flow(&items, &images, Some(270.0)).is_none());
}
#[test]
fn wide_banner_does_not_anchor_local_columns() {
let mut items = Vec::new();
for index in 0..7 {
let y = 270.0 - index as f32 * 14.0;
items.push(item("left column prose words", 55.0, y, 210.0));
items.push(item("right column prose words", 280.0, y, 210.0));
}
let images = vec![(55.0, 310.0, 490.0, 550.0)];
assert!(infer_image_anchored_flow(&items, &images, None).is_none());
}
}
+5 -10
View File
@@ -54,6 +54,7 @@ pub use extractor::{
};
pub use markdown::{
to_markdown, to_markdown_from_items, to_markdown_from_items_with_rects, MarkdownOptions,
MarkdownProfile,
};
pub use process_mode::ProcessMode;
pub use types::{LayoutComplexity, PdfLine, PdfRect, TextItem};
@@ -1173,7 +1174,7 @@ pub fn detect_vector_grid_in_region_mem(
}
let line_tables =
tables::detect_tables_from_lines(&items_in_region, &lines_in_region, page_1idx);
tables::detect_vector_grid_tables_from_lines(&items_in_region, &lines_in_region, page_1idx);
for table in line_tables {
if let Some(result) = vector_grid_result_from_table(
&table,
@@ -1526,16 +1527,10 @@ mod vector_grid_tests {
/// strip while body rows are drawn with `m`/`l` operators, so the rect
/// cluster has only 2 Y-edges and `try_build_grid` rejects.
///
/// IGNORED: lifting this shape required the exact-duplicate early-dedup
/// (PR #76 first iteration), which had broad collateral damage on
/// SEC 10-K TOCs and similar docs that draw rule-rects above + below
/// section dividers (production diff: 0001104659-25-093871 lost its
/// TOC structure, perf-graph data table, and qualifications matrix).
/// Re-enable once a more surgical lift exists in `try_build_grid` or
/// `snap_edges` that handles cell-border + inner-fill + text-bg rect
/// triplets without page-wide dedup.
/// The page repeats a full-page background many times. Those fills must be
/// removed from clustering before chart/table evidence is evaluated, or
/// they swamp the real cell rectangles and make this table look chart-like.
#[test]
#[ignore]
fn greencomp_competence_two_cols() {
let tables = detect_rect_tables_in_fixture("tests/fixtures/greencomp_competence.pdf");
assert!(
+124
View File
@@ -374,6 +374,15 @@ pub(crate) fn compute_heading_tiers(lines: &[TextLine], base_size: f32) -> Vec<f
for line in lines {
if let Some(first) = line.items.first() {
if first.font_size / base_size >= 1.2 {
// Digit-only lines (page numbers, issue numbers) must not
// define heading tiers: a large bold folio claims tier 0 and
// blocks the bold-size fallback for the document's real
// same-size headings.
let text = line.text();
let t = text.trim();
if !t.is_empty() && t.chars().all(|c| !c.is_alphabetic()) {
continue;
}
heading_sizes.push(first.font_size);
}
}
@@ -391,11 +400,45 @@ pub(crate) fn compute_heading_tiers(lines: &[TextLine], base_size: f32) -> Vec<f
}
}
// Books often set section headings barely above body size (e.g. 11pt
// bold over 10pt text). When nothing clears the 1.2x ratio gate, fall
// back to bold lines modestly larger than body so those documents still
// get an H1 instead of every bold heading defaulting to H2.
if tiers.is_empty() {
let mut bold_sizes: Vec<f32> = lines
.iter()
.filter(|line| {
let text = line.text();
let t = text.trim();
!t.is_empty() && t.chars().any(|c| c.is_alphabetic())
})
.filter_map(|line| line.items.first())
.filter(|it| it.is_bold && it.font_size / base_size >= 1.05)
.map(|it| it.font_size)
.collect();
bold_sizes.sort_by(|a, b| b.total_cmp(a));
for size in bold_sizes {
if !tiers.iter().any(|&t| (t - size).abs() < 0.5) {
tiers.push(size);
}
}
}
// Cap at 4 tiers
tiers.truncate(4);
tiers
}
/// Boldness of a line judged by character mass, so a heading with an
/// unbold section-number prefix ("4. " + bold title) still counts as bold.
pub(crate) fn line_is_mostly_bold(line: &TextLine) -> bool {
let (bold, total) = line.items.iter().fold((0usize, 0usize), |(b, t), it| {
let n = it.text.trim().chars().count();
(b + if it.is_bold { n } else { 0 }, t + n)
});
total > 0 && bold * 2 >= total
}
/// Detect header level from font size using document-specific heading tiers.
/// When tiers are available, maps tier 0→H1, tier 1→H2, etc.
/// Falls back to ratio-based thresholds when no tiers exist.
@@ -403,9 +446,21 @@ pub(crate) fn detect_header_level(
font_size: f32,
base_size: f32,
heading_tiers: &[f32],
is_bold: bool,
) -> Option<usize> {
let ratio = font_size / base_size;
// Tier matches are trusted below the 1.2x gate (down to 1.05x) only for
// bold lines: sub-gate tiers come from the bold fallback, and honoring
// them for non-bold text at the same size would promote captions.
if (1.05..1.2).contains(&ratio) && is_bold && !heading_tiers.is_empty() {
for (i, &tier_size) in heading_tiers.iter().enumerate() {
if (font_size - tier_size).abs() < 0.5 {
return Some(i + 1); // tier 0 → H1, tier 1 → H2, etc.
}
}
}
if ratio < 1.2 {
return None; // Regular text
}
@@ -442,6 +497,75 @@ pub(crate) fn detect_header_level(
mod tests {
use super::*;
fn line_of(text: &str, font_size: f32, bold: bool, y: f32) -> crate::types::TextLine {
let item = crate::types::TextItem {
text: text.into(),
x: 72.0,
y,
width: text.len() as f32 * font_size * 0.5,
height: font_size,
font: "Test".into(),
font_size,
page: 1,
is_bold: bold,
is_italic: false,
is_underline: false,
is_strikeout: false,
item_type: crate::types::ItemType::Text,
mcid: None,
};
crate::types::TextLine {
items: vec![item],
y,
page: 1,
adaptive_threshold: 0.10,
}
}
#[test]
fn digit_only_lines_do_not_define_tiers() {
// A 14pt bold page number must not claim tier 0 — that both demotes
// every real heading a level and blocks the bold-size fallback.
let lines = vec![
line_of("76", 14.0, true, 760.0),
line_of("Replace", 11.0, true, 700.0),
line_of("body text at eleven points", 11.0, false, 680.0),
];
let tiers = compute_heading_tiers(&lines, 11.0);
assert!(tiers.is_empty(), "page number claimed a tier: {tiers:?}");
}
#[test]
fn bold_fallback_tiers_when_nothing_clears_ratio_gate() {
// 10pt body, 11pt bold section headings (book-style): no size clears
// 1.2x, so bold sizes modestly above body form the tiers.
let lines = vec![
line_of("4. Entropy", 11.0, true, 700.0),
line_of("body text about entropy", 10.0, false, 680.0),
line_of("5. The dynamics", 11.0, true, 500.0),
];
let tiers = compute_heading_tiers(&lines, 10.0);
assert_eq!(tiers, vec![11.0]);
assert_eq!(detect_header_level(11.0, 10.0, &tiers, true), Some(1));
// Non-bold text at the fallback size must not become a heading.
assert_eq!(detect_header_level(11.0, 10.0, &tiers, false), None);
// Non-tier body text stays regular.
assert_eq!(detect_header_level(10.0, 10.0, &tiers, true), None);
}
#[test]
fn bold_fallback_skipped_when_real_tiers_exist() {
let lines = vec![
line_of("Chapter One", 18.0, false, 700.0),
line_of("bold label", 11.0, true, 600.0),
line_of("body", 10.0, false, 580.0),
];
let tiers = compute_heading_tiers(&lines, 10.0);
assert_eq!(tiers, vec![18.0]);
// The 11pt bold label does not match any tier and stays non-heading.
assert_eq!(detect_header_level(11.0, 10.0, &tiers, true), None);
}
#[test]
fn toc_entry_with_single_dot_group() {
assert!(is_toc_entry_line("Measurement Lab worksheet ... 3"));
+519 -97
View File
@@ -1,5 +1,6 @@
//! Core line-to-markdown conversion loop with table/image interleaving.
use std::cmp::Ordering;
use std::collections::{HashMap, HashSet};
use crate::structure_tree::StructRole;
@@ -13,9 +14,149 @@ use super::analysis::{
use super::classify::{
format_list_item, is_caption_line, is_list_item, is_monospace_font, starts_with_bullet_marker,
};
use super::heading::classify_heading_sequences;
use super::postprocess::clean_markdown;
use super::preprocess::{merge_drop_caps, merge_heading_lines};
use super::MarkdownOptions;
use super::{item_is_in_chart_region, MarkdownOptions, CHART_SEPARATOR_PAD};
/// Logical stream geometry for a page where one full-width chart separates
/// two prose columns. Positioned non-text blocks use this same ordering so a
/// right-column table or image cannot jump ahead of left-column prose.
#[derive(Debug, Clone, Copy)]
pub(super) struct ChartProseOrder {
split_x: f32,
chart_region: (f32, f32, f32, f32),
}
impl ChartProseOrder {
pub(super) fn new(split_x: f32, chart_region: (f32, f32, f32, f32)) -> Self {
Self {
split_x,
chart_region,
}
}
}
/// Markdown block with its physical position and optional logical chart-page
/// stream. Tables and images share this representation because both are
/// removed before text-line grouping and reinserted during conversion.
#[derive(Debug, Clone)]
pub(super) struct PositionedMarkdown {
y: f32,
x: f32,
markdown: String,
chart_order: Option<ChartProseOrder>,
}
impl PositionedMarkdown {
pub(super) fn new(
y: f32,
x: f32,
markdown: String,
chart_order: Option<ChartProseOrder>,
) -> Self {
Self {
y,
x,
markdown,
chart_order,
}
}
}
fn chart_stream_position(
y: f32,
x: f32,
claimed_by_chart: bool,
order: ChartProseOrder,
) -> (u8, u8) {
let (_, y0, _, y1) = order.chart_region;
let low = y0.min(y1) - CHART_SEPARATOR_PAD;
let high = y0.max(y1) + CHART_SEPARATOR_PAD;
let in_chart_zone = claimed_by_chart || (y >= low && y <= high);
let zone = if in_chart_zone {
1
} else if y > high {
0
} else {
2
};
let column = if in_chart_zone || x < order.split_x {
0
} else {
1
};
(zone, column)
}
fn positioned_block_precedes_line(block: &PositionedMarkdown, line: &TextLine) -> bool {
let Some(order) = block.chart_order else {
return block.y > line.y;
};
let line_x = line.items.first().map(|item| item.x).unwrap_or(0.0);
let line_claimed_by_chart = line
.items
.iter()
.any(|item| item_is_in_chart_region(item, &[order.chart_region]));
let block_position = chart_stream_position(block.y, block.x, false, order);
let line_position = chart_stream_position(line.y, line_x, line_claimed_by_chart, order);
block_position < line_position || (block_position == line_position && block.y > line.y)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
enum PositionedBlockKind {
Table,
Image,
}
type PositionedBlockRef<'a> = (PositionedBlockKind, usize, &'a PositionedMarkdown);
fn compare_positioned_blocks(
(a_kind, a_idx, a): &PositionedBlockRef<'_>,
(b_kind, b_idx, b): &PositionedBlockRef<'_>,
) -> Ordering {
if let (Some(a_order), Some(b_order)) = (a.chart_order, b.chart_order) {
let a_position = chart_stream_position(a.y, a.x, false, a_order);
let b_position = chart_stream_position(b.y, b.x, false, b_order);
return a_position
.cmp(&b_position)
.then_with(|| b.y.total_cmp(&a.y))
.then_with(|| a.x.total_cmp(&b.x))
.then_with(|| a_kind.cmp(b_kind))
.then_with(|| a_idx.cmp(b_idx));
}
// Preserve the legacy ordering for ordinary pages: tables in detection
// order, followed by images in input order. Chart pages give every block
// a chart order and use the logical stream comparison above.
a_kind.cmp(b_kind).then_with(|| a_idx.cmp(b_idx))
}
fn positioned_blocks_for_page<'a>(
page: u32,
page_tables: &'a HashMap<u32, Vec<PositionedMarkdown>>,
page_images: &'a HashMap<u32, Vec<PositionedMarkdown>>,
) -> Vec<PositionedBlockRef<'a>> {
let mut blocks = Vec::new();
if let Some(tables) = page_tables.get(&page) {
blocks.extend(
tables
.iter()
.enumerate()
.map(|(idx, table)| (PositionedBlockKind::Table, idx, table)),
);
}
if let Some(images) = page_images.get(&page) {
blocks.extend(
images
.iter()
.enumerate()
.map(|(idx, image)| (PositionedBlockKind::Image, idx, image)),
);
}
blocks.sort_by(compare_positioned_blocks);
blocks
}
/// Pre-scan struct heading tags to find levels that are overused — i.e., tagged on
/// so many lines that they clearly represent body text, not real headings.
@@ -160,6 +301,112 @@ fn find_isolated_lines(lines: &[TextLine], base_size: f32, para_threshold: f32)
/// wrapped visual line as "standalone" once the first line is misclassified,
/// producing a stack of `##` headings. Multi-line body-size bold runs with a
/// paragraph-sized word count should stay paragraph text.
/// Merge 2-3 consecutive all-bold body-size lines into one line when the
/// group is isolated (paragraph break before and after) and short enough to
/// be a heading. Longer/wordier bold runs are wrapped bold paragraphs and
/// are left for `find_wrapped_bold_paragraph_lines` to suppress.
/// "9.5. ", "12.3.1. " — section-numbered heading prefix followed by a word.
fn starts_with_section_number(t: &str) -> bool {
let t = t.trim_start();
let mut rest = t;
let mut groups = 0;
loop {
let digits = rest.chars().take_while(|c| c.is_ascii_digit()).count();
if digits == 0 || digits > 3 {
break;
}
groups += 1;
rest = &rest[digits..];
if let Some(r) = rest.strip_prefix('.') {
rest = r;
} else {
break;
}
}
// Two components minimum ("9.5. "): a single "1. " is an ordered list
// item, and this prefix bypasses the isolation checks entirely.
groups >= 2
&& rest.starts_with(char::is_whitespace)
&& rest.trim_start().starts_with(|c: char| c.is_alphabetic())
}
fn merge_wrapped_bold_heading_groups(
lines: Vec<TextLine>,
base_size: f32,
para_threshold: f32,
) -> Vec<TextLine> {
let mut out: Vec<TextLine> = Vec::with_capacity(lines.len());
let mut i = 0usize;
while i < lines.len() {
if !is_body_size_all_bold_line(&lines[i], base_size) {
out.push(lines[i].clone());
i += 1;
continue;
}
let start = i;
let mut end = i;
let mut word_count = lines[i].text().split_whitespace().count();
while end + 1 < lines.len()
&& is_body_size_all_bold_line(&lines[end + 1], base_size)
&& is_wrapped_same_style_line(&lines[end], &lines[end + 1], para_threshold)
{
end += 1;
word_count += lines[end].text().split_whitespace().count();
}
let line_count = end - start + 1;
// Column-local isolation: on interleaved multi-column pages the
// vector neighbors may be the other column's lines, so judge the
// break by x-overlapping lines only.
let gx0 = lines[start..=end]
.iter()
.flat_map(|l| l.items.iter().map(|i| i.x))
.fold(f32::INFINITY, f32::min);
let gx1 = lines[start..=end]
.iter()
.flat_map(|l| l.items.iter().map(|i| i.x + i.width))
.fold(f32::NEG_INFINITY, f32::max);
let overlaps_x = |l: &TextLine| {
let lx0 = l.items.iter().map(|i| i.x).fold(f32::INFINITY, f32::min);
let lx1 = l
.items
.iter()
.map(|i| i.x + i.width)
.fold(f32::NEG_INFINITY, f32::max);
lx0 < gx1 && lx1 > gx0
};
let page = lines[start].page;
let break_before = !lines.iter().any(|l| {
l.page == page
&& l.y > lines[start].y
&& l.y - lines[start].y <= para_threshold
&& overlaps_x(l)
});
let break_after = !lines.iter().any(|l| {
l.page == page
&& l.y < lines[end].y
&& lines[end].y - l.y <= para_threshold
&& overlaps_x(l)
});
let numbered = starts_with_section_number(&lines[start].text());
if (2..=3).contains(&line_count)
&& word_count <= 15
&& ((break_before && break_after) || numbered)
{
let mut merged = lines[start].clone();
for l in &lines[start + 1..=end] {
merged.items.extend(l.items.iter().cloned());
}
out.push(merged);
} else {
for l in &lines[start..=end] {
out.push(l.clone());
}
}
i = end + 1;
}
out
}
fn find_wrapped_bold_paragraph_lines(
lines: &[TextLine],
base_size: f32,
@@ -277,7 +524,7 @@ fn struct_role_heading_level(role: &StructRole) -> Option<usize> {
/// We strip their header+separator rows and append their data rows to the first page's
/// table, then remove them from later pages.
pub(super) fn merge_continuation_tables(
page_tables: &mut std::collections::HashMap<u32, Vec<(f32, String)>>,
page_tables: &mut std::collections::HashMap<u32, Vec<PositionedMarkdown>>,
table_only_pages: &HashSet<u32>,
) {
let mut sorted_pages: Vec<u32> = page_tables.keys().copied().collect();
@@ -305,7 +552,7 @@ pub(super) fn merge_continuation_tables(
continue;
}
let first_col_count = count_table_columns(&first_tables[0].1);
let first_col_count = count_table_columns(&first_tables[0].markdown);
if first_col_count == 0 {
i += 1;
continue;
@@ -336,7 +583,7 @@ pub(super) fn merge_continuation_tables(
_ => break,
};
let next_col_count = count_table_columns(&next_tables[0].1);
let next_col_count = count_table_columns(&next_tables[0].markdown);
if next_col_count != first_col_count {
break;
}
@@ -350,7 +597,7 @@ pub(super) fn merge_continuation_tables(
let mut extra_rows = String::new();
for &cont_page in &continuation_pages {
if let Some(tables) = page_tables.get(&cont_page) {
let table_md = &tables[0].1;
let table_md = &tables[0].markdown;
// Skip header row (line 1) and separator row (line 2), keep the rest
for (line_idx, line) in table_md.lines().enumerate() {
if line_idx >= 2 {
@@ -363,7 +610,7 @@ pub(super) fn merge_continuation_tables(
// Append continuation rows to the first page's table
if let Some(tables) = page_tables.get_mut(&first_page) {
tables[0].1.push_str(&extra_rows);
tables[0].markdown.push_str(&extra_rows);
}
// Remove continuation pages from the map
@@ -395,37 +642,35 @@ fn count_table_columns(table_md: &str) -> usize {
/// Flush any remaining tables and images for a given page
fn flush_page_tables_and_images(
page: u32,
page_tables: &std::collections::HashMap<u32, Vec<(f32, String)>>,
page_images: &std::collections::HashMap<u32, Vec<(f32, String)>>,
page_blocks: &HashMap<u32, Vec<PositionedBlockRef<'_>>>,
inserted_tables: &mut HashSet<(u32, usize)>,
inserted_images: &mut HashSet<(u32, usize)>,
output: &mut String,
in_paragraph: &mut bool,
) {
if let Some(tables) = page_tables.get(&page) {
for (idx, (_, table_md)) in tables.iter().enumerate() {
if !inserted_tables.contains(&(page, idx)) {
if *in_paragraph {
output.push_str("\n\n");
*in_paragraph = false;
}
output.push('\n');
output.push_str(table_md);
output.push('\n');
let Some(blocks) = page_blocks.get(&page) else {
return;
};
for &(kind, idx, block) in blocks {
let already_inserted = match kind {
PositionedBlockKind::Table => inserted_tables.contains(&(page, idx)),
PositionedBlockKind::Image => inserted_images.contains(&(page, idx)),
};
if already_inserted {
continue;
}
if *in_paragraph {
output.push_str("\n\n");
*in_paragraph = false;
}
output.push('\n');
output.push_str(&block.markdown);
output.push('\n');
match kind {
PositionedBlockKind::Table => {
inserted_tables.insert((page, idx));
}
}
}
if let Some(images) = page_images.get(&page) {
for (idx, (_, image_md)) in images.iter().enumerate() {
if !inserted_images.contains(&(page, idx)) {
if *in_paragraph {
output.push_str("\n\n");
*in_paragraph = false;
}
output.push('\n');
output.push_str(image_md);
output.push('\n');
PositionedBlockKind::Image => {
inserted_images.insert((page, idx));
}
}
@@ -436,8 +681,9 @@ fn flush_page_tables_and_images(
pub(super) fn to_markdown_from_lines_with_tables_and_images(
lines: Vec<TextLine>,
options: MarkdownOptions,
page_tables: std::collections::HashMap<u32, Vec<(f32, String)>>,
page_images: std::collections::HashMap<u32, Vec<(f32, String)>>,
page_tables: std::collections::HashMap<u32, Vec<PositionedMarkdown>>,
page_images: std::collections::HashMap<u32, Vec<PositionedMarkdown>>,
page_chart_regions: &std::collections::HashMap<u32, Vec<(f32, f32, f32, f32)>>,
band_split_pages: &HashSet<u32>,
struct_roles: Option<
&std::collections::HashMap<u32, std::collections::HashMap<i64, StructRole>>,
@@ -468,6 +714,17 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
// threshold and cause every line to be treated as a paragraph break.
let para_threshold = compute_paragraph_threshold(&lines, base_size);
// Merge wrapped bold headings: a 2-3 line group of consecutive all-bold
// body-size lines that is isolated as a group (paragraph break before
// and after) is one heading that wrapped. Left split, the internal line
// gap breaks each line's isolation and neither classifies as a heading —
// the whole group then merges into the following body paragraph.
let lines = if std::env::var("PI_NO_MERGE").is_ok() {
lines
} else {
merge_wrapped_bold_heading_groups(lines, base_size, para_threshold)
};
// Pre-scan: identify isolated lines (paragraph break before AND after).
// These are heading candidates even without bold/large font — common in
// academic papers where section titles like "Acknowledgements" sit alone
@@ -477,6 +734,33 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
let wrapped_bold_paragraph_lines =
find_wrapped_bold_paragraph_lines(&lines, base_size, para_threshold);
let mut sequence_excluded_lines = wrapped_bold_paragraph_lines.clone();
for (line_idx, line) in lines.iter().enumerate() {
if page_chart_regions.get(&line.page).is_some_and(|regions| {
line.items
.iter()
.any(|item| item_is_in_chart_region(item, regions))
}) {
sequence_excluded_lines.insert(line_idx);
}
}
if let Some(roles) = struct_roles {
for (line_idx, line) in lines.iter().enumerate() {
if resolve_line_struct_role(line, roles)
.is_some_and(|role| role.is_non_heading_content())
{
sequence_excluded_lines.insert(line_idx);
}
}
}
let sequence_heading_levels = classify_heading_sequences(
&lines,
base_size,
&heading_tiers,
&isolated_lines,
&sequence_excluded_lines,
);
// Detect struct heading levels that are overused (body text mistagged as headings)
let overused_heading_levels = detect_overused_struct_heading_levels(&lines, struct_roles);
@@ -502,6 +786,18 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
.collect();
all_content_pages.sort();
all_content_pages.dedup();
// Build the unified table/image order once per page. This is only a
// meaningful sort on chart/prose pages; ordinary pages retain their
// legacy table-then-image order without repeating work for every line.
let page_blocks: HashMap<u32, Vec<PositionedBlockRef<'_>>> = all_content_pages
.iter()
.map(|&page| {
(
page,
positioned_blocks_for_page(page, &page_tables, &page_images),
)
})
.collect();
for (line_idx, line) in lines.iter().enumerate() {
// Page break
@@ -514,8 +810,7 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
}
flush_page_tables_and_images(
current_page,
&page_tables,
&page_images,
&page_blocks,
&mut inserted_tables,
&mut inserted_images,
&mut output,
@@ -539,8 +834,7 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
}
flush_page_tables_and_images(
p,
&page_tables,
&page_images,
&page_blocks,
&mut inserted_tables,
&mut inserted_images,
&mut output,
@@ -563,38 +857,32 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
}
}
// Check if we should insert a table before this line
if let Some(tables) = page_tables.get(&current_page) {
for (idx, (table_y, table_md)) in tables.iter().enumerate() {
// Insert table when we pass its Y position
if *table_y > line.y && !inserted_tables.contains(&(current_page, idx)) {
// Insert tables and images through one ordered stream. Chart/prose
// pages sort by zone, column, and physical Y; ordinary pages retain
// the legacy table-then-image input order.
if let Some(blocks) = page_blocks.get(&current_page) {
for &(kind, idx, block) in blocks {
let already_inserted = match kind {
PositionedBlockKind::Table => inserted_tables.contains(&(current_page, idx)),
PositionedBlockKind::Image => inserted_images.contains(&(current_page, idx)),
};
if positioned_block_precedes_line(block, line) && !already_inserted {
if in_paragraph {
output.push_str("\n\n");
in_paragraph = false;
paragraph_in_wrapped_bold_run = false;
}
output.push('\n');
output.push_str(table_md);
output.push_str(&block.markdown);
output.push('\n');
inserted_tables.insert((current_page, idx));
}
}
}
// Check if we should insert an image before this line
if let Some(images) = page_images.get(&current_page) {
for (idx, (image_y, image_md)) in images.iter().enumerate() {
// Insert image when we pass its Y position
if *image_y > line.y && !inserted_images.contains(&(current_page, idx)) {
if in_paragraph {
output.push_str("\n\n");
in_paragraph = false;
paragraph_in_wrapped_bold_run = false;
match kind {
PositionedBlockKind::Table => {
inserted_tables.insert((current_page, idx));
}
PositionedBlockKind::Image => {
inserted_images.insert((current_page, idx));
}
}
output.push('\n');
output.push_str(image_md);
output.push('\n');
inserted_images.insert((current_page, idx));
}
}
}
@@ -721,7 +1009,13 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
&& toc_suppress_page != Some(line.page)
{
let line_font_size = line.items.first().map(|i| i.font_size).unwrap_or(base_size);
detect_header_level(line_font_size, base_size, &heading_tiers).or_else(|| {
detect_header_level(
line_font_size,
base_size,
&heading_tiers,
crate::markdown::analysis::line_is_mostly_bold(line),
)
.or_else(|| {
// Rarity-based heading detection (inspired by opendataloader).
// Heading probability scoring with lookahead context.
// Score = rarity * 0.5 + bold * 0.3 + standalone * 0.2
@@ -754,16 +1048,23 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
// paragraph continuity and minor font-size variation
// inflates rarity scores.
let has_strong_signal = all_bold || isolated || (rarity >= 0.97 && word_count <= 8);
// Single-word headings ("IMPLEMENTATION", "CONTENTS") are common;
// accept them only with the strongest signal combination.
let enough_words =
word_count >= 2 || (all_bold && isolated && plain_trimmed.len() >= 4);
if score >= 0.5 && standalone && enough_words && has_strong_signal {
// Single-word headings ("IMPLEMENTATION", "CONTENTS",
// "Replace") are common. All-bold single words qualify when
// standalone (paragraph break before / page top) — headings
// hug their section's first paragraph, so requiring a break
// after as well missed most of them. Mixed bold lead-ins
// ("Note: ...") are excluded by all_bold.
let enough_words = word_count >= 2 || (all_bold && plain_trimmed.len() >= 4);
let numbered_bold = all_bold && starts_with_section_number(plain_trimmed);
if numbered_bold
|| (score >= 0.5 && standalone && enough_words && has_strong_signal)
{
Some(bold_heading_level(&heading_tiers))
} else {
None
}
})
.or_else(|| sequence_heading_levels.get(&line_idx).copied())
} else {
None
};
@@ -915,8 +1216,7 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
// (handles table-only pages after the last text line, and trailing image-only pages)
flush_page_tables_and_images(
current_page,
&page_tables,
&page_images,
&page_blocks,
&mut inserted_tables,
&mut inserted_images,
&mut output,
@@ -928,8 +1228,7 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
}
flush_page_tables_and_images(
p,
&page_tables,
&page_images,
&page_blocks,
&mut inserted_tables,
&mut inserted_images,
&mut output,
@@ -973,6 +1272,13 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
let isolated_lines = find_isolated_lines(&lines, base_size, para_threshold);
let wrapped_bold_paragraph_lines =
find_wrapped_bold_paragraph_lines(&lines, base_size, para_threshold);
let sequence_heading_levels = classify_heading_sequences(
&lines,
base_size,
&heading_tiers,
&isolated_lines,
&wrapped_bold_paragraph_lines,
);
let mut output = String::new();
let mut current_page = 0u32;
@@ -1067,33 +1373,39 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
&& !(options.detect_code && line.items.iter().any(|i| is_monospace_font(&i.font)))
{
let line_font_size = line.items.first().map(|i| i.font_size).unwrap_or(base_size);
if let Some(header_level) =
detect_header_level(line_font_size, base_size, &heading_tiers).or_else(|| {
if line_font_size < base_size * 0.95 {
return None;
}
let word_count = plain_trimmed.split_whitespace().count();
if !(1..=15).contains(&word_count) {
return None;
}
if wrapped_bold_paragraph_lines.contains(&line_idx) {
return None;
}
let rarity = font_size_rarity(line_font_size, &font_stats);
let all_bold = !line.items.is_empty() && line.items.iter().all(|i| i.is_bold);
let standalone = !in_paragraph;
let isolated = isolated_lines.contains(&line_idx);
let score = rarity * 0.5
+ if all_bold { 0.3 } else { 0.0 }
+ if standalone { 0.2 } else { 0.0 }
+ if isolated { 0.3 } else { 0.0 };
let enough_words =
word_count >= 2 || (all_bold && isolated && plain_trimmed.len() >= 4);
if score >= 0.5 && standalone && enough_words {
return Some(bold_heading_level(&heading_tiers));
}
None
})
if let Some(header_level) = detect_header_level(
line_font_size,
base_size,
&heading_tiers,
crate::markdown::analysis::line_is_mostly_bold(line),
)
.or_else(|| {
if line_font_size < base_size * 0.95 {
return None;
}
let word_count = plain_trimmed.split_whitespace().count();
if !(1..=15).contains(&word_count) {
return None;
}
if wrapped_bold_paragraph_lines.contains(&line_idx) {
return None;
}
let rarity = font_size_rarity(line_font_size, &font_stats);
let all_bold = !line.items.is_empty() && line.items.iter().all(|i| i.is_bold);
let standalone = !in_paragraph;
let isolated = isolated_lines.contains(&line_idx);
let score = rarity * 0.5
+ if all_bold { 0.3 } else { 0.0 }
+ if standalone { 0.2 } else { 0.0 }
+ if isolated { 0.3 } else { 0.0 };
let enough_words =
word_count >= 2 || (all_bold && isolated && plain_trimmed.len() >= 4);
if score >= 0.5 && standalone && enough_words {
return Some(bold_heading_level(&heading_tiers));
}
None
})
.or_else(|| sequence_heading_levels.get(&line_idx).copied())
{
if in_paragraph {
output.push_str("\n\n");
@@ -1204,6 +1516,20 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
#[cfg(test)]
mod tests {
#[test]
fn section_number_prefix_detection() {
assert!(starts_with_section_number(
"9.5. Adapting to the New Normal"
));
assert!(starts_with_section_number("12.3.1. Deep subsection"));
assert!(starts_with_section_number("2.1 Systems thinking"));
assert!(!starts_with_section_number("1. First item in a list"));
assert!(!starts_with_section_number("24% in October 2020."));
assert!(!starts_with_section_number("2020 was a hard year"));
assert!(!starts_with_section_number("Introduction"));
}
use super::*;
use crate::structure_tree::StructRole;
use crate::types::TextItem;
@@ -1245,6 +1571,91 @@ mod tests {
make_line(vec![item])
}
#[test]
fn chart_page_blocks_follow_zone_and_column_stream() {
let line = |text: &str, x: f32, y: f32| {
let mut item = make_item(text, 1, None);
item.x = x;
item.y = y;
make_line(vec![item])
};
// Logical newspaper order for the prose zone: all left-column lines,
// then all right-column lines, even though their physical Y values
// jump back upward at the column switch.
let lines = vec![
line("Left column upper prose.", 90.0, 700.0),
line("Left column lower prose.", 90.0, 500.0),
line("Right column upper prose.", 340.0, 700.0),
line("Right column lower prose.", 340.0, 500.0),
];
let order = ChartProseOrder::new(280.0, (100.0, 300.0, 500.0, 400.0));
let mut tables = HashMap::new();
tables.insert(
1,
vec![
// Detection order is deliberately right before left.
PositionedMarkdown::new(
600.0,
340.0,
"| Right metric | Value |\n|---|---|\n| A | 1 |\n".into(),
Some(order),
),
PositionedMarkdown::new(
550.0,
90.0,
"| Left metric | Value |\n|---|---|\n| B | 2 |\n".into(),
Some(order),
),
],
);
let mut images = HashMap::new();
images.insert(
1,
vec![
PositionedMarkdown::new(
575.0,
90.0,
"![Left column figure](left-image)\n".into(),
Some(order),
),
PositionedMarkdown::new(
575.0,
340.0,
"![Right column figure](right-image)\n".into(),
Some(order),
),
],
);
let md = to_markdown_from_lines_with_tables_and_images(
lines,
MarkdownOptions::default(),
tables,
images,
&HashMap::new(),
&HashSet::from([1]),
None,
);
let positions = [
"Left column upper prose.",
"![Left column figure](left-image)",
"| Left metric | Value |",
"Left column lower prose.",
"Right column upper prose.",
"| Right metric | Value |",
"![Right column figure](right-image)",
"Right column lower prose.",
]
.map(|needle| {
md.find(needle)
.unwrap_or_else(|| panic!("missing {needle:?} in {md}"))
});
assert!(
positions.windows(2).all(|pair| pair[0] < pair[1]),
"blocks must follow the logical chart-page stream: {md}"
);
}
#[test]
fn isolated_lines_kept_on_sparse_pages() {
// A ToC page with a lone title and one entry far below: the density
@@ -1297,6 +1708,7 @@ mod tests {
MarkdownOptions::default(),
HashMap::new(),
HashMap::new(),
&HashMap::new(),
&std::collections::HashSet::new(),
Some(&roles),
);
@@ -1325,6 +1737,7 @@ mod tests {
MarkdownOptions::default(),
HashMap::new(),
HashMap::new(),
&HashMap::new(),
&std::collections::HashSet::new(),
Some(&roles),
);
@@ -1366,6 +1779,7 @@ mod tests {
MarkdownOptions::default(),
HashMap::new(),
HashMap::new(),
&HashMap::new(),
&std::collections::HashSet::new(),
Some(&roles),
);
@@ -1402,6 +1816,7 @@ mod tests {
MarkdownOptions::default(),
HashMap::new(),
HashMap::new(),
&HashMap::new(),
&std::collections::HashSet::new(),
Some(&roles),
);
@@ -1437,6 +1852,7 @@ mod tests {
MarkdownOptions::default(),
HashMap::new(),
HashMap::new(),
&HashMap::new(),
&std::collections::HashSet::new(),
Some(&roles),
);
@@ -1466,6 +1882,7 @@ mod tests {
MarkdownOptions::default(),
HashMap::new(),
HashMap::new(),
&HashMap::new(),
&std::collections::HashSet::new(),
None,
);
@@ -1507,6 +1924,7 @@ mod tests {
MarkdownOptions::default(),
HashMap::new(),
HashMap::new(),
&HashMap::new(),
&std::collections::HashSet::new(),
Some(&roles),
);
@@ -1555,6 +1973,7 @@ mod tests {
MarkdownOptions::default(),
HashMap::new(),
HashMap::new(),
&HashMap::new(),
&std::collections::HashSet::new(),
None,
);
@@ -1656,6 +2075,7 @@ mod tests {
MarkdownOptions::default(),
HashMap::new(),
HashMap::new(),
&HashMap::new(),
&std::collections::HashSet::new(),
None,
);
@@ -1703,6 +2123,7 @@ mod tests {
MarkdownOptions::default(),
HashMap::new(),
HashMap::new(),
&HashMap::new(),
&std::collections::HashSet::new(),
Some(&roles),
);
@@ -1842,6 +2263,7 @@ mod tests {
MarkdownOptions::default(),
HashMap::new(),
HashMap::new(),
&HashMap::new(),
&std::collections::HashSet::new(),
None,
);
File diff suppressed because it is too large Load Diff
+1223 -85
View File
File diff suppressed because it is too large Load Diff
+23 -3
View File
@@ -2,12 +2,15 @@
use regex::Regex;
use super::MarkdownOptions;
use super::{MarkdownOptions, MarkdownProfile};
/// Clean up markdown output with post-processing
pub(crate) fn clean_markdown(mut text: String, options: &MarkdownOptions) -> String {
// Collapse dot leaders (e.g. TOC entries: "Introduction...............................1")
text = collapse_dot_leaders(&text);
if options.profile == MarkdownProfile::Compact {
// Dot-leader collapse saves tokens but changes source text, so it is
// reserved for the explicit compact profile.
text = collapse_dot_leaders(&text);
}
// Fix hyphenation first (before other processing)
if options.fix_hyphenation {
@@ -355,6 +358,23 @@ fn format_urls(text: &str) -> String {
mod tests {
use super::*;
#[test]
fn fidelity_profile_preserves_dot_leaders() {
let input = "Introduction............................1".to_string();
let result = clean_markdown(input.clone(), &MarkdownOptions::default());
assert_eq!(result, format!("{input}\n"));
}
#[test]
fn compact_profile_collapses_dot_leaders() {
let input = "Introduction............................1".to_string();
let options = MarkdownOptions {
profile: MarkdownProfile::Compact,
..MarkdownOptions::default()
};
assert_eq!(clean_markdown(input, &options), "Introduction ... 1\n");
}
// --- collapse_dot_leaders ---
#[test]
+6 -1
View File
@@ -42,7 +42,12 @@ fn effective_heading_level(
// Fall back to font-size heuristic
let font = line.items.first().map(|i| i.font_size).unwrap_or(base_size);
detect_header_level(font, base_size, heading_tiers)
detect_header_level(
font,
base_size,
heading_tiers,
crate::markdown::analysis::line_is_mostly_bold(line),
)
}
/// Merge consecutive heading lines at the same level into a single line.
+282 -1
View File
@@ -914,7 +914,126 @@ fn looks_like_number(s: &str) -> bool {
///
/// Used by format.rs to render TOCs as flat lists instead of markdown tables.
pub fn is_table_of_contents(cells: &[Vec<String>]) -> bool {
is_dot_leader_toc(cells) || is_tabular_toc(cells)
is_dot_leader_toc(cells) || is_tabular_toc(cells) || is_page_number_toc(cells)
}
/// Parse a page-number-like token: a short arabic integer (≤4 digits) or a
/// canonical roman numeral (front-matter pages: i, ii, …, xxxviii). Roman
/// parsing is shared with the formatter via `super::canonical_roman_value` so
/// the two stay in sync.
fn page_number_value(token: &str) -> Option<u32> {
let t = token.trim();
if t.is_empty() {
return None;
}
if t.chars().all(|c| c.is_ascii_digit()) && t.len() <= 4 {
return t.parse().ok();
}
super::canonical_roman_value(t)
}
/// Page-number-column TOC: title-based contents with no dot leaders and no
/// section numbers (e.g. "About the Publisher vii", "Experiment #1 … 3").
/// The signature is a text-title first column and a last column that is almost
/// entirely page numbers whose values are *mostly non-decreasing* — the
/// monotonic run is what separates a real TOC from an incidental 2-column
/// numeric data table.
pub(super) fn is_page_number_toc(cells: &[Vec<String>]) -> bool {
let num_cols = cells.first().map(|r| r.len()).unwrap_or(0);
// A page-number TOC is a narrow list (title + page, optionally a leader
// column). Wider grids are data tables, not contents.
if !(2..=3).contains(&num_cols) || cells.len() < 5 {
return false;
}
let last = num_cols - 1;
// No header row: a TOC's first row is already an entry, so its last cell is
// a page number. A data table's first row is a column header (non-numeric,
// or an empty units cell like "Category | ") — the tell that separates
// "Mineral | CEC" tables from real contents. Check the actual first row,
// not the first non-empty one, so a blank header cell still rejects.
let first_last = cells[0].get(last).map(|s| s.trim()).unwrap_or("");
if page_number_value(first_last).is_none() {
return false;
}
// Last column: page numbers on ≥70% of filled rows; collect their values.
let mut filled = 0u32;
let mut page_vals: Vec<u32> = Vec::new();
for row in cells {
let cell = row.get(last).map(|s| s.trim()).unwrap_or("");
if cell.is_empty() {
continue;
}
filled += 1;
if let Some(v) = page_number_value(cell) {
page_vals.push(v);
}
}
if filled < 4 || (page_vals.len() as f32) < 0.7 * filled as f32 {
return false;
}
// First column: mostly text titles (has alphabetic content). This rejects
// numeric-vs-numeric grids.
let text_first = cells
.iter()
.filter(|row| {
row.first()
.is_some_and(|c| c.chars().any(|ch| ch.is_alphabetic()))
})
.count();
if (text_first as f32) < 0.6 * cells.len() as f32 {
return false;
}
// Page numbers mostly ascend (allow front-matter→body resets and noise).
if page_vals.len() < 2 {
return false;
}
let non_decreasing = page_vals.windows(2).filter(|w| w[1] >= w[0]).count();
if (non_decreasing as f32) < 0.7 * (page_vals.len() - 1) as f32 {
return false;
}
// Stronger TOC signal. Real page numbers SPAN the document — entries skip
// (3, 6, 13, 24, …) so their range exceeds the entry count. A rank / ID /
// ordinal column is instead a *perfectly dense* consecutive run (1,2,3,… or
// 100,101,102,…). Accept anything with page gaps; for a dense run — which a
// one-page-per-entry TOC can also produce — fall back to a title signal:
// real contents entries are multi-word headings, rank labels are short.
let min = *page_vals.iter().min().unwrap();
let max = *page_vals.iter().max().unwrap();
let span = max.saturating_sub(min);
if span > page_vals.len() as u32 {
return true;
}
let dense_consecutive = (span as usize) + 1 == page_vals.len() && {
let mut sorted = page_vals.clone();
sorted.sort_unstable();
sorted.dedup();
sorted.len() == page_vals.len()
};
if !dense_consecutive {
// Narrow range but with a gap or repeat — still contents-like.
return true;
}
// Dense counter: only a TOC if the titles read like headings, not the
// short single-word labels typical of rank/leaderboard/ID tables.
let (total_words, titled_rows) = cells
.iter()
.filter_map(|row| row.first())
.filter(|c| c.chars().any(|ch| ch.is_alphabetic()))
.fold((0usize, 0usize), |(w, n), c| {
(
w + c
.split_whitespace()
.filter(|t| t.chars().any(|ch| ch.is_alphabetic()))
.count(),
n + 1,
)
});
titled_rows > 0 && (total_words as f32) / titled_rows as f32 >= 1.8
}
/// Dot-leader TOC: any "Chapter 1 ........ 42" style with explicit leader
@@ -1886,4 +2005,166 @@ mod tests {
assert!(!starts_with_section_number(""));
assert!(!starts_with_section_number("Hello world"));
}
#[test]
fn page_number_value_rejects_roman_lookalike_words() {
// Ordinary words made only of {i,v,x,l,c} are not page numbers.
assert!(page_number_value("civil").is_none());
assert!(page_number_value("mix").is_none());
assert!(page_number_value("ill").is_none());
assert!(page_number_value("lil").is_none());
// Canonical roman numerals still parse.
assert_eq!(page_number_value("vii"), Some(7));
assert_eq!(page_number_value("ix"), Some(9));
assert_eq!(page_number_value("xii"), Some(12));
assert_eq!(page_number_value("42"), Some(42));
}
#[test]
fn page_number_toc_matches_consecutive_pages_with_titles() {
// A short chapter-per-page contents: pages are a dense 1..n run, but
// the multi-word titles mark it as a real TOC (recovered by the title
// signal rather than rejected for lacking page gaps).
let cells: Vec<Vec<String>> = vec![
vec!["Introduction to the Study".into(), "1".into()],
vec!["Materials and Methods".into(), "2".into()],
vec!["Results and Discussion".into(), "3".into()],
vec!["Summary of Findings".into(), "4".into()],
vec!["References and Notes".into(), "5".into()],
];
assert!(is_page_number_toc(&cells));
}
#[test]
fn page_number_toc_rejects_dense_ordinal_column() {
// Headerless title | rank table: values are a consecutive 1..n
// sequence (monotonic, no header, text first column) but their range
// ~= the row count, so it is data, not a table of contents.
let cells: Vec<Vec<String>> = vec![
vec!["Alice".into(), "1".into()],
vec!["Bob".into(), "2".into()],
vec!["Carol".into(), "3".into()],
vec!["Dave".into(), "4".into()],
vec!["Erin".into(), "5".into()],
vec!["Frank".into(), "6".into()],
];
assert!(!is_page_number_toc(&cells));
}
#[test]
fn page_number_toc_rejects_blank_header_cell() {
// First row is a header whose last cell is blank ("Category | ");
// must not be flattened even though later rows look TOC-like.
let cells = vec![
vec!["Category".into(), "".into()],
vec!["Alpha".into(), "3".into()],
vec!["Beta".into(), "9".into()],
vec!["Gamma".into(), "14".into()],
vec!["Delta".into(), "20".into()],
];
assert!(!is_page_number_toc(&cells));
}
#[test]
fn page_number_toc_matches_title_based_contents() {
// Title-left, page-number-right, no dot leaders, no section numbers.
let cells = vec![
vec!["About the Publisher".into(), "vii".into()],
vec!["About This Project".into(), "ix".into()],
vec!["Acknowledgments".into(), "xi".into()],
vec!["Experiment #1: Hydrostatic Pressure".into(), "3".into()],
vec!["Experiment #2: Bernoulli's Theorem".into(), "13".into()],
vec![
"Experiment #3: Energy Loss in Pipe Fittings".into(),
"24".into(),
],
];
assert!(is_page_number_toc(&cells));
assert!(is_table_of_contents(&cells));
}
#[test]
fn page_number_toc_rejects_numeric_data_table() {
// Real 2-col data table: numeric first column, non-monotonic values.
let cells = vec![
vec!["101".into(), "45".into()],
vec!["102".into(), "12".into()],
vec!["103".into(), "88".into()],
vec!["104".into(), "7".into()],
vec!["105".into(), "63".into()],
];
assert!(!is_page_number_toc(&cells));
}
#[test]
fn page_number_toc_rejects_non_monotonic_pages() {
// Text labels but the "page" column jumps around — a small data table,
// not a contents listing. 5 rows so the row-count guard passes and the
// monotonicity check is what does the rejecting.
let cells: Vec<Vec<String>> = vec![
vec!["Apples".into(), "42".into()],
vec!["Oranges".into(), "7".into()],
vec!["Pears".into(), "91".into()],
vec!["Plums".into(), "3".into()],
vec!["Grapes".into(), "60".into()],
];
// Sanity: this input clears the row-count and header guards, so a
// failure here is genuinely the monotonicity check.
assert!(cells.len() >= 5 && page_number_value(cells[0][1].trim()).is_some());
assert!(!is_page_number_toc(&cells));
}
#[test]
fn page_number_toc_rejects_header_row_data_table() {
// Real 2-col data table with a header row ("Mineral | CEC") and
// ascending values that mimic page numbers — the header tells us it
// is data, not contents.
let cells = vec![
vec![
"Mineral or colloid type".into(),
"CEC of pure colloid".into(),
],
vec!["kaolinite".into(), "10".into()],
vec!["illite".into(), "30".into()],
vec!["montmorillonite".into(), "100".into()],
vec!["vermiculite".into(), "150".into()],
];
assert!(!is_page_number_toc(&cells));
}
#[test]
fn page_number_toc_rejects_wide_data_grid() {
// A 4-column regional data table must not be read as a TOC even with a
// text first column and integer last column.
let cells = vec![
vec![
"REGIONS".into(),
"2007".into(),
"2010".into(),
"2016".into(),
],
vec![
"National Capital Region".into(),
"9".into(),
"8".into(),
"5".into(),
],
vec!["Cordillera".into(), "1".into(), "2".into(), "1".into()],
vec!["Ilocos Region".into(), "1".into(), "5".into(), "4".into()],
vec!["Cagayan Valley".into(), "1".into(), "3".into(), "5".into()],
];
assert!(!is_page_number_toc(&cells));
}
#[test]
fn page_number_toc_needs_page_number_last_column() {
// Last column is prose, not page numbers.
let cells = vec![
vec!["Section A".into(), "see appendix".into()],
vec!["Section B".into(), "see notes".into()],
vec!["Section C".into(), "later".into()],
vec!["Section D".into(), "TBD".into()],
];
assert!(!is_page_number_toc(&cells));
}
}
+2111 -21
View File
File diff suppressed because it is too large Load Diff
+762 -8
View File
@@ -8,6 +8,9 @@ use crate::types::{PdfRect, TextItem};
use super::Table;
const DOMINANT_PAGE_BACKGROUND_MIN_REPETITIONS: usize = 8;
const COMPETING_TABLE_MIN_ROWS: usize = 8;
/// Disjoint-set (union-find) with component sizes for clustering indices.
struct UnionFind {
parent: Vec<usize>,
@@ -226,6 +229,77 @@ pub struct RectHintRegion {
/// Also returns hint regions: bounding boxes of cell-sized rects from clusters
/// that failed full grid validation. These can be used to scope heuristic
/// detection and prevent unrelated items from being merged into tables.
/// Bounding boxes of chart-bar clusters on the page. Text inside these
/// regions (axis labels, data values, legends) belongs to a figure and must
/// not be gridded into a table by any detection strategy.
pub fn detect_chart_regions(
items: &[TextItem],
rects: &[PdfRect],
page: u32,
) -> Vec<(f32, f32, f32, f32)> {
// Match detect_tables_from_rects: image placeholders are not text and
// would defeat the bar-content check.
let items_owned: Vec<TextItem> = items
.iter()
.filter(|i| crate::extractor::is_text_layout_item(i))
.cloned()
.collect();
let items = items_owned.as_slice();
let page_rects: Vec<(f32, f32, f32, f32)> = rects
.iter()
.filter(|r| r.page == page)
.map(|r| {
let (x, w) = if r.width < 0.0 {
(r.x + r.width, -r.width)
} else {
(r.x, r.width)
};
let (y, h) = if r.height < 0.0 {
(r.y + r.height, -r.height)
} else {
(r.y, r.height)
};
(x, y, w, h)
})
// Origin-anchored page backgrounds/clipping paths are never chart
// geometry, and letting one bridge into a bar cluster would inflate
// the region to the whole page.
.filter(|&(x, y, w, h)| w >= 5.0 && h >= 5.0 && !(x < 5.0 && y < 5.0))
.collect();
if page_rects.len() < 6 {
return Vec::new();
}
let mut regions = Vec::new();
for cluster in &cluster_rects(&page_rects, 3.0, 6) {
let group: Vec<(f32, f32, f32, f32)> = cluster.iter().map(|&i| page_rects[i]).collect();
if is_chart_bar_cluster(items, &group, page) {
let bbox = group.iter().fold(
(
f32::INFINITY,
f32::INFINITY,
f32::NEG_INFINITY,
f32::NEG_INFINITY,
),
|(x0, y0, x1, y1), &(x, y, w, h)| {
(x0.min(x), y0.min(y), x1.max(x + w), y1.max(y + h))
},
);
regions.push(bbox);
}
}
regions
}
fn detect_direct_rect_table(
items: &[TextItem],
rects: &[(f32, f32, f32, f32)],
page: u32,
) -> Option<Table> {
detect_table_from_rect_group(items, rects, page)
.or_else(|| detect_row_stripe_table(items, rects, page))
.or_else(|| detect_stacked_box_table(items, rects, page))
}
pub fn detect_tables_from_rects(
items: &[TextItem],
rects: &[PdfRect],
@@ -379,12 +453,77 @@ pub fn detect_tables_from_rects(
.collect();
debug!("page {}: {} clusters with >= 6 rects", page, clusters.len());
for cluster_indices in &clusters {
let mut merge_excluded_cluster_ids: Vec<usize> = Vec::new();
for (cluster_id, cluster_indices) in clusters.iter().enumerate() {
let group_rects: Vec<(f32, f32, f32, f32)> =
cluster_indices.iter().map(|&i| page_rects[i]).collect();
if let Some(table) = detect_table_from_rect_group(items, &group_rects, page) {
tables.push(table);
} else if let Some(table) = detect_row_stripe_table(items, &group_rects, page) {
// Chart bars are neither table cells nor a hint region — gridding
// a chart's axis labels scrambles the page. Skip the cluster
// entirely so it can't reach any detector, the merged fallback,
// or the hint fallback.
if is_chart_bar_cluster(items, &group_rects, page) {
// Repeated page fills can dominate the geometry and make a
// real shaded-cell table look like a chart. Remove those fills,
// re-cluster the remaining geometry, and evaluate valid table
// candidates as a competing hypothesis before the chart
// rejection wins.
let normalized = without_dominant_page_backgrounds(&group_rects);
let normalized_table = (normalized.len() < group_rects.len())
.then(|| {
cluster_rects(&normalized, 3.0, 6)
.iter()
.filter_map(|indices| {
let candidate: Vec<(f32, f32, f32, f32)> =
indices.iter().map(|&i| normalized[i]).collect();
if is_chart_bar_cluster(items, &candidate, page) {
None
} else {
detect_table_from_rect_group(items, &candidate, page)
.or_else(|| {
detect_row_stripe_table_from_cell_rects(
items, &candidate, page,
)
})
// Small chart panels can still form
// plausible grids from their labels.
// Require sustained row evidence; the
// motivating table has 17 rows.
.filter(|table| {
table.rows.len() >= COMPETING_TABLE_MIN_ROWS
})
}
})
.max_by_key(|table| table.rows.len() * table.columns.len())
})
.flatten();
if let Some(table) = normalized_table {
debug!(
"page {}: chart-like cluster normalized from {} to {} rects; accepted {}x{} table hypothesis",
page,
group_rects.len(),
normalized.len(),
table.rows.len(),
table.columns.len()
);
// The accepted hypothesis is based on normalized
// geometry. Keep the original chart-like cluster out of
// the merged fallback: reintroducing its repeated page
// fills can manufacture a wider candidate that replaces
// this valid narrow table below.
merge_excluded_cluster_ids.push(cluster_id);
tables.push(table);
continue;
} else {
debug!(
"page {}: skipping chart-bar cluster ({} rects)",
page,
group_rects.len()
);
merge_excluded_cluster_ids.push(cluster_id);
continue;
}
}
if let Some(table) = detect_direct_rect_table(items, &group_rects, page) {
tables.push(table);
} else if let Some((left, right)) = split_wide_cluster(&group_rects, 15.0, 6) {
// Cluster was too wide — retry each half independently
@@ -419,12 +558,19 @@ pub fn detect_tables_from_rects(
// text-based column detection.
let only_narrow = !tables.is_empty() && tables.iter().all(|t| t.columns.len() <= 3);
if tables.is_empty() || only_narrow {
let total_clustered: usize = clusters.iter().map(|c| c.len()).sum();
if clusters.len() >= 3 && total_clustered >= 50 {
// Chart clusters stay out of the merge as well.
let table_clusters: Vec<&Vec<usize>> = clusters
.iter()
.enumerate()
.filter(|(id, _)| !merge_excluded_cluster_ids.contains(id))
.map(|(_, c)| c)
.collect();
let total_clustered: usize = table_clusters.iter().map(|c| c.len()).sum();
if table_clusters.len() >= 3 && total_clustered >= 50 {
debug!(
"page {}: trying merged-cluster fallback ({} clusters, {} rects{})",
page,
clusters.len(),
table_clusters.len(),
total_clustered,
if only_narrow {
", replacing narrow tables"
@@ -432,7 +578,7 @@ pub fn detect_tables_from_rects(
""
}
);
let all_cluster_rects: Vec<(f32, f32, f32, f32)> = clusters
let all_cluster_rects: Vec<(f32, f32, f32, f32)> = table_clusters
.iter()
.flat_map(|idxs| idxs.iter().map(|&i| page_rects[i]))
.collect();
@@ -491,6 +637,14 @@ pub fn detect_tables_from_rects(
}
}
// NOTE: 3-5 box stacks never reach detect_stacked_box_table — the main
// loop requires >=6-rect clusters (and a >=6-rect page). This is a
// deliberate precision gate: routing smaller clusters through the
// detector was tried and regressed four pdf-evals documents (striped
// bullet lists, wrapped regulation text, stats-table columns) while
// improving nothing — with so few boxes the anti-prose guards have too
// little signal to discriminate. See stacked_box_three_rows_below_
// cluster_minimum for the pinned behavior.
if tables.is_empty() {
// When no tables detected but clusters exist, generate XY hint regions
// from cluster bounding boxes to scope heuristic table detection.
@@ -631,6 +785,240 @@ pub fn detect_tables_from_rects(
/// overlap or are close (gap < 50pt). This handles calendar-style layouts where a
/// month zone's decorative rects split into 2-3 adjacent clusters with small X gaps.
/// Runs iteratively until no more merges occur.
/// Detect a single-column table drawn as a vertical stack of boxes, each
/// holding one short line of text (framework/step lists on slide-style
/// pages). The normal grid path rejects these — one column means only two
/// x-edges — so the rows would otherwise flow into surrounding prose as a
/// run-on paragraph.
fn detect_stacked_box_table(
items: &[TextItem],
group_rects: &[(f32, f32, f32, f32)],
page: u32,
) -> Option<Table> {
// Candidate row boxes: single-text-line height, substantial width.
let cands: Vec<(f32, f32, f32, f32)> = group_rects
.iter()
.copied()
.filter(|&(_, _, w, h)| w >= 100.0 && (8.0..=80.0).contains(&h))
.collect();
// The row boxes form the largest family of same-width, x-aligned rects
// (backgrounds and decor have their own geometry and stay out).
let mut boxes: Vec<(f32, f32, f32, f32)> = Vec::new();
for &anchor in &cands {
let family: Vec<(f32, f32, f32, f32)> = cands
.iter()
.copied()
.filter(|&(x, _, w, h)| {
(x - anchor.0).abs() <= 12.0
&& (w - anchor.2).abs() <= anchor.2 * 0.15
&& (h - anchor.3).abs() <= anchor.3 * 0.3
})
.collect();
if family.len() > boxes.len() {
boxes = family;
}
}
if boxes.len() < 3 {
return None;
}
// Boxes flanked at the same y-level — by other rects or by text outside
// the family's x-range — are one column of a wider structure. Leave
// those to the grid/cell-rect paths instead of collapsing to one column.
let flanked = boxes
.iter()
.filter(|&&(bx, by, bw, bh)| {
let rect_sibling = group_rects.iter().any(|&(ox, oy, ow, oh)| {
let y_overlap = (by + bh).min(oy + oh) - by.max(oy);
oh >= 8.0
&& y_overlap > bh * 0.5
&& (ox + ow <= bx + 2.0 || ox >= bx + bw - 2.0)
&& ow >= 30.0
});
let text_sibling = items.iter().any(|it| {
let cx = it.x + it.width / 2.0;
it.page == page
&& it.y >= by - 2.0
&& it.y <= by + bh + 2.0
&& (cx < bx - 5.0 || cx > bx + bw + 5.0)
&& it.width >= 10.0
});
rect_sibling || text_sibling
})
.count();
if flanked * 3 >= boxes.len() {
debug!(
" stacked-box rejected: {}/{} boxes flanked by rects or text",
flanked,
boxes.len()
);
return None;
}
boxes.sort_by(|a, b| b.1.total_cmp(&a.1)); // top to bottom (descending y)
// Merge duplicates (border + fill pairs draw the same box twice), then
// require a clean vertical stack: no overlaps beyond a small tolerance.
boxes.dedup_by(|a, b| (a.1 - b.1).abs() <= 3.0 && (a.3 - b.3).abs() <= 6.0);
if boxes.len() < 3 {
return None;
}
for w in boxes.windows(2) {
let (upper, lower) = (w[0], w[1]);
let upper_bottom = upper.1;
let lower_top = lower.1 + lower.3;
if lower_top > upper_bottom + 4.0 {
return None; // vertical overlap — not a stack
}
if upper_bottom - lower_top > upper.3.max(lower.3) {
return None; // gap larger than a row — unrelated boxes
}
}
// Assign items to boxes; every box needs text and cells must stay short
// (prose paragraphs inside stacked frames are page decor, not a table).
let mut cells: Vec<Vec<String>> = Vec::with_capacity(boxes.len());
let mut item_indices: Vec<usize> = Vec::new();
let mut multi_run_boxes = 0usize;
for &(bx, by, bw, bh) in &boxes {
let mut in_box: Vec<(usize, &TextItem)> = items
.iter()
.enumerate()
.filter(|(_, it)| {
it.page == page
&& it.y >= by - 2.0
&& it.y <= by + bh + 2.0
&& it.x + it.width / 2.0 >= bx
&& it.x + it.width / 2.0 <= bx + bw
})
.collect();
if in_box.is_empty() {
return None;
}
in_box.sort_by(|a, b| {
b.1.y
.partial_cmp(&a.1.y)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| {
a.1.x
.partial_cmp(&b.1.x)
.unwrap_or(std::cmp::Ordering::Equal)
})
});
// Count horizontally separated text runs inside the box. A single
// list row flows as one run; two-plus runs across most boxes means
// multi-column content (striped prose or a real grid) that must not
// collapse into a one-column table. Same-baseline only: boxed
// display/diagram rows legitimately scatter segments at mixed
// baselines, and those must stay one row.
let mut runs = 1usize;
for pair in in_box.windows(2) {
let (prev, item) = (pair[0].1, pair[1].1);
if (prev.y - item.y).abs() <= 2.0 && item.x - (prev.x + prev.width) > 15.0 {
runs += 1;
}
}
if runs >= 2 {
multi_run_boxes += 1;
}
let text = in_box
.iter()
.map(|(_, it)| it.text.trim())
.filter(|t| !t.is_empty())
.collect::<Vec<_>>()
.join(" ");
if text.is_empty() || text.chars().count() > 120 {
return None;
}
item_indices.extend(in_box.iter().map(|(i, _)| *i));
cells.push(vec![text]);
}
if multi_run_boxes * 2 >= boxes.len() {
debug!(
" stacked-box rejected: {}/{} boxes hold multiple text runs",
multi_run_boxes,
boxes.len()
);
return None;
}
// Reject prose behind per-line stripe rects: sentence fragments flowing
// across rows read as long, function-word-dense cells, while genuine
// list-table rows are short labels/titles.
const PROSE_WORDS: &[&str] = &[
"a", "an", "the", "of", "to", "is", "was", "are", "were", "be", "been", "in", "on", "at",
"with", "for", "by", "as", "and", "or", "but", "this", "that", "these", "those", "from",
"into", "has", "have", "had", "not", "it", "its", "their", "such", "shall", "which",
];
let total_chars: usize = cells.iter().map(|r| r[0].chars().count()).sum();
let mean_chars = total_chars / cells.len().max(1);
let prose_cells = cells
.iter()
.filter(|r| {
r[0].to_ascii_lowercase()
.split(|c: char| !c.is_ascii_alphabetic() && c != '\'')
.any(|w| PROSE_WORDS.contains(&w))
})
.count();
if mean_chars > 60 && prose_cells * 5 >= cells.len() * 2 {
debug!(
" stacked-box rejected: prose rows (mean {} chars, prose words {}/{})",
mean_chars,
prose_cells,
cells.len()
);
return None;
}
// Sentences wrapping across stripe rects: a row ending with a comma, or
// a row without terminal punctuation followed by a row starting
// lowercase, is mid-sentence flow — not list rows. Genuine label/title
// rows produce none of these, so even a small share is disqualifying.
let continuations = cells
.windows(2)
.filter(|pair| {
let prev = pair[0][0].trim_end();
let next = pair[1][0].trim_start();
let prev_open = !prev.ends_with(['.', ':', ';', '!', '?', ')', '"', '%']);
let next_lower = next.chars().next().is_some_and(|c| c.is_lowercase());
prev.ends_with(',') || (prev_open && next_lower)
})
.count();
if cells.len() >= 2 && (continuations >= 2 || continuations * 4 >= cells.len() - 1) {
debug!(
" stacked-box rejected: {}/{} row pairs continue a sentence",
continuations,
cells.len() - 1
);
return None;
}
// Numbered/lettered list items behind decorative stripes stay lists:
// "1) content..." / "(ii) content..." / "a. content...".
let list_marker = |t: &str| {
let t = t.trim_start().strip_prefix('(').unwrap_or(t.trim_start());
let marker_len = t.chars().take_while(|c| c.is_ascii_alphanumeric()).count();
(1..=3).contains(&marker_len)
&& t.chars()
.nth(marker_len)
.is_some_and(|c| c == ')' || c == '.')
};
let list_rows = cells.iter().filter(|r| list_marker(&r[0])).count();
if list_rows * 2 >= cells.len() {
debug!(
" stacked-box rejected: {}/{} rows are numbered list items",
list_rows,
cells.len()
);
return None;
}
debug!(
"page {}: stacked-box table: {} single-column rows",
page,
cells.len()
);
let columns = vec![boxes[0].0 + boxes[0].2 / 2.0];
let rows: Vec<f32> = boxes.iter().map(|b| b.1 + b.3 / 2.0).collect();
Some(Table::new(columns, rows, cells, item_indices))
}
fn merge_overlapping_hints(mut hints: Vec<RectHintRegion>) -> Vec<RectHintRegion> {
if hints.len() <= 1 {
return hints;
@@ -1578,11 +1966,153 @@ fn row_stripe_is_sparse_prose_outline(cells: &[Vec<String>]) -> bool {
long_dense_cells * 2 >= dense_count
}
/// Remove repeated page-scale fills from a chart-like cluster so the actual
/// cell/bar geometry can be evaluated independently. A small number of
/// coincident origin frames may be meaningful table structure, so repetition
/// only becomes normalization evidence when it dominates the cluster.
fn without_dominant_page_backgrounds(rects: &[(f32, f32, f32, f32)]) -> Vec<(f32, f32, f32, f32)> {
let x_max = rects
.iter()
.map(|&(x, _, width, _)| x + width)
.fold(0.0_f32, f32::max);
let y_max = rects
.iter()
.map(|&(_, y, _, height)| y + height)
.fold(0.0_f32, f32::max);
let is_page_scale = |&(x, y, width, height): &(f32, f32, f32, f32)| {
x < 5.0 && y < 5.0 && width >= x_max * 0.9 && height >= y_max * 0.9
};
if rects.iter().filter(|rect| is_page_scale(rect)).count()
< DOMINANT_PAGE_BACKGROUND_MIN_REPETITIONS
{
return rects.to_vec();
}
rects
.iter()
.filter(|rect| !is_page_scale(rect))
.copied()
.collect()
}
/// Detect a table from cell-background rects that failed grid detection.
///
/// Uses rect Y-edges for row boundaries and text X-position clustering for
/// columns. Handles tables with cell backgrounds that don't form a clean
/// X-edge grid (variable column widths, decorative fills).
/// Chart-bar signature: ≥3 rects sharing an aligned bottom edge (the axis),
/// with similar widths (bars) but strongly varying heights (data-driven),
/// holding at most a single numeric data label each. Bar charts drawn as
/// filled rects otherwise read as cell rects and grid their axis labels
/// into a phantom table. The mirrored check catches horizontal bar charts.
fn is_chart_bar_cluster(
items: &[TextItem],
group_rects: &[(f32, f32, f32, f32)],
page: u32,
) -> bool {
let numeric_or_empty = |(rx, ry, rw, rh): (f32, f32, f32, f32)| {
let inside: Vec<&TextItem> = items
.iter()
.filter(|it| {
let cx = it.x + it.width / 2.0;
it.page == page && cx >= rx && cx <= rx + rw && it.y >= ry && it.y <= ry + rh
})
.collect();
// Any number of numeric data labels is chart-like; a single run of
// word text inside means a table cell.
inside.iter().all(|it| {
let t = it.text.trim();
let data = t
.chars()
.filter(|c| c.is_ascii_digit() || ",.%-".contains(*c))
.count();
t.is_empty() || data * 2 >= t.chars().count()
})
};
// Bars: the dominant equal-width family, arranged in >=2 spaced columns
// (inter-column gap >= half a bar width — table cell rects touch), with
// data-driven height variation (checkbox/cell grids are uniform).
// Mirrored predicate catches horizontal bar charts.
let bar_family = |pos: fn(&(f32, f32, f32, f32)) -> f32,
breadth: fn(&(f32, f32, f32, f32)) -> f32,
length: fn(&(f32, f32, f32, f32)) -> f32,
along: fn(&(f32, f32, f32, f32)) -> f32| {
group_rects.iter().any(|anchor| {
let bw = breadth(anchor);
if bw <= 0.0 {
return false;
}
let family: Vec<&(f32, f32, f32, f32)> = group_rects
.iter()
.filter(|r| {
(breadth(r) - bw).abs() <= (bw * 0.1).max(2.0)
&& length(r) > 0.0
&& length(r) < bw * 20.0
})
.collect();
if family.len() < 4 {
return false;
}
// Distinct positions along the axis (bar columns).
let mut positions: Vec<f32> = Vec::new();
for r in &family {
let p = pos(r);
if !positions.iter().any(|&q| (q - p).abs() <= 2.0) {
positions.push(p);
}
}
if positions.len() < 2 {
return false;
}
positions.sort_by(|a, b| a.total_cmp(b));
let min_gap = positions
.windows(2)
.map(|w| w[1] - w[0] - bw)
.fold(f32::INFINITY, f32::min);
if min_gap < bw * 0.5 {
return false;
}
// Data-driven variation along the bar direction.
let len_min = family
.iter()
.map(|r| length(r))
.fold(f32::INFINITY, f32::min);
let len_max = family
.iter()
.map(|r| length(r))
.fold(f32::NEG_INFINITY, f32::max);
if len_max < len_min * 1.3 {
return false;
}
// Grid rows disguise as bars: a table's cell rects have same-y,
// same-height partners in other columns (uniform row heights).
// Chart segments start where the previous datum ended, so their
// extents rarely pair up across positions.
let matched = family
.iter()
.filter(|r| {
family.iter().any(|s| {
(pos(s) - pos(r)).abs() > 2.0
&& (along(s) - along(r)).abs() <= 3.0
&& (length(s) - length(r)).abs() <= 3.0
})
})
.count();
if matched * 5 >= family.len() * 3 {
return false;
}
family.iter().filter(|r| numeric_or_empty(***r)).count() * 3 >= family.len() * 2
})
};
// vertical bars: position/breadth = x/width, length = height, along = y
bar_family(|r| r.0, |r| r.2, |r| r.3, |r| r.1)
// horizontal bars: position/breadth = y/height, length = width, along = x
|| bar_family(|r| r.1, |r| r.3, |r| r.2, |r| r.0)
}
fn detect_row_stripe_table_from_cell_rects(
items: &[TextItem],
group_rects: &[(f32, f32, f32, f32)],
@@ -2458,6 +2988,187 @@ mod tests {
}
}
// --- is_chart_bar_cluster / detect_chart_regions ---
/// Stacked bar chart: frame + 3 columns of equal-width segments with
/// data-driven heights, holding numeric labels.
fn chart_rects() -> Vec<PdfRect> {
let mut rects = vec![PdfRect {
x: 126.0,
y: 548.0,
width: 396.0,
height: 216.0,
page: 1,
}];
let bars = [
(208.0, 618.0, 59.0),
(208.0, 661.0, 39.0),
(208.0, 696.0, 37.0),
(313.0, 618.0, 67.0),
(313.0, 670.0, 49.0),
(313.0, 691.0, 42.0),
(419.0, 618.0, 73.0),
(419.0, 684.0, 37.0),
(419.0, 708.0, 25.0),
];
for (x, y, h) in bars {
rects.push(PdfRect {
x,
y,
width: 46.0,
height: h,
page: 1,
});
}
rects
}
#[test]
fn chart_bars_produce_region_not_table() {
let items: Vec<TextItem> = [
("38", 228.0, 638.0),
("30", 228.0, 676.0),
("46", 333.0, 643.0),
("17", 333.0, 679.0),
("57", 438.0, 650.0),
("20", 438.0, 694.0),
]
.iter()
.map(|&(t, x, y)| make_item(t, x, y, 9.0))
.collect();
let rects = chart_rects();
let regions = detect_chart_regions(&items, &rects, 1);
assert_eq!(regions.len(), 1, "expected one chart region");
let (tables, hints) = detect_tables_from_rects(&items, &rects, 1);
assert!(tables.is_empty(), "chart bars must not become a table");
assert!(hints.is_empty(), "chart bars must not become a hint region");
}
#[test]
fn dominant_page_backgrounds_are_normalized_only_after_repetition() {
let page_fill = (0.0, 0.0, 600.0, 800.0);
let cell = (100.0, 500.0, 120.0, 20.0);
let mut dominant = vec![page_fill; DOMINANT_PAGE_BACKGROUND_MIN_REPETITIONS];
dominant.push(cell);
assert_eq!(without_dominant_page_backgrounds(&dominant), vec![cell]);
let mut incidental = vec![page_fill; DOMINANT_PAGE_BACKGROUND_MIN_REPETITIONS - 1];
incidental.push(cell);
assert_eq!(without_dominant_page_backgrounds(&incidental), incidental);
}
#[test]
fn uniform_cell_grid_is_not_a_chart() {
// Touching, uniform-height cell rects (a real table) must not match:
// no inter-column gap and no bar-length variation.
let mut rects = Vec::new();
for row in 0..4 {
for col in 0..3 {
rects.push(PdfRect {
x: 100.0 + col as f32 * 80.0,
y: 600.0 - row as f32 * 20.0,
width: 80.0,
height: 20.0,
page: 1,
});
}
}
let items: Vec<TextItem> = (0..4)
.flat_map(|r| {
(0..3).map(move |c| (100.0 + c as f32 * 80.0 + 10.0, 605.0 - r as f32 * 20.0))
})
.map(|(x, y)| make_item("42", x, y, 9.0))
.collect();
assert!(detect_chart_regions(&items, &rects, 1).is_empty());
}
// --- detect_stacked_box_table ---
/// N stacked boxes at x=100, w=300, h=22, top-to-bottom from y=600.
fn stacked_boxes(n: usize) -> Vec<(f32, f32, f32, f32)> {
(0..n)
.map(|i| (100.0, 600.0 - i as f32 * 22.0, 300.0, 22.0))
.collect()
}
#[test]
fn stacked_box_list_becomes_single_column_table() {
let rects = stacked_boxes(5);
let items: Vec<TextItem> = (0..5)
.map(|i| make_item("#1: Recycling Basics", 120.0, 605.0 - i as f32 * 22.0, 10.0))
.collect();
let table = detect_stacked_box_table(&items, &rects, 1).expect("stacked-box table");
assert_eq!(table.cells.len(), 5);
assert_eq!(table.cells[0].len(), 1);
}
#[test]
fn stacked_box_rejects_wrapped_sentences() {
// Line stripes behind flowing prose: rows continue mid-sentence.
let rects = stacked_boxes(4);
let texts = [
"the provisions of this section apply to",
"companies subject to tax under those",
"sections, except that the copy of the",
"annual statement must be retained.",
];
let items: Vec<TextItem> = texts
.iter()
.enumerate()
.map(|(i, t)| make_item(t, 120.0, 605.0 - i as f32 * 22.0, 10.0))
.collect();
assert!(detect_stacked_box_table(&items, &rects, 1).is_none());
}
#[test]
fn stacked_box_rejects_flanking_text() {
// A ruled label column with plain-text data columns beside it is one
// column of a wider table, not a single-column list.
let rects = stacked_boxes(4);
let mut items = Vec::new();
for i in 0..4 {
let y = 605.0 - i as f32 * 22.0;
items.push(make_item("Section 1.382", 120.0, y, 10.0));
items.push(make_item("removed text", 450.0, y, 10.0)); // beside the box
}
assert!(detect_stacked_box_table(&items, &rects, 1).is_none());
}
#[test]
fn stacked_box_rejects_two_column_content() {
// Boxes holding two separated runs are striped multi-column content.
let rects = stacked_boxes(4);
let mut items = Vec::new();
for i in 0..4 {
let y = 605.0 - i as f32 * 22.0;
let mut left = make_item("left words", 110.0, y, 10.0);
left.width = 60.0;
let mut right = make_item("right words", 250.0, y, 10.0);
right.width = 60.0;
items.push(left);
items.push(right);
}
assert!(detect_stacked_box_table(&items, &rects, 1).is_none());
}
#[test]
fn stacked_box_rejects_mixed_height_stripes() {
// Mixed 13/27pt stripes (redline markup) — height uniformity splits
// the family and the gap check rejects the remainder.
let mut rects = Vec::new();
let mut y = 600.0;
for i in 0..8 {
let h = if i % 3 == 0 { 27.0 } else { 13.5 };
y -= h;
rects.push((100.0, y, 300.0, h));
}
let items: Vec<TextItem> = (0..8)
.map(|i| make_item("PART 602 OMB CONTROL", 120.0, 590.0 - i as f32 * 18.0, 10.0))
.collect();
assert!(detect_stacked_box_table(&items, &rects, 1).is_none());
}
// --- has_dominant_prose_cell ---
fn cells_of(rows: &[&[&str]]) -> Vec<Vec<String>> {
@@ -3480,6 +4191,49 @@ mod tests {
assert!((merged[0].x_right - 340.0).abs() < 0.01);
}
#[test]
fn stacked_box_three_rows_below_cluster_minimum() {
// Pins a deliberate precision gate: a 3-box stack stays below the
// main loop's 6-rect cluster minimum and is NOT detected end-to-end.
// Routing smaller clusters through detect_stacked_box_table was
// tried and regressed four pdf-evals documents (striped bullet
// lists, wrapped regulation text, stats-table columns) with no
// corpus gains — too few boxes for the anti-prose guards to work.
// If this ever becomes worth revisiting, the guards need stronger
// signals first; flipping this assertion is the entry point.
let mut rects: Vec<PdfRect> = (0..3)
.map(|i| PdfRect {
x: 100.0,
y: 600.0 - i as f32 * 22.0,
width: 300.0,
height: 22.0,
page: 1,
})
.collect();
// Unrelated scattered rects push the page past the 6-rect page gate
// so the run reaches clustering, while the 3-box stack itself stays
// below the 6-rect cluster minimum.
for i in 0..4 {
rects.push(PdfRect {
x: 100.0 + i as f32 * 120.0,
y: 100.0,
width: 40.0,
height: 15.0,
page: 1,
});
}
let items: Vec<TextItem> = ["Step One: Plan", "Step Two: Build", "Step Three: Ship"]
.iter()
.enumerate()
.map(|(i, t)| make_item(t, 120.0, 605.0 - i as f32 * 22.0, 10.0))
.collect();
let (tables, _) = detect_tables_from_rects(&items, &rects, 1);
assert!(
tables.is_empty(),
"3-box stacks are intentionally below the detection floor"
);
}
#[test]
fn failed_cluster_generates_hint_with_items() {
// A cluster of rects forming an outer border (2 x-edges after snapping)
+62 -2
View File
@@ -123,6 +123,7 @@ fn format_toc_as_list(cells: &[Vec<String>], footnotes: &[String]) -> String {
/// True when the cell looks like a page number. Accepts:
/// - plain digit tokens: "42", "86 86"
/// - canonical roman numerals (front-matter pages): "vii", "ix", "xii"
/// - dashed section-page IDs: "5-21", "A-1", "B--3", "TC-2" (common in
/// technical manuals)
fn is_page_number_cell(cell: &str) -> bool {
@@ -138,6 +139,9 @@ fn is_page_number_cell(cell: &str) -> bool {
if all_digits {
return t.len() <= 4;
}
if super::canonical_roman_value(t).is_some() {
return true;
}
// Section-page form: uppercase letters, digits, dashes; at least
// one digit present.
t.chars()
@@ -184,6 +188,21 @@ fn starts_with_numbered_label(cell: &str) -> bool {
.is_some_and(|c| matches!(c, '.' | ')' | '-' | ':'))
}
fn starts_with_hierarchical_numbered_label(cell: &str) -> bool {
let token = cell
.split_whitespace()
.next()
.unwrap_or("")
.trim_end_matches(['.', ')', ':', '-']);
let levels: Vec<&str> = token.split('.').collect();
(2..=4).contains(&levels.len())
&& levels.iter().all(|level| {
!level.is_empty()
&& level.len() <= 3
&& level.chars().all(|character| character.is_ascii_digit())
})
}
fn alpha_word_count(cell: &str) -> usize {
cell.split_whitespace()
.filter(|word| word.chars().any(|c| c.is_alphabetic()))
@@ -337,11 +356,12 @@ fn clean_table_cells(cells: &[Vec<String>]) -> (Vec<Vec<String>>, Vec<String>) {
// mid-sentence/lowercase ("continued text here", "with 3.5%...") or
// carry lowercase fragments in the later cells, so keep those mergeable.
let looks_like_hierarchical_subrow = first_cell.is_empty()
&& row.len() >= 3
&& first_non_empty_col == Some(1)
&& looks_like_compact_entry_label(first_non_empty_cell)
&& ((non_first_cells.len() >= 2 && title_like_later_cells > 0)
&& ((row.len() == 2 && starts_with_hierarchical_numbered_label(first_non_empty_cell))
|| (row.len() >= 3 && non_first_cells.len() >= 2 && title_like_later_cells > 0)
|| (non_first_cells.len() == 1
&& row.len() >= 3
&& prev_first_cell_empty
&& alpha_word_count(first_non_empty_cell) >= 2));
let looks_like_new_first_column_entry = !first_cell.is_empty()
@@ -684,6 +704,46 @@ mod tests {
assert_eq!(cleaned[4][1], "Model training");
}
#[test]
fn test_clean_table_cells_two_column_numbered_subrows_not_merged() {
let cells = vec![
vec!["Area".into(), "Competence".into()],
vec![
"1. Embodying sustainability values".into(),
"1.1 Valuing sustainability".into(),
],
vec!["".into(), "1.2 Supporting fairness".into()],
vec!["".into(), "1.3 Promoting nature".into()],
vec![
"2. Embracing complexity".into(),
"2.1 Systems thinking".into(),
],
vec!["".into(), "2.2 Critical thinking".into()],
];
let (cleaned, _) = clean_table_cells(&cells);
assert_eq!(cleaned.len(), 6);
assert_eq!(cleaned[2], vec!["", "1.2 Supporting fairness"]);
assert_eq!(cleaned[3], vec!["", "1.3 Promoting nature"]);
assert_eq!(cleaned[5], vec!["", "2.2 Critical thinking"]);
}
#[test]
fn test_clean_table_cells_two_column_numbered_continuation_merges() {
let cells = vec![
vec!["Area".into(), "Requirement".into()],
vec!["Safety".into(), "The program includes".into()],
vec!["".into(), "1. First requirement for every operator".into()],
];
let (cleaned, _) = clean_table_cells(&cells);
assert_eq!(cleaned.len(), 2);
assert_eq!(
cleaned[1][1],
"The program includes 1. First requirement for every operator"
);
}
#[test]
fn test_clean_table_cells_partial_hierarchical_subrow_not_merged() {
let cells = vec![
+57 -2
View File
@@ -4,7 +4,7 @@
mod detect_heuristic;
mod detect_lines;
mod detect_rects;
pub(crate) mod detect_rects;
mod detect_struct;
mod financial;
mod format;
@@ -14,8 +14,9 @@ pub mod structured;
pub use detect_heuristic::detect_tables;
pub(crate) use detect_heuristic::is_table_of_contents;
pub use detect_lines::detect_tables_from_lines;
pub(crate) use detect_lines::detect_vector_grid_tables_from_lines;
pub(crate) use detect_rects::cluster_rects;
pub use detect_rects::{detect_tables_from_rects, RectHintRegion};
pub use detect_rects::{detect_chart_regions, detect_tables_from_rects, RectHintRegion};
pub use detect_struct::detect_tables_from_struct_tree;
pub use format::table_to_markdown;
pub use structured::{cells_to_markdown, StructuredCell};
@@ -177,6 +178,60 @@ pub(crate) fn try_build_rect_guided_table(
))
}
/// Canonical lowercase roman numeral for `n` (the i/v/x/l/c range).
pub(super) fn to_roman_lower(mut n: u32) -> String {
const TABLE: [(u32, &str); 9] = [
(100, "c"),
(90, "xc"),
(50, "l"),
(40, "xl"),
(10, "x"),
(9, "ix"),
(5, "v"),
(4, "iv"),
(1, "i"),
];
let mut out = String::new();
for (val, sym) in TABLE {
while n >= val {
out.push_str(sym);
n -= val;
}
}
out
}
/// Parse a *canonical* roman numeral (i/v/x/l/c range, ≤8 chars) to its value.
/// Returns `None` for non-canonical strings, so ordinary words made of those
/// letters — "civil", "mix", "ill" — are not mistaken for numbers. Shared by
/// the TOC detector and the TOC formatter so the two stay in sync.
pub(super) fn canonical_roman_value(token: &str) -> Option<u32> {
let lower = token.trim().to_ascii_lowercase();
if lower.is_empty() || lower.len() > 8 || !lower.chars().all(|c| "ivxlc".contains(c)) {
return None;
}
let mut total = 0i32;
let mut prev = 0i32;
for c in lower.chars().rev() {
let v = match c {
'i' => 1,
'v' => 5,
'x' => 10,
'l' => 50,
'c' => 100,
_ => return None,
};
if v < prev {
total -= v;
} else {
total += v;
prev = v;
}
}
let value = u32::try_from(total).ok().filter(|&n| n > 0)?;
(to_roman_lower(value) == lower).then_some(value)
}
/// Split a TextItem whose text contains multiple whitespace-separated tokens
/// (like "10 11 12 ... 31") into individual TextItems, each assigned to the
/// nearest column boundary.
+3
View File
@@ -337,6 +337,7 @@ fn test_group_into_lines_sorting_by_x() {
#[test]
fn test_markdown_options_default() {
let opts = MarkdownOptions::default();
assert_eq!(opts.profile, pdf_inspector::MarkdownProfile::Fidelity);
assert!(opts.detect_headers);
assert!(opts.detect_lists);
assert!(opts.detect_code);
@@ -346,6 +347,7 @@ fn test_markdown_options_default() {
#[test]
fn test_markdown_options_custom() {
let opts = MarkdownOptions {
profile: pdf_inspector::MarkdownProfile::Compact,
detect_headers: false,
detect_lists: true,
detect_code: false,
@@ -361,6 +363,7 @@ fn test_markdown_options_custom() {
..Default::default()
};
assert!(!opts.detect_headers);
assert_eq!(opts.profile, pdf_inspector::MarkdownProfile::Compact);
assert!(opts.detect_lists);
assert!(!opts.detect_code);
assert_eq!(opts.base_font_size, Some(14.0));
+3 -1
View File
@@ -22,7 +22,9 @@ Name and address of employee
**Publication 1244 (Rev. 7-96)** Cat. No. 44472W
**Instructions** You must keep sufficient proof to show the amount of your tip income for the year. A daily record of your tip income is considered sufficient proof. Keep a daily record for each workday showing the amount of cash and credit card tips received directly from customers or other employees. Also keep a record of the amount of tips, if any, you paid to other employees through tip sharing, tip pooling or other arrangements, and the names of employees to whom you paid tips. Show the date that each entry is made. This date should be on or near the date you received the tip income. You may use **Form 4070A**, Employees Daily Record of Tips, or any other daily record to record your tips. **Reporting Tips to Your Employer.—**If you receive tips that total $20 or more for any month while working for one employer, you must report the tips to your employer. Tips include cash left by customers, tips customers add to credit card charges, and tips you receive from other employees. You must report your tips for any one month by the 10th day of the next month. If the 10th day falls on a Saturday, Sunday, or legal holiday, you may give the report to your employer on the next business day that is not a Saturday, Sunday, or legal holiday. You must report tips that total $20 or more every month regardless of your total wages and tips for the year. You may use **Form 4070**, Employees Report of Tips to Employer, to report your tips to your employer. See the instructions on the back of Form 4070. You must include all tips, including tips not reported to your employer, as wages on your income tax return. You may use the last page of this publication to total your tips for the year. Your employer must withhold income, social security, and Medicare (or railroad retirement) taxes on tips you report. Your employer usually deducts the withholding due on tips from your regular wages.
### Instructions
You must keep sufficient proof to show the amount of your tip income for the year. A daily record of your tip income is considered sufficient proof. Keep a daily record for each workday showing the amount of cash and credit card tips received directly from customers or other employees. Also keep a record of the amount of tips, if any, you paid to other employees through tip sharing, tip pooling or other arrangements, and the names of employees to whom you paid tips. Show the date that each entry is made. This date should be on or near the date you received the tip income. You may use **Form 4070A**, Employees Daily Record of Tips, or any other daily record to record your tips. **Reporting Tips to Your Employer.—**If you receive tips that total $20 or more for any month while working for one employer, you must report the tips to your employer. Tips include cash left by customers, tips customers add to credit card charges, and tips you receive from other employees. You must report your tips for any one month by the 10th day of the next month. If the 10th day falls on a Saturday, Sunday, or legal holiday, you may give the report to your employer on the next business day that is not a Saturday, Sunday, or legal holiday. You must report tips that total $20 or more every month regardless of your total wages and tips for the year. You may use **Form 4070**, Employees Report of Tips to Employer, to report your tips to your employer. See the instructions on the back of Form 4070. You must include all tips, including tips not reported to your employer, as wages on your income tax return. You may use the last page of this publication to total your tips for the year. Your employer must withhold income, social security, and Medicare (or railroad retirement) taxes on tips you report. Your employer usually deducts the withholding due on tips from your regular wages.
*(continued on inside of back cover)*
+7 -5
View File
@@ -6,20 +6,22 @@
#### Thermodynamic Properties
**of**
®
**of** ®
# Freon 12
**(R-12)** **Technical Information** **Technical Information**
##### (R-12)
##### Technical Information Technical Information
**®** **Thermodynamic Properties of Freon 12 Refrigerant** **(R-12)** **SI Units**
Tables of the thermodynamic **Units** properties of R-12 have been developed and are presented here. P = Pressure in kPa. Absolute This information is based on values calculated using the NIST REFPROP T = Temperature in Celcius Database (McLinden, M.O., Klein,
S.A., Lemmon, E.W., and Peskin, Vf = Fluid (liquid) specific volume
A.P., NIST Standard Reference in cubic meters per kilogram Database 23, NIST thermodynamic and transport properties of Vg = Vapour (gas) specific volume refrigerants and refrigerant in cubic meters per kilogram mixtures REFPROP version 6.01, Standard Reference Data Program, df and dg = Fluid and Vapour National Institute of Standards and (respectively) densities in Technology, 1998). kilograms per cubic meter
A.P., NIST Standard Reference in cubic meters per kilogram Database 23, NIST thermodynamic and transport properties of Vg = Vapour (gas) specific volume refrigerants and refrigerant in cubic meters per kilogram mixtures REFPROP version 6.01, Standard Reference Data Program, df and dg = Fluid and Vapour National Institute of Standards and (respectively) densities in Technology, 1998).
kilograms per cubic meter
##### H = Enthalpy (kJ/kg)
##### S = Entropy (kJ/kg.K)