Commit Graph
80 Commits
Author SHA1 Message Date
Abimael Martell 12e9a655e3 fix(extractor): supply built-in metrics for non-embedded base-14 fonts (#241)
* fix(extractor): supply built-in metrics for non-embedded base-14 fonts

PDFs may legally omit /Widths for non-embedded standard fonts (Times,
Helvetica, Courier, Symbol, ZapfDingbats) — the spec requires the reader
to supply the metrics. We returned None, so every glyph advanced 0 and
each text item got width 0, silently breaking every gap-based heuristic
downstream: space synthesis, sub/superscript detection, table column
detection, heading merging.

- src/extractor/base14.rs: Adobe Core-14 AFM width tables keyed by
  Unicode char, plus the standard Symbol/ZapfDingbats encoding vectors
  (their glyphs sit at byte positions unrelated to Latin text, so widths
  must resolve through the built-in encoding, not cp1252)
- Width resolution order: Differences -> built-in encoding -> the same
  cp1252-style fallback the text decoder uses, so a code's advance always
  matches the character we emit for it
- Type3 visual sizing: PK bitmap fonts (dvips) use FontMatrix
  [1 0 0 -1 0 0] with nominal sizes like 0.12pt; scale by FontBBox height
  x |matrix_y|. Applied in the page-stream and Form XObject paths.
  Indirect numeric array elements are resolved before use.

Effect on Shannon's 'A Mathematical Theory of Communication' (1998
dvips/Distiller, the reported case): glued sentences 95 -> 5. Corpus
impact: 12 of 184 eval documents, e.g. Data-Processing-Agreement
recovers a paragraph that a phantom table had shredded into cells.

Layout heuristics tuned on the same document (indent-based paragraph
breaks, heading reclassification, table script filtering) are held back
for a separate PR — they change ~98 further documents and need to be
justified against the corpus, not against one PDF.

* review: narrow Type3 rescaling to self-inconsistent fonts; dedup + test all width tables

Addresses cubic review on #241, plus a follow-up from a local cubic run.

- Type3 visual scaling was applied to every Type3 font whose FontBBox
  height x |matrix_y| deviated >5% from 1.0. FontBBox is the glyph box,
  not the em box, so a conventional 1/1000-matrix font with a
  descender..ascender bbox (~700 units) computed 0.7 and had every
  reported size shrunk by 30% — corrupting the drop-cap, heading-tier,
  sub/superscript and table heuristics this is meant to fix.

  First attempt gated on the matrix being unit-scale, but a local cubic
  run pointed out that wrongly excludes valid non-standard matrices (a
  0.005 matrix with a full-em bbox legitimately needs a 5x scale). The
  product is the right discriminator, not the matrix: a self-consistent
  font lands near 1.0 because the matrix is the reciprocal of the
  glyph-space em, so only a wildly inconsistent one (dvips/PK bitmap
  fonts sit at ~159) is renormalized. Band widened to [0.25, 4.0].

  Corpus effect: 12 -> 7 documents change. The 5 that drop out were
  being wrongly rescaled — including Data-Processing-Agreement, whose
  phantom-table fix turned out to come from this bug rather than from
  the width fallback, so it is correctly given up.

- base14: all 14 width tables now covered by the sort-invariant test via
  an ALL_TABLES registry, not a hand-picked subset.
- base14: identical tables share one static (all four Courier variants
  are monospace 600; the oblique Helvetica variants match their upright
  forms), removing 5 duplicate copies.

* test: refresh Shannon snapshot after merging main

CI checks out a merge of the PR head with main, and main advanced 8
commits since this branch was cut — including #201 (contextual digit
runs), #240 and #253 (markdown fixes). Those change extraction output,
so a snapshot generated on the unmerged branch could not match; the
Test job failed on the merge commit while passing on the branch itself.

The merged behaviour is better: the footnote marker '2' before
'Hartley, R. V. L.' is now recovered instead of dropped.

950 tests pass on the merged tree, clippy clean.
2026-08-04 18:00:43 -07:00
Abimael MartellandClaude Fable 5 1d134e26aa docs: sync AGENTS.md with CLAUDE.md, refresh eval workflow guidance (#243)
AGENTS.md was stale (179+ PDFs, missing the semantic-quality bullet).
Both files now match: ~200-PDF corpus, and iteration guidance to prefer
subset runs (bench.py test -q / -s <name>) with the full suite as the
final pre-commit check.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 17:26:04 -07:00
Abimael Martell 7747b3a086 fix(markdown): reject non-text strikeout rules (#253)
* fix(markdown): reject non-text strikeout rules

* fix(markdown): address strikeout ownership edge cases

* fix(markdown): reject connected filled strike rules

* fix(markdown): group drifted strikeout runs
2026-08-04 11:31:58 -07:00
Abimael Martell 3fb545284b fix(layout): preserve contextual digit runs (#201)
* fix(layout): preserve contextual digit runs

* fix(layout): harden page folio filtering

* fix(markdown): distinguish folios from contextual numbers

* fix(layout): distinguish running folios from contextual digits

* fix(layout): tighten running folio evidence

* test(layout): guard the folio evidence floor

* fix(layout): preserve page-number decisions across partitions

* fix(layout): harden document-level folio filtering

* fix(markdown): carry folio context across public APIs

* fix(layout): isolate folios from layout metadata

* fix(markdown): preserve table cells in per-page extraction

* fix(layout): preserve folio context with page filters

* fix(layout): handle contextual folio sequences

* fix(layout): tighten adjacent folio evidence

* fix(layout): constrain folio context inference

* fix(forms): resolve widget pages from annotations
2026-08-03 16:35:52 -07:00
Abimael Martell 8121ae97ce chore(ci): bump GitHub Actions to current majors (#237)
* chore(ci): bump GitHub Actions to current majors

Node 20 action runtimes are deprecated on GitHub runners; bump every
first-party action to its latest major across all workflows:

- actions/checkout v4/v6 -> v7
- actions/cache v4 -> v6
- actions/upload-artifact v4 -> v7, download-artifact v4 -> v8
- actions/setup-node v6 -> v7, setup-python v5 -> v7
- actions/upload-pages-artifact v3 -> v5, deploy-pages v4 -> v5

Third-party pins (dtolnay/rust-toolchain, Swatinem/rust-cache,
setup-zig, setup-bun, taiki-e/install-action, maturin-action) are
already on their latest majors.

* chore(ci): pin all actions to full commit SHAs

Mutable @vN tags can be retagged; in the publish workflows that code
runs with OIDC credentials before npm/PyPI/crates.io publishes. Pin
every action (first- and third-party) to its release commit SHA with
the version in a trailing comment.

dtolnay/rust-toolchain infers the toolchain from its ref name, so the
SHA-pinned invocations pass an explicit toolchain: stable input.
2026-08-03 11:07:36 -07:00
Abimael Martell 98990cc550 feat(npm): add Linux musl and ARM64 N-API binaries (#233)
Adds x86_64-unknown-linux-musl, aarch64-unknown-linux-gnu, and
aarch64-unknown-linux-musl to the napi build targets so
@firecrawl/pdf-inspector works on Alpine and ARM64 Linux deployments.

- gnu arm64 cross-compiles with --use-napi-cross (old-glibc sysroot),
  musl targets with -x (zig + cargo-zigbuild), per the napi-rs template
- new platform packages carry npm libc metadata (glibc/musl)
- smoke-test job runs napi/test.mjs on all six targets before publish
  (Alpine containers for musl, ubuntu-24.04-arm runners for ARM64)
- bump to 1.12.0 to trigger publishing of all platform packages

Closes #216
2026-08-03 10:09:23 -07:00
Abimael Martell a15ec2d68d docs(readme): refresh local parser benchmark results (#192)
* docs(readme): refresh parser benchmark results

* docs(readme): include stored parser results

* docs(readme): refresh remaining parser results

* docs: sync benchmark references
2026-07-31 22:58:46 -06:00
Abimael Martell 7b7960ee73 chore(pypi): bump pdf-inspector to 0.2.6 (#188) 2026-07-31 17:12:34 -06:00
Abimael Martell 7188667045 chore(wasm): bump @firecrawl/pdf-inspector-wasm to 0.1.3 (#190) 2026-07-31 16:43:52 -06:00
Abimael Martell 6ff104409a chore(npm): bump @firecrawl/pdf-inspector to 1.11.2 (#189) 2026-07-31 16:37:50 -06:00
Abimael Martell 5e8f1570f6 chore(crate): bump pdf-inspector to 0.1.7 (#187) 2026-07-31 16:25:57 -06:00
Abimael Martell 3c6eb8bf6b feat(site): add local WebAssembly demo (#181)
* feat(site): add local WebAssembly demo

* feat(site): parse PDFs on selection
2026-07-17 15:00:47 -07:00
Abimael Martell 5b287341a0 feat(wasm): add browser bindings (#180)
* feat(wasm): add browser bindings

* fix(wasm): address review feedback

* fix(wasm): preserve numeric plain text

* chore(wasm): prepare 0.1.2 release
2026-07-17 14:09:43 -07:00
Abimael Martell 55d50ad1c4 docs(site): redesign open-source project page (#179)
* docs(site): redesign open-source project page

* docs(site): lead with node and cli

* docs(site): emphasize package registries

* docs(site): align footer wordmark

* docs(site): remove hero terminal scrollbar

* docs(site): keep hero install command inline

* docs(site): feature rust core in hero

* docs(site): add hero language playground

* Revert "docs(site): add hero language playground"

This reverts commit 16df5fcf07.
2026-07-16 22:55:51 -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
Abimael MartellandClaude Fable 5 bcaadd52fd fix(regions): exclusive item→region assignment in extract_text_in_regions (#145)
* fix(regions): exclusive item->region assignment in extract_text_in_regions

Overlapping layout regions used to extract shared items into EVERY
region they touched (the 1.5pt inclusion margin makes borders generous),
duplicating whole lines in the final markdown on 21% of a 2,078-doc
bench corpus — and downstream duplicate-handling sometimes dropped the
variant holding a sentence tail, turning duplication into content loss.

Each item is now pre-assigned to the single region with the largest
overlap area (same margin as the boolean test); the per-region filter
uses the assignment. Items are partitioned, never suppressed, so no
content can vanish that was previously extracted.

Paired with fire-pdf assembly fixes (neighbor-local sweep dedup +
remainder salvage); verified together on the repro doc: duplicate lines
6 -> 0, the audit's lost sentence recovered (fuzz 78 -> 87.5). Batch
over the worst duplication docs: 185 -> 59 total, 6 of 8 docs to zero.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3U6BKYCS73DVA83odAfYB

* fix(regions): review round — single-pass bucketing, no-OCR for lost-to-neighbor empties, shared margin constant

- Assignment and materialization now happen in ONE pass over items
  (clone bucketed at argmax time) instead of a second O(items x regions)
  traversal.
- A region whose only overlapping items were assigned to a
  better-overlapping neighbor no longer flags needs_ocr: the pixels it
  would re-read belong to that neighbor, and OCR would reintroduce the
  duplication exclusivity removed. Matches the pre-change OCR load
  (these regions were non-empty native before).
- REGION_MARGIN hoisted to a module const shared by the boolean
  predicates and the area score — they must stay in sync or an item
  passing the guard could score zero area.

Repro re-verified after fixes: fuzz 87.5, duplicates 0; 756 tests pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3U6BKYCS73DVA83odAfYB

* fix(regions): lost-to-neighbor requires zero items assigned to the region

had_candidates records overlap, not assignment loss: a region whose own
assigned items materialize to empty text (whitespace-only items,
collector filtering) was indistinguishable from one that lost everything
to a neighbor, and wrongly skipped its OCR fallback. The suppression now
also requires assigned_count == 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3U6BKYCS73DVA83odAfYB

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 18:22:30 -07:00
Abimael MartellandClaude Fable 5 6f75873807 fix(ci): move x86_64 macOS wheel build to macos-15-intel (#146)
macos-13 runners were retired by GitHub, so the x86_64-apple-darwin
build job queued forever and the publish never ran.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 18:22:06 -07:00
Abimael MartellandClaude Fable 5 3ed30d01e1 ci: add PyPI trusted publishing (abi3 wheels, v0.2.1) (#123)
* ci: add PyPI trusted publishing, abi3 wheels, bump to 0.2.1

Adds publish-pypi.yml mirroring the npm/crates.io pattern: triggers on
Cargo.toml version change, builds wheels for 5 platforms via maturin,
publishes with OIDC trusted publishing (no tokens). workflow_dispatch
serves as a manual fallback for the first run after the PyPI project
transfer.

Enables pyo3 abi3-py38 so one wheel per platform covers CPython >=3.8
(previous manual uploads were cp312-only). Bumps version to 0.2.1 since
PyPI already has 0.2.0.

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

* ci(pypi): guard dispatch to main, support partial-release repair

Review feedback: trusted publishing doesn't match on branch, so
workflow_dispatch needed an explicit main-ref guard. Manual dispatch now
always rebuilds and publishes with skip-existing so a release that
failed after uploading only some wheels can be completed by re-running.

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

* ci(pypi): version PyPI package from pyproject.toml, not Cargo.toml

Decouple the Python package version from the crate version, matching
how npm publishing keys off napi/package.json: bump [project] version
in pyproject.toml manually and CI publishes on merge. Reverts the
Cargo.toml bump so this PR no longer triggers a crates.io release.

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

* chore: remove accidentally committed uv.lock

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

* fix(ci): tolerate missing version key in parent pyproject.toml

The first merge of this workflow has a parent commit where pyproject.toml
still used dynamic = ["version"], so the old-version read would KeyError
and the auto-publish would never fire. Treat a missing key as a change.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 17:52:13 -07:00
Abimael Martell eac8af0df8 chore(napi): bump @firecrawl/pdf-inspector to 1.10.3 (#144) 2026-07-13 08:58:14 -07:00
Abimael MartellandClaude Fable 5 39c31a8404 fix(underline): rescue snug-owned underlines from the table-ruling filters (#143)
* fix(underline): rescue snug-owned underlines from the table-ruling filters

Documents that underline many full-width lines (dense CJK business docs,
legal redlines, 10-K section links) produce span-similar rules at 3+
y-levels — exactly what the repeated-ruling filter treats as table
rulings, so every semantic underline on such pages was discarded. Three
changes fix detection without re-marking real tables:

1. Snug-owner rescue: a rule survives the repeated-ruling filter when
   the union of touching text runs on its baseline row owns it (rule
   contained within the union's span +0.75em, runs cover >=60% of the
   rule, no column-sized gaps between runs). Table row separators fail
   ownership: they overshoot their cells' text or match gapped items.
   Same-row segmented rules (column-header separators) always stay
   discarded, and a rule enclosed by a drawn cell-sized box (rect-grid
   tables) is never rescued.

2. Vertical window widened 0.35em -> 0.72em below the baseline: CJK
   layouts draw underlines under the full em box, measured at ~0.67em.

3. Prose-table guard in the positions-path suppressor: a detected
   'table' whose cells hold flowing prose (>=30% of cells over 100
   chars) is a detection artifact of boxed callouts + stacked rules,
   not a real table — suppressing there erased every underline on the
   page.

Also fixes cluster_x_positions fabricating phantom table columns from
style-split continuation runs (touching items, gap <2pt, now feed one
column start) — the fix that keeps rect-grid table shapes stable while
underlined links inside cells are correctly marked.

Snapshot updates are underline gains on regulation/form fixtures and one
empty spacer-column change in a subscripted header.

Corpus (508-doc public bench sweep): text output byte-identical on all
docs; underlined items +224/-0; strikeout now fires on redline docs.
Item-level GT coverage: is_underline 86->151/405, is_strikeout 0->10/44.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3U6BKYCS73DVA83odAfYB

* fix(underline): fraction-bar guard + subscript merge across underline marks

Found by a 202-doc real-world corpus diff (pdf-evals) that exercises the
full markdown pipeline, which the bench-corpus item sweep does not:

1. Math fraction bars and lattice grid lines are underline geometry —
   short horizontal rules under digits. Guard: a narrow rule (<=60pt)
   with bar-sized text hanging just below it (denominator) never marks.
   The below-text width bound matters: tightly-leaded REAL underlines
   have a full-width next line below, which must not trip the guard.

2. merge_subscript_items refused to merge when the parent was underlined
   but the tiny digit was not (the drawn rule easily misses the digit's
   own overlap window) — losing the merge broke subscript tokens inside
   table cells (b+2 no longer became b₂). Strikeout boundaries still
   block the merge in both directions; only parent-underlined/digit-bare
   merges, absorbing with the parent's flags.

Corpus after refinement: underlined items +220/-2 (the 2 are fraction
bars the old code wrongly marked), GT rule-text coverage 149/405
underline + 10/44 strikeout, text output identical on all 508 bench
docs and word-count-identical on the 202 pdf-evals docs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3U6BKYCS73DVA83odAfYB

* fix(underline): address review — grid-evidence veto, strikeout-safe fraction guard, bounded gaps

- Cell-box veto now requires GRID EVIDENCE (a vertically abutting
  neighbor rect with x-overlap) instead of a height window: multiline
  table cells taller than the old 90pt ceiling veto again, and isolated
  filled callout panels (which legitimately contain underlines) no
  longer veto at all.
- The fraction guard gates only UNDERLINE marking; rule_strikes_item
  still evaluates, so short strikeouts near lower text survive.
- Fraction hug distance tightened to 0.3em so a short last-line at
  normal leading is not mistaken for a denominator.
- Continuation-run suppression bounds the negative gap (-4pt): text
  overhanging from an adjacent cell keeps its own column start.

Corpus after review fixes: underlined items +222/-2, GT coverage
150/405 underline + 10/44 strikeout, bench text output identical on
all 508 docs, pdf-evals word loss bounded at equation-reflow noise.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3U6BKYCS73DVA83odAfYB

* chore: appease clippy (redundant closure)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3U6BKYCS73DVA83odAfYB

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 08:47:45 -07:00
Abimael MartellandClaude Fable 5 cb3906e7b8 chore(napi): bump @firecrawl/pdf-inspector to 1.10.2 (#141)
Releases since 1.10.1: lowercase-fragment heading gate (#140,
opendataloader MHS spurious-heading class), per-page OCR routing
reasons (#139), --password support for encrypted PDFs (#138), docs
refresh (#137).


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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 13:30:49 -07:00
Abimael MartellandClaude Fable 5 ebfd096b78 fix(markdown): lowercase-initial short fragments are not headings (#140)
A lowercase-initial one-or-two-word "heading" is a mid-sentence
fragment beside display math ("or inversely", "and therefore") — real
headings that short start uppercase. Measured as spurious headings on
academic docs (opendataloader MHS via fire-pdf's coverage_fallback
path, ENG-5029).

Extends is_heading_fragment, so both bold-heading call sites get the
gate. Corpus sweep (708 opendataloader + ParseBench PDFs): 33 docs
change, all lowercase-fragment demotions from `##`/`#` to plain or
bold ("## caldera" -> "**caldera**", "## of quorum.\"" ->
"**of quorum.\"**") — no real heading is lowercase-initial and that
short in either corpus.


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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 13:24:01 -07:00
Abimael MartellandClaude Fable 5 d8eb33e390 feat: per-page OCR routing reasons (#139)
* feat: per-page OCR routing reasons (scanned/no_text/vector_text/garbled)

Replaces the single suspected_garbled_text signal with a per-page
explanation for why each OCR-flagged page needs OCR. The detector
classifies each page in pages_needing_ocr from its content analysis:

- scanned            — no usable text, image-backed page
- no_text            — no text and no image (blank/unreachable)
- vector_text        — text drawn as vector outlines, not extractable
- suspected_garbled_text — undecodable Identity-H/Type3 fonts

Exposed on PdfTypeResult.ocr_reasons_by_page and surfaced through
PdfProcessResult and the detect-pdf CLI (JSON + human output). Reasons
only ever explain pages already flagged for OCR — a text page with an
embedded logo stays TextBased, so this doesn't widen the OCR net.
Markdown output is byte-identical across the regression corpus.

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

* review: cache freshly-analyzed pages so OCR reasons aren't lost

Under a sampling ScanStrategy, the Mixed per-page loop (Phase 2) and the
garbled-font check (Phase 3) analyze non-sampled pages but dropped the
PageAnalysis after flagging them. The reason-classification pass then
missed the cache and defaulted those pages to "scanned", masking the
real vector_text / suspected_garbled_text cause. Insert the fresh
analyses into analysis_cache so the reason pass classifies them
correctly. No change under the default full-sampling strategy (all
pages are already cached).

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 13:23:37 -07:00
Abimael Martell a38efcf142 feat: --password support for encrypted PDFs (#138) 2026-07-11 11:55:36 -07:00
Abimael MartellandClaude Fable 5 42f0e52987 feat(site): GitHub Pages landing page (#135)
* feat(site): add GitHub Pages landing page

Self-contained landing page (single index.html, no build step) plus a
Pages deploy workflow that publishes site/ on push to main. Covers the
pitch, install commands for all three registries, feature grid,
benchmark, and tabbed quick-start for Rust/Python/Node/CLI.

Benchmark numbers mirror the README's current published table; both
should be refreshed together in a follow-up.

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

* feat(site): add Firecrawl Parse upsell to closing section

Replace the single CTA with a two-path split: run pdf-inspector locally
(OSS) vs. hand scanned/OCR/at-scale documents to Firecrawl Parse
(hosted). Links the OSS library back to the paid product for the cases
local parsing can't cover.

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

* feat(site): add Firecrawl branding (flame mark + wordmark)

Firecrawl flame mark anchors the hosted-parse card; charcoal wordmark
in the footer credit. Brand SVGs referenced as-is (exact colors).

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

* feat(site): refresh benchmark with current main numbers

pdf-inspector row updated from a fresh run on latest main: overall
0.78→0.83, tables 0.59→0.66, headings 0.57→0.74. Now within 0.01 of
opendataloader overall, best tables of the group, headings on par.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 01:03:45 -07:00
Abimael MartellandClaude Fable 5 0ee3cd7d10 docs: refresh benchmark table with current numbers (#137)
Re-ran opendataloader-bench on current main. pdf-inspector improved
across the board since the last table: overall 0.78→0.83, tables
0.59→0.66, headings 0.57→0.74 (competitor rows unchanged). Updated the
prose — heading detection no longer lags opendataloader, and overall is
now within 0.01 of it at ~2.5× the speed.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:58:43 -07:00
Abimael Martell f5be40143c chore(napi): bump @firecrawl/pdf-inspector to 1.10.1 (#136) 2026-07-11 00:42:27 -07:00
Abimael MartellandClaude Fable 5 fed3b90d37 feat(extractor): run-local space floor for tracked (letter-spaced) glyph runs (#133)
* feat(extractor): run-local space floor for tracked (letter-spaced) glyph runs

Display type set with tracking renders one glyph per show op; the merge
loop's fixed space thresholds (0.08-0.13 em) then read every letter gap
as a word boundary and emit "H O W" / "F U R T H E R" instead of
"HOW" / "FURTHER". The page-level Canva fixer can't help: it requires
>=50% of the page's items to be letter-spaced, and these docs track
only their display headings.

merge_text_items now pre-scans each run of consecutive single-glyph
items (same size band, same style, mergeable gaps — the loop's own
break conditions) and, when the run is tracked, derives the space floor
from the run's own gap distribution:

- runs with >=4 gaps qualify when the median gap clears the fixed
  threshold; word gaps, if present, form a second mode — split at the
  largest relative jump (>=1.4x), else the run is a single word
  ("I T I S I M P O R T A N T" -> "IT IS IMPORTANT")
- short runs (2-3 gaps: "H O W") additionally demand uniform gaps and
  ALL-CAPS or CJK — a genuine spaced sequence of single letters
  ("x y z" variables) has the same gap count, and display tracking is
  a caps convention; CJK never wants inter-glyph spaces

Corpus sweep (708 opendataloader + ParseBench text PDFs) vs main: 9
docs change — the tracked display titles ("HOW CAN YOU HELP?",
"LUNCHTIME MENU", a tracked email address), and CJK glyph-per-item
docs whose spurious inter-glyph spaces now collapse (GT for those docs
is unspaced CJK; should_join_items already treats no-space CJK as
correct on its path). No other doc moves.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3U6BKYCS73DVA83odAfYB

* fix(review): convention gate on both tiers; Han/Kana floor always infinite (PR #133 review)

- Long lowercase spaced-single runs ("a b c d e") had the tracked gap
  shape in the >=4-gap tier with no convention guard — word boundaries
  lost. The caps/CJK/title-case gate now applies to BOTH tiers; a
  title-case single word ("B u f f a l o") also qualifies.
- Han/Kana runs skipped straight to the bimodal split, so a nonuniform
  gap distribution (justification, punctuation spacing) could
  manufacture a word boundary. Han/Kana now always floors at infinity;
  Hangul deliberately keeps word-boundary handling — Korean spaces
  between words (is_spaceless_cjk excludes it).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3U6BKYCS73DVA83odAfYB

* fix(extractor): preserve mixed-case glyph boundaries

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 00:28:22 -07:00
Abimael MartellandClaude Fable 5 dd8aba9e20 fix(markdown): keep isolated headings on sparse pages; gate tagged roles (#132)
* fix(markdown): keep isolated headings on sparse pages; gate tagged roles

The isolated-line density guard wiped every isolated line on a page
where they exceeded 25% of lines. On sparse pages (covers, ToC pages
with a lone "CONTENTS" title, section-divider pages) a single heading
is trivially >25%, so the guard erased exactly the line it exists to
find. Require the page to have >=10 lines before the guard runs — the
25% ratio only signals a multi-column misfire on a dense page.

That let more isolated lines through, exposing that the visual heading
heuristic could promote lines already tagged with a non-heading struct
role (list item, blockquote, code, caption, ToC) or set in a monospace
font. Gate the heuristic on those in both converter paths.

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

* review: extend non-heading role gate; centralize on StructRole method

Move the non-heading-role check to StructRole::is_non_heading_content
and extend it to the content roles the inline allowlist missed: Quote,
Index, Note, Reference, BibEntry, Formula, Form (in addition to the
existing list/quote/caption/toc/code roles).

Figure is deliberately excluded: cover and banner pages routinely tag
the document title inside a Figure next to a seal/logo, and that title
is a real heading — including Figure demoted the LA County protocol
cover title from headings to bold. Verified against the reference.

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

* review: block table roles from heading promotion too

Add Table/TR/TH/TD/THead/TBody/TFoot to is_non_heading_content. When
table reconstruction falls back and cells reach the line loop as plain
text, a short isolated cell (a TH column header in particular) could be
promoted to a heading. Defensive: no change across either regression
corpus, so pure hardening for the fallback path.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 22:25:42 -07:00
Abimael MartellandClaude Fable 5 2dcba76d6f refactor(lib): extract text-quality detectors into a dedicated module (#121)
The garbage/encoding detectors had accreted as ~490 lines of free
functions scattered through lib.rs (flagged in the #120 review). Move the
whole cluster into src/text_quality.rs behind a module doc that maps the
surface: the two interfaces (markdown-level vs item/span-level) and the
detection classes (replacement runs, private-use/C1 runs, dollar-as-space,
non-alphanumeric dominance, substitution-cipher statistics).

Moved verbatim: detect_encoding_issues, is_garbage_text, is_cid_garbage,
analyze_text_quality, region_items_have_decoding_issue and their helpers,
CipherGarbleStats, and the TextQuality* types. The OCR-reason aggregation
plumbing (add_ocr_reason, merge_ocr_reasons, page_ocr_reason*) stays in
lib.rs since it is shared by the main extraction loops, not detection.

Pure code motion — function bodies are unchanged; only visibility keywords
were added (pub(crate) on the six items lib.rs consumes; add_ocr_reason is
now pub(crate) so the module can call it). Behavior is provably unchanged:
same test counts (565 unit + 139 integration), and release output is
byte-identical to merged main across all 185 eval PDFs. Detector unit tests
stay in lib.rs for now because they share test helpers with the table and
layout tests there.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 22:13:08 -07:00
Abimael MartellandClaude Fable 5 3a389f079e fix(markdown): ToC-page suppression, wrapped bold headings, math fragments (#131)
* fix(markdown): ToC-page suppression, wrapped bold headings, math fragments

Three heading-classification improvements:

- After emitting a "Contents"/"Table of Contents" heading, suppress
  heading promotion for the rest of that page: ToC entries are section
  titles that look exactly like headings ("1. Overview of OCR Pack")
  and whole contents pages came out as stacks of ##.
- merge_heading_lines only merged font-size-tier and struct-tree
  headings, so bold-at-body-size headings that wrap emitted two
  separate ## lines. Merge a fully-bold line into the previous
  fully-bold line when it reads as a wrap continuation (starts
  lowercase, tiny Y gap, no terminal punctuation on the previous line).
- Reject display-math fragments from the bold/rarity heading heuristic:
  equations ending in an equation number ("S = kB ln W, (2)") and
  lead-ins referencing one ("Rearranging Equation (8) gives:"). A bare
  trailing colon is deliberately NOT a signal — real headings often end
  with colons ("Procedure:").

The p1244 snapshot change is the bold-merge working as intended:
stacked form labels "**Subtotals** **from pages**" now read
"**Subtotals from pages**".

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

* review: guard tier path, protect prev headings in merge, narrow (N) rule

All three review findings applied, calibrated against the corpora:

- is_heading_fragment now gates the font-size-tier path too, not just
  the rarity heuristic.
- The bold wrap-merge requires the previous line to be tier-less as
  suggested; corpus diff confirmed the old behavior was absorbing a
  wrapped list-item fragment into a real heading.
- The bare "(N)" suffix rule suppressed real headings ("Nicaea (325)",
  appendix numbering). It now requires math evidence: an operator
  (=, <=, <<, ...) in the line or ,/: immediately before the number.
  Page-of-total running headers ("PM 2 (10)") get an explicit rule
  since the old blanket suffix check had been catching them only by
  accident.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 21:40:18 -07:00
Abimael MartellandClaude Fable 5 83754ddad0 fix(tables): reject row-stripe grids that swallow body text (#130)
* fix(tables): reject row-stripe grids that swallow body text

Charts (bar graphs, axis gridlines) emit fields of drawing rects that
pass the row-stripe shape test; the resulting phantom table then
captures the page's prose in scrambled reading order — losing headings
and paragraph flow with it. The existing max-cell-length gate only
fires for tables with <4 non-empty rows, which these grids exceed.

Add has_dominant_prose_cell: reject when one cell holds >=60 words AND
at least a third of the table's total words. Real tables never
concentrate that much text in a single cell, even with a description
column.

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

* review: extend prose-cell guard to merged-cluster path, add boundary test

The merged-cluster fallback had the same unguarded 4+-row gap as
row-stripe; apply has_dominant_prose_cell there too. The cell-rect path
already runs its own function-word prose check and is left unchanged.

Also add the boundary test from review: a 4+-row data table with one
60-word note cell stays accepted because the 1/3-of-total-words
denominator scales with table size.

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

* review: document intended small-table boundary of prose-cell guard

Investigated the suggested row-count exemption: adding a second-prose-
cell requirement (or a non_empty_rows >= 4 exemption) resurrects
verified phantom grids in 7 corpus documents — scrambled body text and
chart/figure regions, one of which is a 4-row grid. Every observed
single-dominant-cell grid in the corpora is swallowed prose, never a
real note table, and rejection degrades gracefully to prose while
acceptance scrambles reading order. Keep the guard unconditional,
document the rationale, and pin the boundary with a test.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 20:02:23 -07:00
Abimael MartellandClaude Fable 5 f2f49bdac2 fix(markdown): one-word bold headings, block ToC entries from headings (#129)
Two heading-classification fixes:

- Accept single-word headings ("IMPLEMENTATION", "CONTENTS") when the
  line is all-bold and isolated; the word_count >= 2 gate rejected them
  unconditionally.
- Add is_toc_entry_line: a line ending in a dot-leader group plus page
  number ("Measurement Lab worksheet ... 3") is a table-of-contents
  entry, never a heading. has_dot_leaders misses single-group leaders,
  so entire ToC pages were being promoted to ## headings.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:30:33 -07:00
Abimael MartellandClaude Fable 5 d28594ef94 fix(fonts): recognize URW -Medi suffix as bold (#128)
URW Type 1 fonts abbreviate the Medium weight as "Medi" in the font
name (NimbusRomNo9L-Medi is the Times-Bold substitute embedded by most
LaTeX toolchains; -MediItal is bold italic). is_bold_font only matched
the full word "medium", so bold ran undetected across LaTeX-produced
PDFs — dropping ** emphasis and starving bold-based heading detection
of its signal.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 18:30:14 -07:00
Abimael MartellandClaude Fable 5 b4d401241e chore(napi): bump @firecrawl/pdf-inspector to 1.10.0 (#127)
New in this release (#125): isStrikeout on TextItem (geometric
detection sharing the underline rules pipeline), descriptor/embedded-
font bold+italic recall for subset fonts (FontDescriptor flags,
ttf-parser OS/2+post, bare-CFF Name INDEX), quote-operator advance
width, Ts text-rise handling, ActualText rise/position fixes, and a
document-scoped font style cache.


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

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:41:46 -07:00
Abimael MartellandClaude Fable 5 57335f8bcf feat(extractor): descriptor/embedded-font style flags + geometric strikeout detection (#125)
* feat(extractor): descriptor/embedded-font style flags + geometric strikeout detection

Two style-recall gaps, both invisible to the existing name-based
heuristics:

1. Subset fonts with opaque BaseFont names ("Tc1", "AAAAAB+Amplitude")
   defeat is_italic_font/is_bold_font. New descriptor_style_flags reads
   the FontDescriptor (ItalicAngle beyond 4 degrees, Flags bit 7 Italic,
   bit 19 ForceBold) and, when the descriptor claims upright, falls back
   to the embedded font file: ttf-parser's OS/2 fsSelection + post
   italicAngle for sfnt fonts, and the CFF Name INDEX PostScript name
   for bare-CFF FontFile3 (descriptor rewritten to ItalicAngle 0 while
   embedding "Amplitude-LightItalic" was observed in the wild).
   ORed into is_bold/is_italic at item creation (content streams and
   form XObjects).

2. No strikeout signal existed. New is_strikeout on TextItem, detected
   in the same pass as underline: same rules pipeline (stroked lines /
   thin filled rects, table-ruling suppression), different vertical
   window — a rule crossing the glyphs at 12-55% of the em above the
   baseline instead of sitting at it. Exposed through napi and python
   bindings and pdf2md --items-json.

Verified on public ParseBench corpus docs: previously-missed italic
council titles and bold CJK itinerary headings now flagged (render-
checked); 24/508 docs gain flags, none lose any; 35 strikeout items
detected corpus-wide, disjoint from underline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3U6BKYCS73DVA83odAfYB

* fix(review): quote-op advance width, Ts text rise, doc-level font style cache (PR #125 review)

Address three valid findings from review:

- The ' (move-to-next-line-and-show-text) operator emitted zero-width
  items and never advanced the text matrix, so geometric underline/
  strikeout detection (which requires width > 0) could never mark its
  text, and following show ops overlapped it. Reuse Tj's advance-width
  computation and matrix advance.

- Ts (text rise) was dropped entirely: raised/lowered runs kept the
  unshifted baseline, so rules drawn at the risen glyph position missed
  the strike/underline windows. Track rise in the text state (saved and
  restored with q/Q) and shift the rendering position through the text
  matrix's y column; advances stay on the unshifted matrix per spec.

- descriptor_style_flags re-decompressed and re-parsed the same embedded
  font program on every page whenever the descriptor left a style flag
  unset (the common case). Add a document-scoped FontStyleCache keyed by
  the FontFile2/FontFile3 object id, threaded through page and form
  extraction alongside the existing CMapDecisionCache.

The fourth finding (Form XObject rules never reach geometric detection)
is real but pre-existing for underline and needs the form walker to grow
path/paint tracking plus a new return type; deferred as a follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3U6BKYCS73DVA83odAfYB

* fix(review): ActualText items render at their glyphs' text rise (PR #125 review)

The EMC-built ActualText item used the captured text matrix without the
rise adjustment the ordinary Tj/TJ/' emission sites apply, so a tagged
run shown with Ts landed on the unshifted baseline — off the strikeout/
underline windows and inconsistent with untagged runs. The rise is
captured together with the first-glyph matrix (and at BDC for the
entry-position fallback): the item must render at the rise of its
GLYPHS, not whatever rise is set by EMC time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3U6BKYCS73DVA83odAfYB

* fix(review): capture ActualText glyph position after the quote op's line move (PR #125 review)

The `'` handler skipped the entire suppressed-extraction block, so a
tagged span whose show op is `'` never captured its glyph matrix/rise —
the EMC item fell back to the BDC-entry matrix, which sits on the
PREVIOUS line (the `'` line move happens after BDC) with no rise. The
capture now happens right after the line move, matching the Tj/TJ
paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3U6BKYCS73DVA83odAfYB

* fix(review): style-boundary gate on subscript merge + strikeout suppression coverage (PR #125 review)

merge_subscript_items absorbed a script digit into its parent
regardless of underline/strikeout flags — dropping the digit's own mark
or widening the parent's over it. The merged item carries one flag, so
differing marks now break the merge, mirroring merge_text_items'
style-boundary rule (pre-existing for underline as well).

Also extends the table-suppression test to assert is_strikeout is
cleared alongside is_underline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L3U6BKYCS73DVA83odAfYB

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 17:30:47 -07:00
Abimael MartellandClaude Fable 5 15bc7894a4 docs: add MIT LICENSE file and license badge (#124)
Cargo.toml and pyproject.toml already declare MIT but the repo had no
LICENSE file, so GitHub and package registries couldn't display it.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 14:25:08 -07:00
Abimael MartellandClaude Fable 5 6e5e5849c8 Detect substitution-cipher garbled text from broken ToUnicode CMaps (#120)
* fix(lib): detect substitution-cipher garbled text from broken ToUnicode CMaps

ParseBench text_simple__att10k.pdf (issue #118) ships Type0/Identity-H
fonts whose ToUnicode CMaps are authored garbled: every bfrange maps with
a wrong constant delta, so text extracts as pure-ASCII ciphertext
("Certificate" -> "8VceZWZTReV"). The embedded subset font has no cmap
table and no glyph names, so no decode source can recover the real text
(poppler and mupdf emit the same ciphertext). The only correct behavior
is to flag the page for OCR instead of serving the garbage silently --
but the text is 100% printable ASCII with word-like tokens, so it slipped
past is_garbage_text and detect_encoding_issues.

Add CipherGarbleStats, a letter-statistics discriminator that flags a
Latin-dominant sample (>=200 ASCII letters) when vowels are starved
(<=30% of letters) AND either:
- lowercase->uppercase transitions inside words exceed 10% of letter
  bigrams (a shifted lowercase alphabet straddles the ASCII uppercase
  block), or
- the letter histogram's cosine similarity against English letter
  frequencies drops below 0.60 (catches shifts that stay within case
  blocks).

Wired into analyze_text_quality (per-page, item-level) and
detect_encoding_issues (markdown-level), so extract_pages_markdown
reports needs_ocr + suspected_garbled_text and suppresses the garbage.

Thresholds validated against the 380-document pdf-evals snapshot corpus
(Swedish, Finnish, Turkish, German, romaji, schematics, all-caps and
camelCase-heavy docs): zero false positives, and byte-identical eval
output vs main. Garbled page measures vowel ratio 0.245 / case-shift
rate 0.225 / cosine 0.532; closest legitimate document on each axis is
0.264 / 0.021 / 0.801.

Fixes #118

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

* chore: bump pdf-inspector to 0.1.4, npm package to 1.9.11

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

* fix(lib): exempt uniform-case structured content from cipher detection

Address PR review (cubic P2): the frequency branch (english_cosine < 0.60)
fired on any Latin-dominant, low-vowel letter distribution unlike English,
so non-linguistic ASCII — DNA/protein sequences, ticker symbols, hex dumps —
could be suppressed and routed to OCR despite not being garbled. Measured:
DNA cosine 0.428 / vowel ratio 0.260, protein 0.738, tickers 0.747, hex
0.549 — all would have flagged.

Add a mixed-case guard to looks_garbled: garbled English is a permutation of
natural language and carries sentence capitalization (block-straddling shifts
invert the ratio — att10k is 60% uppercase; in-case Caesar shifts preserve it
at ~3%), so both keep some of each case. The exempted structured content is
uniform case (all upper or all lower). Requiring the minority case to be >=1%
of ASCII letters exempts single-case sequences while preserving both garble
signals, including the in-case-shift scenario the frequency branch exists for.

Strictly tightens the detector: it can only remove flags, so the eval corpus
stays at zero false positives (verified byte-identical to a baseline main
binary across all 185 PDFs) and att10k remains flagged. Adds regression tests
for DNA, protein, tickers, and an in-case Caesar shift.

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

* fix(lib): make cipher detection case-agnostic via sorted-histogram shape

Address PR review follow-up: the mixed-case guard from the previous commit
returned before the vowel/frequency checks, creating a blind spot — a
uniform-case (all-lower or all-upper) substitution cipher is a plausible
broken-CMap output and would bypass OCR entirely.

Replace the case proxy with the actual invariant. A substitution cipher is
a bijection over a real language's alphabet, so it preserves the frequency
SHAPE (the sorted histogram) while scrambling letter POSITIONS (the unsorted
histogram). Signal 2 now flags when english_cosine < 0.60 (positions unlike
English) AND english_shape_cosine >= 0.90 (profile is still English-shaped).
This is independent of case, so it catches all-lower, all-upper, and
case-straddling shifts alike.

The exempted structured content fails one half: DNA/hex dumps have too steep
a profile (shape cosine 0.74 / 0.81 < 0.90), while protein sequences, ticker
symbols and base64 are not sufficiently unlike English in position (unsorted
cosine 0.74 / 0.75 / 0.77 >= 0.60). All stay out of OCR.

Still strictly corpus-safe: every real Latin document scores unsorted cosine
>= 0.70 (min 0.80), far above the 0.60 gate, so none can reach Signal 2.
Re-verified byte-identical to a baseline main binary across all 185 eval
PDFs; att10k remains flagged. Drops the now-unused case counters and adds
all-lowercase / all-uppercase shifted-prose regression tests.

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

* chore: source Python package version from Cargo.toml via maturin

Address PR review (cubic P2): pyproject.toml pinned version = "0.1.0",
which overrides Cargo.toml, so a maturin build produced a 0.1.0 Python
artifact regardless of the crate version (it had drifted since the PyO3
bindings were added). Switch to dynamic = ["version"] so maturin sources
the version from Cargo.toml [package] version and the two can no longer
diverge. No workflow auto-publishes the Python package, so this is metadata
hygiene rather than a release-path fix.

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 12:44:32 -07:00
Abimael MartellandCursor b375d6f102 feat(markdown): underline emission, Unicode scripts, style-preserving merges (#117)
* feat(markdown): underline emission, Unicode scripts, style-preserving merges (ENG-5015 2b)

Three formatting losses in the direct-extraction markdown path:

1. text_with_formatting gains <u> run emission (detect_underline option,
   default on) using the geometric is_underline flag from 1.9.9.
   Underline runs stay free of nested bold/italic markers — consumers
   match tag content literally. Heading lines keep plain text for
   bold/italic but preserve <u>: the tag carries meaning `#` doesn't.
2. merge_subscript_items now maps absorbed digit scripts to Unicode
   sub/superscript forms with direction from the baseline offset
   ("H"+"2" -> "H₂", "word"+raised "2" -> "word²", "m"+"3" -> "m³").
   NFKC/NFKD folds these back to plain digits so text matching
   downstream is unaffected; renderers keep the script semantics.
3. merge_text_items no longer merges across bold/italic boundaries —
   absorbing a styled run into a plain neighbor erased the styling
   before markdown emission ever saw it. On eval docs this recovers
   20-82 italic runs per document that previously emitted as plain.

Snapshots regenerated (diffs are the features: CCl₂F₂, m³, underlined
legal section headings, finer bold runs). pdf-evals regression suite:
202/202 real PDFs pass. napi 1.9.9 -> 1.9.10.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(extractor): break merges at underline boundaries too (review)

OR-merging underline stretched the eventual <u> span over neighboring
plain fragments. Merge runs now break on any style-flag change, the
redundant accumulator is gone, and format_list_item learned to move
bullet markers outside <u> wrappers so fully-underlined bullet lines
still render as markdown lists. td9264 snapshot regenerated — spans are
tighter (trailing periods correctly outside the tag).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(markdown): strip stray spaces before sentence punctuation (review)

Style-boundary item splits can strand a trailing period in its own
fragment, and multiple assembly paths join fragments with spaces,
yielding "word ." artifacts. Rather than chasing every join site, a
postprocess pass removes a space before `.`/`,`/`;` when the mark ends
its token (whitespace, cell boundary `|`, or end of text follows).
Dot leaders/ellipses and mid-token periods are untouched.

Fixes the td9264 "companies ." artifacts and two pre-existing
"armoring ," artifacts in the 2013-app2 snapshot. pdf-evals: zero
markdown diffs across all 203 corpus PDFs vs committed baselines.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(tables): trim spaces inside parenthetical cell fragments

* fix(tables): reject sparse prose row-stripe tables

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 23:40:08 -07:00
Abimael MartellandCursor 422a2ff118 feat(extractor): geometric underline detection on TextItem (#116)
* feat(extractor): geometric underline detection on TextItem (ENG-5015)

PDFs carry no underline font flag — underlines are stroked horizontal
lines or thin filled rects drawn under the baseline. Correlate those
graphics (already parsed from the content stream) with text items in a
post-pass: a rule within ~0.35em below the baseline covering >=60% of
an item's width marks is_underline.

Exposed through the napi and python bindings. Verified on real docs:
4/4 underlined sentences flagged on a Japanese report, links/headings
flagged on 8 of 10 underline-bearing eval docs, zero flags on docs
without underlines. Known FP source (table cell borders) documented —
downstream applies inline styling only to plain-text regions.

napi 1.9.8 -> 1.9.9.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(extractor): underline rules only from painted rects, normalized extents (review)

Two review fixes: (1) normalize rect extents before the thickness/width
checks — `re` operands pass through the CTM so width/height can be
negative, which missed negative-width rules and let negative-height
bands pass as thin; (2) only feed painted rects to underline detection —
`re` rects now wait in a pending list until a paint operator (S/s, f/F/
f*, B/B*/b/b*) confirms them, and `re W n` clip-only paths are discarded
at `n`, so invisible clip boundaries no longer underline nearby text.
Marking moved into content_stream where paint state lives (pre-rotation,
consistent device space).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(extractor): harden underline detection

* feat(cli): export positioned text item json

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-08 10:20:18 -07:00
Abimael Martell 30eddade77 fix(extractor): preserve tagged overlapping text order (#114) 2026-06-24 10:26:52 -07:00
Abimael Martell 1b2e2c76d6 fix(extractor): make trace previews unicode-safe (#113) 2026-06-24 01:27:27 -06:00
Abimael Martell ce49794719 fix(extractor): reduce garbled OCR false positives (#112)
* fix(extractor): reduce garbled OCR false positives

* fix(extractor): tighten garbled text OCR routing

* fix(extractor): decode UTF-16 ToUnicode destinations

* fix(extractor): narrow ToUnicode destination cleanup

* fix(extractor): decode Aptos private ff ligature
2026-06-24 01:13:16 -06:00
Abimael Martell 1a5ba6f1e9 feat(api): expose OCR reason signal (#110) 2026-06-23 15:51:55 -07:00
Abimael Martell 57b98c6a5d fix(extractor): flag garbled text spans for OCR (#108)
* fix(extractor): flag garbled text spans for OCR

* fix(extractor): apply text quality checks to regions

* chore(napi): bump npm package version
2026-06-23 13:12:10 -07:00
Abimael Martell f25808e0a7 fix(extractor): restore CID font state for Chinese text (#106)
* fix Chinese CID text decoding

* bump package versions
2026-06-20 19:43:27 -06:00
Abimael Martell 9360c8464d ci: add crates trusted publishing (#103) 2026-06-05 11:21:55 -07:00
Abimael Martell 252d87ac58 docs(readme): add package badges and install docs (#102)
* docs: add crates.io install instructions

* docs: add npm badge
2026-06-05 11:01:56 -07:00
Abimael Martell 85890648c9 chore: use crates.io lopdf (#101) 2026-06-05 10:50:05 -07:00