Compare commits

..
18 Commits
Author SHA1 Message Date
Abimael MartellandClaude Opus 4.6 cf7e6b895d Bump napi package version to 0.3.3
Publish npm package / Build aarch64-apple-darwin (push) Has been cancelled
Publish npm package / Build x86_64-unknown-linux-gnu (push) Has been cancelled
Publish npm package / Publish to npm (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:57:32 -07:00
Abimael MartellandClaude Opus 4.6 af34bbb6b5 feat: synthesize lines from thin filled rects as last-resort table detection
PDFs that draw table borders as thin filled rectangles (< 2pt, common in
spreadsheet exports) were invisible to both rect-based and line-based
table detectors. Now converts these thin rects to PdfLine objects and
runs line-based detection, but ONLY as a last resort after all other
methods (rect, line, heuristic, column-based) found nothing.

This avoids the regression from the earlier attempt which ran synthesis
at step 2, preempting the heuristic detector on PDFs where it worked
better.

Also relaxes uniform row spacing threshold (CV 0.05→0.02) to accept
spreadsheet-exported tables with even row heights.

Benchmark: TEDS 0.519→0.586 (+0.067), overall +0.006, no regressions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:44:11 -07:00
Abimael MartellandClaude Opus 4.6 dbc2de3e9b revert: undo table splitting changes that caused TEDS regression
Reverts commits 937311c, 1dcb0c6, 0300e96, 999f9a2. The thin-rect-to-line
synthesis and stacked table splitting improved extraction for specific
government PDFs but caused -0.05 TEDS regression on the benchmark by
preempting the heuristic detector with worse line-based grids.

These features need more targeted guards before re-enabling.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 11:20:05 -07:00
Abimael MartellandClaude Opus 4.6 999f9a2f2d feat: split stacked tables at rows without vertical borders
Rows that sit between horizontal rules but lack vertical border coverage
are not table cells — they're freestanding text (e.g. "Note: The cutoff
mark is out of 120"). These rows now split the grid into separate
sub-tables, with the unbounded text emitted as plain text between them.

Single-cell "tables" (from the split) render as plain text instead of
a degenerate 1x1 markdown table.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 10:24:28 -07:00
Abimael MartellandClaude Opus 4.6 0300e96b7d fix: don't merge column header rows as continuations
Rows with 3+ short-valued cells (avg ≤10 chars) and an empty first cell
are column headers (e.g. "UR | SC | ST | OBC | EWS"), not text overflow
from the previous row. Prevents them from being merged into the
preceding section title row.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 02:24:52 -07:00
Abimael MartellandClaude Opus 4.6 1dcb0c6feb fix: improve table row splitting for stacked sub-tables
Three fixes for better table extraction from spreadsheet-exported PDFs:

1. Convert thin filled rects (< 2pt) to PdfLine objects before line-based
   table detection. Many PDFs draw table borders as narrow filled rectangles
   instead of stroked paths — these were invisible to the line detector.

2. Relax uniform row spacing rejection (CV 0.05 → 0.02). Spreadsheet
   exports have very even row heights that were being rejected as "chart
   grids".

3. Fix continuation row merging: don't merge rows where the only non-first
   cell content is a long label (section headers like "Category No. 03").
   Don't merge first-cell-only rows with long text ("Note: ...").

Also adds multi-Y row splitting in line-based detection and column-aware
table detection skipping for multi-column pages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 02:20:42 -07:00
Abimael MartellandClaude Opus 4.6 937311ce27 feat: convert thin filled rects to lines for table border detection
Many PDFs (especially spreadsheet exports) draw table borders as thin
filled rectangles (height/width < 2pt) instead of stroked paths. These
were invisible to our line-based table detector since only stroke
operations produced PdfLine objects.

Now synthesizes PdfLine from thin rects before line-based detection,
enabling table detection on border-drawn PDFs like government forms.

Also relaxes the uniform row spacing rejection threshold (CV 0.05→0.02)
to accept spreadsheet-exported tables with even row heights.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 02:12:05 -07:00
Abimael MartellandClaude Opus 4.6 9c463b9c95 fix: prevent multi-column text from being misdetected as tables
On pages where column detection finds 2+ columns, skip body-font
heuristic table detection in the merged-band retry path. This prevents
sidebar/two-column prose from being formatted as markdown tables.

The fix is targeted: per-band heuristic detection still runs (bands
are scoped to single columns), so real tables within columns are
still detected. Only the merged-band retry (which sees all items
across columns) is gated.

Also relaxes column validation to accept asymmetric layouts (sidebars)
where one side has fewer items, and tries center-based item assignment
before edge-based to improve column splitting for asymmetric layouts.

Benchmark: NID 0.865→0.869, NID-S 0.798→0.805, overall +0.002.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 02:01:24 -07:00
Abimael MartellandClaude Opus 4.6 1c48a014f7 docs: update benchmark MHS score after rarity-based detection
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 01:38:21 -07:00
Abimael MartellandClaude Opus 4.6 6e5abd0b48 feat: rarity-based heading detection inspired by opendataloader
Replace ad-hoc bold/ratio heading checks with a unified scoring system
based on font size rarity. For each line, compute:
  score = font_rarity * 0.5 + bold * 0.3 + standalone * 0.2

Font rarity measures how infrequently a font size appears across the
document — heading fonts are rare while body text is common. This
approach (from opendataloader's ModeWeightStatistics) naturally adapts
to each document's font distribution instead of relying on fixed
thresholds.

Guards: require font_size >= 0.95 * base_size (no small-font headings),
word_count >= 3, and standalone (paragraph break before).

Benchmark improvement: MHS 0.56→0.58, MHS-S 0.66→0.70, overall +0.003.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 01:38:03 -07:00
Abimael MartellandClaude Opus 4.6 b7ec80097b chore: update lopdf to latest commit
7a05512d831415b1f2b1ce522391d6beab8a1284

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 01:27:32 -07:00
Abimael MartellandClaude Opus 4.6 895e47b094 docs: add opendataloader-bench results to README
Compare pdf-inspector against other direct text extraction engines
(no OCR/ML) on the opendataloader-bench corpus.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 01:25:08 -07:00
Abimael MartellandClaude Opus 4.6 2f9390e4bf feat: detect slightly-larger-than-body text as headings
Lines with font size 1.10-1.20x body text that are standalone and short
(1-8 words) are promoted to headings. This catches academic paper
headings where the font is only ~10% larger than body text, below the
previous 1.2x threshold.

Also syncs the simpler to_markdown_from_lines path to match the
table-aware path (removes stale colon exclusion).

Benchmark improvement: MHS 0.54→0.56, overall 0.761→0.766.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 01:18:31 -07:00
Abimael MartellandClaude Opus 4.6 b25a122655 fix: require digit after Table/Figure prefix in caption detection
Caption detection was incorrectly classifying "Table of Contents" as a
caption because it starts with "Table ". Now "Table" and "Figure"
prefixes require a digit, parenthesis, or hash after them — matching
actual captions like "Table 1", "Figure 3.2" but not titles.

Also removes debug logging left from previous iteration.

Benchmark improvement: MHS 0.52→0.54, overall 0.757→0.761.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 01:13:17 -07:00
Abimael MartellandClaude Opus 4.6 103995c200 feat: remove colon exclusion from bold heading detection
The colon exclusion was preventing legitimate headings like "Steps for
Using the Microscope:" and "Changing objectives:" from being detected.
The single edge case it was protecting (chart sub-headers) is less
impactful than the many headings it was blocking.

Benchmark improvement: MHS 0.51→0.52, MHS-S 0.61→0.62.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 01:09:19 -07:00
Abimael MartellandClaude Opus 4.6 28823bd849 feat: improve heuristic table detection for small body-font tables
- Lower minimum item count for body-font table candidates from 9 to 6,
  allowing small 2-3 row tables to be detected.
- Allow 2-column body-font tables with short cells (avg ≤25 chars) to
  bypass the "table-like content" validation. This catches text-only
  definition/category tables (e.g., species lists) without false-positiving
  on 2-column paragraph text (which has longer cells).

Benchmark improvement: TEDS 0.498→0.519, overall 0.750→0.754.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 01:07:08 -07:00
Abimael MartellandClaude Opus 4.6 830b8d955b feat: detect bold-only lines as section headings
Bold lines at body font size that are standalone (preceded by a paragraph
break) and have ≥3 words are promoted to headings. This catches the
common pattern in academic/technical PDFs where section headings use
bold text at the same size as body text.

Guards against false positives: minimum word count, colon-ending
exclusion (labels like "Table I:").

Benchmark improvement: MHS 0.37→0.50, overall 0.71→0.75.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-04 00:56:18 -07:00
Abimael Martell f664f3056d fix: harden hybrid OCR region extraction path (#23)
Align region filtering with rotated-page coordinate rewrites, switch region text assembly to the shared line-grouping pipeline, and retain edge-overlap text to avoid false empty regions that incorrectly trigger OCR fallback. Also make Python region inputs fail fast with clear ValueError messages for malformed boxes.

Made-with: Cursor
2026-04-03 22:48:13 -07:00
19 changed files with 504 additions and 124 deletions
+1 -1
View File
@@ -17,7 +17,7 @@ crate-type = ["lib", "cdylib"]
pyo3 = { version = "0.25", features = ["extension-module"], optional = true }
# PDF parsing
lopdf = { git = "https://github.com/J-F-Liu/lopdf", rev = "052674053814a9f4897af94f0b8e46a545c9b329", features = ["rayon"] }
lopdf = { git = "https://github.com/J-F-Liu/lopdf", rev = "7a05512d831415b1f2b1ce522391d6beab8a1284", features = ["rayon"] }
# Error handling
thiserror = "2.0"
+17
View File
@@ -16,6 +16,23 @@ Built by [Firecrawl](https://firecrawl.dev) to handle text-based PDFs locally in
- **Single document load** — The document is parsed once and shared between detection and extraction, avoiding redundant I/O.
- **Lightweight** — Pure Rust, no ML models, no external services. Single dependency on `lopdf` for PDF parsing.
## 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.
| Engine | Overall | Reading Order (NID) | Tables (TEDS) | Headings (MHS) | Speed (200 docs) |
|---|---|---|---|---|---|
| pdf-inspector | 0.77 | 0.87 | 0.52 | 0.58 | 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 |
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.
**Where we do well:** Speed (fastest of all engines), reading order, table detection vs other direct-text tools.
**Where we lag:** Heading detection trails opendataloader — many PDFs use bold text at body font size for headings, or headings that are only slightly larger than body text. Table detection trails OCR-based engines that can see visual table structure.
## Quick start
### Python
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "firecrawl-pdf-inspector",
"version": "0.3.2",
"version": "0.3.3",
"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",
+10 -9
View File
@@ -82,7 +82,7 @@ pub(crate) fn extract_page_text_items(
page_num: u32,
font_cmaps: &FontCMaps,
include_invisible: bool,
) -> Result<(PageExtraction, bool), PdfError> {
) -> Result<(PageExtraction, bool, bool), PdfError> {
use lopdf::content::Content;
let mut items = Vec::new();
@@ -182,7 +182,7 @@ pub(crate) fn extract_page_text_items(
content.operations.len(),
MAX_OPERATIONS
);
return Ok(((Vec::new(), Vec::new(), Vec::new()), false));
return Ok(((Vec::new(), Vec::new(), Vec::new()), false, false));
}
// Graphics state tracking
@@ -1009,11 +1009,12 @@ pub(crate) fn extract_page_text_items(
// Some PDFs embed landscape content in portrait pages using a rotated text
// matrix (e.g. [0, b, -b, 0, tx, ty] for 90° CCW). The layout engine
// assumes x=horizontal, y=vertical — so we swap coordinates to match.
let (items, rects, lines) = correct_rotated_page(items, rects, lines, &rotation_votes);
let (items, rects, lines, coords_rotated) =
correct_rotated_page(items, rects, lines, &rotation_votes);
let items = super::merge_text_items(items);
let items = super::merge_subscript_items(items);
Ok(((items, rects, lines), has_gid_fonts))
Ok(((items, rects, lines), has_gid_fonts, coords_rotated))
}
/// Counts of text operators with horizontal vs rotated combined matrices.
@@ -1030,9 +1031,9 @@ fn correct_rotated_page(
mut rects: Vec<PdfRect>,
mut lines: Vec<PdfLine>,
votes: &RotationVotes,
) -> (Vec<TextItem>, Vec<PdfRect>, Vec<PdfLine>) {
) -> (Vec<TextItem>, Vec<PdfRect>, Vec<PdfLine>, bool) {
if items.len() < 2 {
return (items, rects, lines);
return (items, rects, lines, false);
}
// Use the combined-matrix direction votes collected during extraction.
@@ -1041,7 +1042,7 @@ fn correct_rotated_page(
let total_votes = votes.horizontal + votes.rotated;
if total_votes == 0 || votes.rotated * 3 < total_votes * 2 {
// Less than ~67% of text operators are rotated → not a rotated page
return (items, rects, lines);
return (items, rects, lines, false);
}
log::debug!(
@@ -1092,7 +1093,7 @@ fn correct_rotated_page(
line.y2 = new_y2;
}
(items, rects, lines)
(items, rects, lines, true)
}
/// Remove near-duplicate rects (same coordinates within 0.5 pt tolerance).
@@ -1228,7 +1229,7 @@ mod tests {
let font_cmaps = FontCMaps::from_doc(&doc);
let result = extract_page_text_items(&doc, page_id, 1, &font_cmaps, false).unwrap();
let ((items, rects, lines), _has_gid) = result;
let ((items, rects, lines), _has_gid, _coords_rotated) = result;
assert!(items.is_empty());
assert!(rects.is_empty());
assert!(lines.is_empty());
+28 -2
View File
@@ -35,6 +35,7 @@ pub(crate) fn detect_columns(
if page_items.is_empty() {
return vec![];
}
debug!("page {}: detect_columns: {} items", page, page_items.len());
// Find page bounds
let x_min = page_items.iter().map(|i| i.x).fold(f32::INFINITY, f32::min);
@@ -166,6 +167,23 @@ pub(crate) fn detect_columns(
return vec![ColumnRegion { x_min, x_max }];
}
// Try center-based assignment first (handles asymmetric layouts / sidebars
// better than edge-based). Fall back to edge-based if center produces
// a degenerate split (one side empty).
let result = validate_and_build_columns(
&valleys,
&page_items,
x_min,
BIN_WIDTH,
x_max,
MIN_ITEMS_PER_COLUMN,
MIN_VERTICAL_SPAN_RATIO,
page,
true, // center-based assignment
);
if result.len() > 1 {
return result;
}
return validate_and_build_columns(
&valleys,
&page_items,
@@ -175,7 +193,7 @@ pub(crate) fn detect_columns(
MIN_ITEMS_PER_COLUMN,
MIN_VERTICAL_SPAN_RATIO,
page,
false, // edge-based assignment for absolute valleys
false, // edge-based fallback
);
}
@@ -505,7 +523,15 @@ fn validate_and_build_columns(
})
.collect();
if left_items.len() < min_items || right_items.len() < min_items {
// Require both sides to have items. Symmetric layout needs min_items
// on each side. Asymmetric layouts (sidebars) are accepted when the
// dominant side has ≥ min_items and the smaller side has ≥ 3 items.
let (smaller, larger) = if left_items.len() <= right_items.len() {
(left_items.len(), right_items.len())
} else {
(right_items.len(), left_items.len())
};
if larger < min_items || smaller < 3 {
continue;
}
+1 -1
View File
@@ -188,7 +188,7 @@ fn extract_positioned_text_impl(
continue;
}
}
let ((mut items, rects, lines), has_gid_fonts) =
let ((mut items, rects, lines), has_gid_fonts, _coords_rotated) =
extract_page_text_items(doc, page_id, *page_num, font_cmaps, include_invisible)?;
if has_gid_fonts {
gid_encoded_pages.insert(*page_num);
+133 -52
View File
@@ -358,6 +358,8 @@ pub fn extract_text_in_regions_mem(
let mut items_by_page: HashMap<u32, Vec<TextItem>> = HashMap::new();
let mut page_heights: HashMap<u32, f32> = HashMap::new();
let mut gid_pages: HashSet<u32> = HashSet::new();
let mut page_thresholds: HashMap<u32, f32> = HashMap::new();
let mut rotated_pages: HashSet<u32> = HashSet::new();
for (page_num, &page_id) in pages.iter() {
if !needed_pages.contains(page_num) {
@@ -369,7 +371,7 @@ pub fn extract_text_in_regions_mem(
page_heights.insert(*page_num, height);
// Extract text items for this page
let ((mut items, _rects, _lines), has_gid) =
let ((mut items, _rects, _lines), has_gid, coords_rotated) =
extractor::content_stream::extract_page_text_items(
&doc,
page_id,
@@ -377,10 +379,16 @@ pub fn extract_text_in_regions_mem(
&font_cmaps,
false,
)?;
text_utils::fix_letterspaced_items(&mut items);
let threshold = text_utils::fix_letterspaced_items(&mut items);
if threshold > 0.10 {
page_thresholds.insert(*page_num, threshold);
}
if has_gid {
gid_pages.insert(*page_num);
}
if coords_rotated {
rotated_pages.insert(*page_num);
}
items_by_page.insert(*page_num, items);
}
@@ -392,6 +400,12 @@ pub fn extract_text_in_regions_mem(
let items = items_by_page.get(&page_1idx);
let page_h = page_heights.get(&page_1idx).copied().unwrap_or(792.0);
let page_has_gid = gid_pages.contains(&page_1idx);
let adaptive_threshold = page_thresholds.get(&page_1idx).copied().unwrap_or(0.10);
let coords = if rotated_pages.contains(&page_1idx) {
RegionCoordSpace::Rotated90Ccw
} else {
RegionCoordSpace::Standard
};
let mut page_results = Vec::with_capacity(regions.len());
@@ -399,7 +413,16 @@ pub fn extract_text_in_regions_mem(
let [rx1, ry1, rx2, ry2] = *rect;
let text = match items {
Some(items) => collect_text_in_region(items, rx1, ry1, rx2, ry2, page_h),
Some(items) => collect_text_in_region_with_options(
items,
rx1,
ry1,
rx2,
ry2,
page_h,
coords,
adaptive_threshold,
),
None => String::new(),
};
@@ -454,6 +477,20 @@ fn obj_to_f32(obj: &lopdf::Object) -> Option<f32> {
}
}
#[derive(Clone, Copy)]
enum RegionCoordSpace {
Standard,
Rotated90Ccw,
}
#[derive(Clone, Copy)]
struct RegionBounds {
x_min: f32,
y_min: f32,
x_max: f32,
y_max: f32,
}
/// Collect text items that fall within a region bbox (top-left origin, PDF points)
/// and return them as a single string in reading order.
pub fn collect_text_in_region(
@@ -464,65 +501,109 @@ pub fn collect_text_in_region(
ry2: f32,
page_height: f32,
) -> String {
// Convert region from top-left to bottom-left origin
let by1 = page_height - ry2; // top-left y2 → bottom-left y1
let by2 = page_height - ry1; // top-left y1 → bottom-left y2
collect_text_in_region_with_options(
items,
rx1,
ry1,
rx2,
ry2,
page_height,
infer_region_coord_space(items),
0.10,
)
}
// Collect items whose center falls within the region
let mut matched: Vec<&TextItem> = items
#[allow(clippy::too_many_arguments)]
fn collect_text_in_region_with_options(
items: &[TextItem],
rx1: f32,
ry1: f32,
rx2: f32,
ry2: f32,
page_height: f32,
coord_space: RegionCoordSpace,
adaptive_threshold: f32,
) -> String {
let bounds = region_bounds(rx1, ry1, rx2, ry2, page_height, coord_space);
let Some(page) = items.first().map(|item| item.page) else {
return String::new();
};
let matched: Vec<TextItem> = items
.iter()
.filter(|item| {
let cx = item.x + item.width / 2.0;
let cy = item.y + item.height / 2.0;
cx >= rx1 && cx <= rx2 && cy >= by1 && cy <= by2
})
.filter(|item| region_overlaps_item(item, bounds))
.cloned()
.collect();
if matched.is_empty() {
return String::new();
}
// Sort top→bottom (descending Y in bottom-left coords), then left→right.
// Uses strict total_cmp ordering to guarantee transitivity (required by
// Rust's sort). The line-grouping phase below handles fuzzy Y matching.
matched.sort_by(|a, b| {
b.y.total_cmp(&a.y) // descending Y = top to bottom
.then(a.x.total_cmp(&b.x)) // ascending X = left to right
});
// Group into lines and join
let mut lines: Vec<String> = Vec::new();
let mut current_line = String::new();
let mut last_y = f32::NAN;
let mut last_x_end = 0.0_f32;
for item in &matched {
let line_threshold = item.font_size * 0.5;
let same_line = (item.y - last_y).abs() < line_threshold;
if !same_line && !current_line.is_empty() {
lines.push(current_line.clone());
current_line.clear();
}
if !current_line.is_empty() {
// Insert space if there's a gap between items on the same line
let gap = item.x - last_x_end;
if gap > item.font_size * 0.15 {
current_line.push(' ');
}
}
current_line.push_str(&item.text);
last_y = item.y;
last_x_end = item.x + item.width;
let mut thresholds = HashMap::new();
if adaptive_threshold > 0.10 {
thresholds.insert(page, adaptive_threshold);
}
let lines = extractor::group_into_lines_with_thresholds(matched, &thresholds, &HashSet::new());
lines
.into_iter()
.map(|line| line.text())
.collect::<Vec<_>>()
.join("\n")
}
if !current_line.is_empty() {
lines.push(current_line);
fn infer_region_coord_space(items: &[TextItem]) -> RegionCoordSpace {
// Rotated-page normalization currently maps y = -old_x, so most text items
// land at negative Y. Use this to keep `collect_text_in_region` behavior
// compatible for direct callers that do not have extractor metadata.
let negative_y = items.iter().filter(|item| item.y < 0.0).count();
if !items.is_empty() && negative_y * 2 >= items.len() {
RegionCoordSpace::Rotated90Ccw
} else {
RegionCoordSpace::Standard
}
}
lines.join("\n")
fn region_bounds(
rx1: f32,
ry1: f32,
rx2: f32,
ry2: f32,
page_height: f32,
coord_space: RegionCoordSpace,
) -> RegionBounds {
let tx_min = rx1.min(rx2);
let tx_max = rx1.max(rx2);
let ty_min = ry1.min(ry2);
let ty_max = ry1.max(ry2);
let by_min = page_height - ty_max;
let by_max = page_height - ty_min;
match coord_space {
RegionCoordSpace::Standard => RegionBounds {
x_min: tx_min,
y_min: by_min,
x_max: tx_max,
y_max: by_max,
},
RegionCoordSpace::Rotated90Ccw => RegionBounds {
x_min: by_min,
x_max: by_max,
y_min: -tx_max,
y_max: -tx_min,
},
}
}
fn region_overlaps_item(item: &TextItem, bounds: RegionBounds) -> bool {
const REGION_MARGIN: f32 = 1.5;
let item_x_min = item.x;
let item_x_max = item.x + text_utils::effective_width(item);
let item_y_min = item.y;
let item_y_max = item.y + item.height;
let x_overlap = (item_x_max.min(bounds.x_max + REGION_MARGIN)
- item_x_min.max(bounds.x_min - REGION_MARGIN))
.max(0.0);
let y_overlap = (item_y_max.min(bounds.y_max + REGION_MARGIN)
- item_y_min.max(bounds.y_min - REGION_MARGIN))
.max(0.0);
x_overlap > 0.0 && y_overlap > 0.0
}
// =========================================================================
+44 -2
View File
@@ -8,6 +8,24 @@ use log::debug;
/// Font statistics for a document
pub(crate) struct FontStats {
pub(crate) most_common_size: f32,
/// Font size frequency distribution (size_key → line count).
/// Used for rarity-based heading detection.
pub(crate) size_counts: HashMap<i32, usize>,
/// Total number of lines counted.
pub(crate) total_lines: usize,
}
/// Compute how rare a font size is in the document (0.0 = most common, 1.0 = unique).
/// Mirrors opendataloader's font rarity boosting approach: heading fonts appear on
/// far fewer lines than body text, so their percentile rank is high.
pub(crate) fn font_size_rarity(font_size: f32, stats: &FontStats) -> f32 {
if stats.total_lines == 0 {
return 0.0;
}
let key = (font_size * 10.0) as i32;
let count = stats.size_counts.get(&key).copied().unwrap_or(0);
// Rarity = 1 - (frequency ratio). A size used on 1/100 lines has rarity ~0.99.
1.0 - (count as f32 / stats.total_lines as f32)
}
/// Calculate font stats directly from items (before grouping into lines)
@@ -21,6 +39,8 @@ pub(crate) fn calculate_font_stats_from_items(items: &[TextItem]) -> FontStats {
}
}
let total_lines = size_counts.values().sum();
// Break ties by preferring the smaller font size for deterministic output
let most_common_size = size_counts
.iter()
@@ -30,7 +50,11 @@ pub(crate) fn calculate_font_stats_from_items(items: &[TextItem]) -> FontStats {
.map(|(size, _)| *size as f32 / 10.0)
.unwrap_or(12.0);
FontStats { most_common_size }
FontStats {
most_common_size,
size_counts,
total_lines,
}
}
/// Calculate font stats from grouped lines
@@ -48,6 +72,8 @@ pub(crate) fn calculate_font_stats(lines: &[TextLine]) -> FontStats {
}
}
let total_lines = size_counts.values().sum();
// Break ties by preferring the smaller font size for deterministic output
let most_common_size = size_counts
.iter()
@@ -57,7 +83,23 @@ pub(crate) fn calculate_font_stats(lines: &[TextLine]) -> FontStats {
.map(|(size, _)| *size as f32 / 10.0)
.unwrap_or(12.0);
FontStats { most_common_size }
FontStats {
most_common_size,
size_counts,
total_lines,
}
}
/// Determine the heading level for a bold-only line that didn't meet the font-size
/// threshold. These are common in academic papers where section headings are bold
/// at the same size as body text.
///
/// Returns a level below the lowest font-size tier (or H2 when no tiers exist).
pub(crate) fn bold_heading_level(heading_tiers: &[f32]) -> usize {
let level = heading_tiers.len() + 1;
// Clamp to 1..=6 — if no font-size tiers, bold headings become H2
// (H1 is reserved for titles which are typically larger)
level.clamp(2, 6)
}
/// Detect TOC-style lines that contain dot leaders (e.g., "Section Name .... 42").
+29 -9
View File
@@ -4,13 +4,11 @@
pub(crate) fn is_caption_line(text: &str) -> bool {
let trimmed = text.trim();
// Common caption prefixes in multiple languages
let caption_prefixes = [
"Figure ",
// Caption prefixes that always match (always followed by identifiers)
let always_prefixes = [
"Figura ",
"Fig. ",
"Fig ",
"Table ",
"Tabela ",
"Source:",
"Fonte:",
@@ -27,17 +25,39 @@ pub(crate) fn is_caption_line(text: &str) -> bool {
"Photo ",
"Foto ",
];
// Check if line starts with a caption prefix
for prefix in &caption_prefixes {
for prefix in &always_prefixes {
if trimmed.starts_with(prefix) {
return true;
}
}
// Check case-insensitive patterns
// "Figure" and "Table" need a digit/reference after them to distinguish
// captions ("Table 1", "Figure 3.2") from headings ("Table of Contents")
for prefix in ["Figure ", "Table "] {
if let Some(rest) = trimmed.strip_prefix(prefix) {
if rest
.trim_start()
.starts_with(|c: char| c.is_ascii_digit() || c == '(' || c == '#')
{
return true;
}
}
}
// Check case-insensitive patterns — require digit or punctuation after
// prefix to avoid matching "Table of Contents" or "Figure drawing" etc.
let lower = trimmed.to_lowercase();
if lower.starts_with("figure ") || lower.starts_with("table ") || lower.starts_with("source:") {
for pfx in ["figure ", "table "] {
if let Some(rest) = lower.strip_prefix(pfx) {
if rest
.trim_start()
.starts_with(|c: char| c.is_ascii_digit() || c == '(' || c == '#')
{
return true;
}
}
}
if lower.starts_with("source:") {
return true;
}
+48 -4
View File
@@ -6,8 +6,8 @@ use crate::structure_tree::StructRole;
use crate::types::TextLine;
use super::analysis::{
calculate_font_stats, compute_heading_tiers, compute_paragraph_threshold, detect_header_level,
has_dot_leaders,
bold_heading_level, calculate_font_stats, compute_heading_tiers, compute_paragraph_threshold,
detect_header_level, font_size_rarity, has_dot_leaders,
};
use super::classify::{format_list_item, is_caption_line, is_list_item, is_monospace_font};
use super::postprocess::clean_markdown;
@@ -443,7 +443,33 @@ pub(super) fn to_markdown_from_lines_with_tables_and_images(
&& plain_trimmed.split_whitespace().count() <= 15
{
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)
detect_header_level(line_font_size, base_size, &heading_tiers).or_else(|| {
// Rarity-based heading detection (inspired by opendataloader).
// Score = font_rarity * 0.5 + bold * 0.3 + standalone * 0.2
// Lines scoring above threshold are promoted to headings.
// Only consider lines at or above body font size.
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;
}
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 score = rarity * 0.5
+ if all_bold { 0.3 } else { 0.0 }
+ if standalone { 0.2 } else { 0.0 };
// Require standalone + at least one other signal
if score >= 0.5 && standalone && word_count >= 3 {
Some(bold_heading_level(&heading_tiers))
} else {
None
}
})
} else {
None
};
@@ -699,7 +725,25 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
{
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)
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;
}
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 score = rarity * 0.5
+ if all_bold { 0.3 } else { 0.0 }
+ if standalone { 0.2 } else { 0.0 };
if score >= 0.5 && standalone && word_count >= 3 {
return Some(bold_heading_level(&heading_tiers));
}
None
})
{
if in_paragraph {
output.push_str("\n\n");
+70 -5
View File
@@ -601,6 +601,15 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
let group = page_groups.get(&page).unwrap();
let page_items: Vec<TextItem> = group.iter().map(|(_, item)| (*item).clone()).collect();
// Detect columns early — on multi-column pages, the merged-band retry
// should skip body-font heuristic table detection (which mistakes column
// text for tables). Individual band heuristic detection is left enabled
// because bands are scoped to single columns.
let page_has_columns = {
let cols = crate::extractor::detect_columns(&page_items, page, false);
cols.len() >= 2
};
// Check for side-by-side layout (e.g. two tables placed left and right)
let mut bands = split_side_by_side(&page_items);
// Fallback: use rect hint regions to detect side-by-side layout
@@ -873,10 +882,7 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
run_heuristic(&unclaimed_items, &unclaimed_map, 6);
}
// 4. Column-based table detection: last resort for borderless tabular
// layouts (e.g. exam/reference grids) when ALL structural methods
// found nothing. Only runs when no rects/lines exist (truly borderless)
// and no other detection method found tables in this band.
// 4. Column-based table detection for borderless tabular layouts.
let band_has_tables = band_items.iter().enumerate().any(|(idx, _)| {
band_index_map
.get(idx)
@@ -903,6 +909,65 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
}
}
// 5. Thin-rect border synthesis: last resort for PDFs that draw table
// borders as thin filled rectangles (common in spreadsheet exports).
// Only runs when ALL other methods found nothing on this page.
if !page_tables.contains_key(&page) {
let page_rects: Vec<&crate::types::PdfRect> =
rects.iter().filter(|r| r.page == page).collect();
let mut synth_lines: Vec<crate::types::PdfLine> = Vec::new();
for r in &page_rects {
let (mut w, mut h) = (r.width, r.height);
let (mut x, mut y) = (r.x, r.y);
if w < 0.0 {
x += w;
w = -w;
}
if h < 0.0 {
y += h;
h = -h;
}
if h < 2.0 && w >= 10.0 {
let mid_y = y + h / 2.0;
synth_lines.push(crate::types::PdfLine {
x1: x,
y1: mid_y,
x2: x + w,
y2: mid_y,
page,
});
} else if w < 2.0 && h >= 10.0 {
let mid_x = x + w / 2.0;
synth_lines.push(crate::types::PdfLine {
x1: mid_x,
y1: y,
x2: mid_x,
y2: y + h,
page,
});
}
}
if synth_lines.len() >= 10 {
let page_text: Vec<TextItem> = text_items
.iter()
.filter(|i| i.page == page)
.cloned()
.collect();
let line_tables = detect_tables_from_lines(&page_text, &synth_lines, page);
for table in &line_tables {
for &idx in &table.item_indices {
table_items.insert(idx);
}
let table_y = table.rows.first().copied().unwrap_or(0.0);
let table_md = table_to_markdown(table);
page_tables
.entry(page)
.or_default()
.push((table_y, table_md));
}
}
}
// Merged-band retry: if we split into bands but found no tables in
// any band, retry heuristic detection with all items as a single band.
// This catches borderless tables whose text-column alignment was
@@ -915,7 +980,7 @@ pub(crate) fn to_markdown_from_items_with_rects_and_lines(
band_items.len(),
was_split
);
let heuristic_tables = detect_tables(band_items, base_size, false);
let heuristic_tables = detect_tables(band_items, base_size, page_has_columns);
for table in &heuristic_tables {
for &idx in &table.item_indices {
if let Some(&page_idx) = band_index_map.get(idx) {
+26 -13
View File
@@ -248,21 +248,34 @@ fn convert_text_items(items: Vec<crate::TextItem>) -> Vec<PyTextItem> {
.collect()
}
fn parse_page_regions(page_regions: Vec<(u32, Vec<Vec<f64>>)>) -> Vec<(u32, Vec<[f32; 4]>)> {
fn parse_page_regions(
page_regions: Vec<(u32, Vec<Vec<f64>>)>,
) -> PyResult<Vec<(u32, Vec<[f32; 4]>)>> {
page_regions
.into_iter()
.map(|(page, regions)| {
let bboxes: Vec<[f32; 4]> = regions
.iter()
.map(|r| {
if r.len() != 4 {
[0.0, 0.0, 0.0, 0.0]
} else {
[r[0] as f32, r[1] as f32, r[2] as f32, r[3] as f32]
}
})
.collect();
(page, bboxes)
let mut bboxes: Vec<[f32; 4]> = Vec::with_capacity(regions.len());
for (idx, region) in regions.into_iter().enumerate() {
if region.len() != 4 {
return Err(PyValueError::new_err(format!(
"Invalid region at page {page}, index {idx}: expected [x1, y1, x2, y2], got {} values",
region.len()
)));
}
let [x1, y1, x2, y2] = [region[0], region[1], region[2], region[3]];
if !(x1.is_finite() && y1.is_finite() && x2.is_finite() && y2.is_finite()) {
return Err(PyValueError::new_err(format!(
"Invalid region at page {page}, index {idx}: coordinates must be finite numbers"
)));
}
if x2 < x1 || y2 < y1 {
return Err(PyValueError::new_err(format!(
"Invalid region at page {page}, index {idx}: expected x2>=x1 and y2>=y1, got [{x1}, {y1}, {x2}, {y2}]"
)));
}
bboxes.push([x1 as f32, y1 as f32, x2 as f32, y2 as f32]);
}
Ok((page, bboxes))
})
.collect()
}
@@ -424,7 +437,7 @@ fn extract_text_in_regions_bytes(
data: &[u8],
page_regions: Vec<(u32, Vec<Vec<f64>>)>,
) -> PyResult<Vec<PyPageRegionTexts>> {
let regions = parse_page_regions(page_regions);
let regions = parse_page_regions(page_regions)?;
let results = crate::extract_text_in_regions_mem(data, &regions).map_err(to_py_err)?;
Ok(convert_region_results(results))
}
+21 -3
View File
@@ -219,7 +219,7 @@ pub fn detect_tables(items: &[TextItem], base_font_size: f32, skip_body_font: bo
body_font_low,
body_font_high,
);
if body_candidates.len() >= 9 {
if body_candidates.len() >= 6 {
let regions = find_table_regions_strict(&body_candidates);
log::debug!("body-font: {} strict regions found", regions.len());
@@ -241,7 +241,7 @@ pub fn detect_tables(items: &[TextItem], base_font_size: f32, skip_body_font: bo
body_candidates.len()
);
if region_items.len() < 9 {
if region_items.len() < 6 {
continue;
}
@@ -801,7 +801,25 @@ fn has_table_like_content(cells: &[Vec<String>], mode: TableDetectionMode) -> bo
// Bypass content check for wide tables (3+ columns) — text-only tables
// (category lists, program descriptions) are legitimate if they passed
// all structural validations (alignment, consistency, not key-value).
pct_data > min_pct || num_cols >= 3
// Also bypass for 2-column body-font tables with short cells (avg ≤40 chars),
// which are likely definition/category lists, not paragraph text.
if pct_data > min_pct || num_cols >= 3 {
return true;
}
if num_cols == 2 && matches!(mode, TableDetectionMode::BodyFont) {
let non_empty: Vec<usize> = cells
.iter()
.skip(1)
.flat_map(|row| row.iter())
.filter(|c| !c.trim().is_empty())
.map(|c| c.trim().len())
.collect();
if !non_empty.is_empty() {
let avg_len = non_empty.iter().sum::<usize>() / non_empty.len();
return avg_len <= 25;
}
}
false
}
/// Check if a cell value looks like table data
+5 -3
View File
@@ -243,9 +243,11 @@ pub fn detect_tables_from_lines(items: &[TextItem], lines: &[PdfLine], page: u32
.map(|s| (s - mean_spacing).powi(2))
.sum::<f32>()
/ spacings.len() as f32;
let cv = variance.sqrt() / mean_spacing; // coefficient of variation
// CV < 0.05 means nearly identical spacing — chart grid
if cv < 0.05 {
let cv = variance.sqrt() / mean_spacing;
// CV < 0.02 means nearly identical spacing — likely chart grid.
// Spreadsheet-exported tables often have uniform rows (CV 0.03-0.05),
// so we use a tighter threshold to avoid false negatives.
if cv < 0.02 {
return Vec::new();
}
}
+49
View File
@@ -1232,6 +1232,55 @@ fn test_extract_regions_mem_not_a_pdf() {
assert!(result.is_err(), "Non-PDF input should return an error");
}
#[test]
fn test_extract_regions_mem_rotated_page_not_false_empty() {
let buf = std::fs::read("tests/fixtures/tnagriculture_06_12.pdf").unwrap();
let regions =
extract_text_in_regions_mem(&buf, &[(0, vec![[0.0, 0.0, 1200.0, 1200.0]])]).unwrap();
assert_eq!(regions.len(), 1);
assert_eq!(regions[0].regions.len(), 1);
let region = &regions[0].regions[0];
assert!(
!region.text.trim().is_empty(),
"Rotated page full-region extraction should not be empty"
);
assert!(
!region.needs_ocr,
"Rotated page with native text should not be flagged for OCR fallback"
);
assert!(
region
.text
.contains("DISTRICT WISE PRODUCTION OF SPICES AND CONDIMENTS"),
"Expected known title from rotated fixture in extracted region text"
);
}
#[test]
fn test_collect_text_in_region_keeps_partial_overlap_items() {
let item = make_text_item("EdgeWord", 100.0, 700.0, 12.0, 1);
// Region intersects only the left edge of the item. Center x=124 falls
// outside x=[95,120], so center-only containment would drop it.
let text = pdf_inspector::collect_text_in_region(&[item], 95.0, 80.0, 120.0, 110.0, 800.0);
assert!(
text.contains("EdgeWord"),
"Partially overlapping items should be retained in region extraction"
);
}
#[test]
fn test_collect_text_in_region_uses_rtl_sorting() {
let items = vec![
make_text_item("بكم", 240.0, 700.0, 12.0, 1),
make_text_item("مرحبا", 300.0, 700.0, 12.0, 1),
];
let text = pdf_inspector::collect_text_in_region(&items, 0.0, 0.0, 600.0, 800.0, 800.0);
assert_eq!(
text, "مرحبا بكم",
"Region path should reuse RTL-aware line sorting"
);
}
// =========================================================================
// Fast vs normal extraction comparison
// =========================================================================
+3 -1
View File
@@ -8,7 +8,9 @@ Department of the Treasury **Internal Revenue Service**
# and Report to Employer
**This publication contains:** **Form 4070A, Employees Daily Record of** Tips **Form 4070, Employees Report of Tips to** Employer
### This publication contains:
**Form 4070A, Employees Daily Record of** Tips **Form 4070, Employees Report of Tips to** Employer
For the period
+2 -6
View File
@@ -6,9 +6,7 @@
8 4 Z E L L / L U R I E R E A L E S T A T E C E N T E R
**Table I: Cap rate correlations**
**Cap Rate Correlation With:*** **BBB Corp** **10-Year Bond Yield S&P Dividend** **Treasury (10-15 yr) Yield** Multifamily 0.187 0.771 0.068 Industrial-0.221 0.748-0.307 CBD Office-0.449 0.694-0.458 Retail-0.181 0.649-02.58
**Table I: Cap rate correlations** **Cap Rate Correlation With:*** **BBB Corp** **10-Year Bond Yield S&P Dividend** **Treasury (10-15 yr) Yield** Multifamily 0.187 0.771 0.068 Industrial-0.221 0.748-0.307 CBD Office-0.449 0.694-0.458 Retail-0.181 0.649-02.58
* Based on 25 years of data for the 10-yrT & S&P DivYld; and 14 years for BBB.
**Figure 1:** NCREIF cap rates vs. 10-yearTreasury
@@ -34,9 +32,7 @@ R E V I E W 8 5
1982 1986 1990 1994 1998 2002 2006
**Table II: Correlationsofspreadsbypropertytype**
**Correlation of Cap Rate Spreads Over Treasury** **Multifamily Industrial CBD Office**
**Table II: Correlationsofspreadsbypropertytype** **Correlation of Cap Rate Spreads Over Treasury** **Multifamily Industrial CBD Office**
||Multifamily|Industrial|CBD Office|
|---|---|---|---|
+9 -12
View File
@@ -26,19 +26,16 @@ S = Entropy (kJ/kg.K)
**Physical Properties**
Chemical Formula CCl2F2
|Chemical Formula|CCl2F2|
|---|---|
|Molecular mass|120.91|
|Boiling Point At one atmosphere|-29.75°C|
|Critical Temperature|111.97°C|
|Critical Pressure|4136 kPa|
|Critical Density|565.0 kg/m|
|Critical Volume|0.0018 m|
Molecular mass 120.91
Boiling Point-29.75°C At one atmosphere
Critical Temperature 111.97°C
Critical Pressure 4136 kPa
3 Critical Density 565.0 kg/m
Critical Volume 0.0018 m /kg
/kg
l
+7
View File
@@ -262,6 +262,13 @@ class TestExtractTextInRegions:
assert results[0].page == 0
assert results[1].page == 1
def test_malformed_region_raises_value_error(self):
with pytest.raises(ValueError, match="Invalid region"):
pdf_inspector.extract_text_in_regions(
fixture_path("thermo-freon12.pdf"),
[(0, [[0.0, 0.0, 600.0]])],
)
# ---------------------------------------------------------------------------
# Error handling