Compare commits

...
Author SHA1 Message Date
Abimael MartellandClaude Opus 4.7 a25d510fd9 Bump version from 1.8.9 to 1.8.10
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:19:09 -04:00
Abimael MartellandClaude Opus 4.7 43bb62647e extract_tables: try vector-grid detectors before text heuristic
extract_tables_in_regions_mem previously ran only the text-only
heuristic detector (tables::detect_tables) on the items inside each
region, discarding the rects and lines that extract_page_text_items
returned. That left the rect-backed and line-backed detectors
(detect_tables_from_rects, detect_tables_from_lines) unused by the
public region-scoped extraction path — they only ran through
detect_vector_grid_in_region_mem, which most callers don't use.

Keep the rects and lines, filter them to each region, and try in
order: rect detector → line detector → heuristic. Each candidate's
markdown is quality-gated by the existing needs_ocr checks
(is_garbage_text, is_cid_garbage, detect_encoding_issues,
looks_like_partial_table_ex); only the first clean output wins.
If all three produce empty or noisy output we still return
needs_ocr=true, matching prior behavior.

Effect on real prod-shape inputs from shadow logs:

  Full-page ruled ledger, 6 cols x ~15 rows:
    before: heuristic emits a 355-char two-row fragment
    after:  line detector emits the full 6520-char table

  Multi-row key/value layout with paragraph values:
    before: heuristic emits a 188-char header-only fragment
    after:  rect detector emits the full 1733-char table including
            the multi-bullet description cell

Existing fixtures that already passed via the heuristic continue to
pass: the quality gate rejects partial vector-grid output and falls
through, so the heuristic still wins where it produced the cleaner
result.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-12 19:13:14 -04:00
3 changed files with 111 additions and 42 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@firecrawl/pdf-inspector",
"version": "1.8.9",
"version": "1.8.10",
"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",
+83 -41
View File
@@ -644,6 +644,8 @@ pub fn extract_tables_in_regions_mem(
let font_cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed_pages));
let mut items_by_page: HashMap<u32, Vec<TextItem>> = HashMap::new();
let mut rects_by_page: HashMap<u32, Vec<PdfRect>> = HashMap::new();
let mut lines_by_page: HashMap<u32, Vec<PdfLine>> = 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();
@@ -656,7 +658,7 @@ pub fn extract_tables_in_regions_mem(
let height = get_page_height(&doc, page_id).unwrap_or(792.0);
page_heights.insert(*page_num, height);
let ((mut items, _rects, _lines), has_gid, coords_rotated) =
let ((mut items, rects, lines), has_gid, coords_rotated) =
extractor::content_stream::extract_page_text_items(
&doc,
page_id,
@@ -675,6 +677,8 @@ pub fn extract_tables_in_regions_mem(
rotated_pages.insert(*page_num);
}
items_by_page.insert(*page_num, items);
rects_by_page.insert(*page_num, rects);
lines_by_page.insert(*page_num, lines);
}
let mut results = Vec::with_capacity(page_regions.len());
@@ -704,15 +708,13 @@ pub fn extract_tables_in_regions_mem(
// content. This avoids rejecting clean tables just because an
// unrelated decorative font on the same page is GID-encoded.
let bounds = region_bounds(rx1, ry1, rx2, ry2, page_h, coords);
let matched: Vec<TextItem> = match items {
Some(items) => {
let bounds = region_bounds(rx1, ry1, rx2, ry2, page_h, coords);
items
.iter()
.filter(|item| region_overlaps_item(item, bounds))
.cloned()
.collect()
}
Some(items) => items
.iter()
.filter(|item| region_overlaps_item(item, bounds))
.cloned()
.collect(),
None => Vec::new(),
};
@@ -736,42 +738,82 @@ pub fn extract_tables_in_regions_mem(
.unwrap_or(12.0)
};
// Run heuristic table detection; skip_body_font = false since
// the layout model already identified this region as a table.
let detected = tables::detect_tables(&matched, base_font_size, false);
// Try rect-backed and line-backed vector-grid detectors first,
// then fall back to the heuristic text-only detector. Each
// candidate's markdown is quality-gated by the same
// needs_ocr checks the heuristic-only path used: if a vector
// detector produces a partial/garbled table, we ignore it and
// try the next path rather than degrade the output.
// needs_ocr fires on any of:
// - garbage text (non-alphanumeric heavy)
// - CID/Latin-1 mojibake
// - encoding issues (U+FFFD, dollar-as-space)
// - structural giveaways that the table is partial /
// mis-detected (numeric "header", empty header cells,
// duplicate header cells).
// skip_body_font = false / layout_assisted = true because the
// layout model already identified this region as a table.
let region_rects: Vec<PdfRect> = rects_by_page
.get(&page_1idx)
.map(|rs| {
rs.iter()
.filter(|r| region_overlaps_rect(r, bounds))
.cloned()
.collect()
})
.unwrap_or_default();
let region_lines: Vec<PdfLine> = lines_by_page
.get(&page_1idx)
.map(|ls| {
ls.iter()
.filter(|l| region_overlaps_line(l, bounds))
.cloned()
.collect()
})
.unwrap_or_default();
if let Some(table) = detected.into_iter().next() {
let md = tables::table_to_markdown(&table);
if md.trim().is_empty() {
page_results.push(RegionText {
text: String::new(),
needs_ocr: true,
});
} else {
// needs_ocr fires on any of:
// - garbage text (non-alphanumeric heavy)
// - CID/Latin-1 mojibake
// - encoding issues (U+FFFD, dollar-as-space)
// - structural giveaways that the table is partial /
// mis-detected (numeric "header", empty header cells,
// duplicate header cells). Caught GLM-OCR-as-baseline
// scoring 0 TEDS on real prod tables in eval.
// Layout model already identified this region as a table,
// so use relaxed partial-table checks (layout_assisted=true).
let needs_ocr = is_garbage_text(&md)
|| is_cid_garbage(&md)
|| detect_encoding_issues(&md)
|| looks_like_partial_table_ex(&md, true);
page_results.push(RegionText {
text: if needs_ocr { String::new() } else { md },
needs_ocr,
});
let evaluate = |t: &tables::Table| -> Option<String> {
let md = tables::table_to_markdown(t);
let trimmed = md.trim();
if trimmed.is_empty() {
return None;
}
} else {
page_results.push(RegionText {
if is_garbage_text(&md)
|| is_cid_garbage(&md)
|| detect_encoding_issues(&md)
|| looks_like_partial_table_ex(&md, true)
{
None
} else {
Some(md)
}
};
let mut accepted_md: Option<String> = None;
if !region_rects.is_empty() {
let (rect_tables, _) =
tables::detect_tables_from_rects(&matched, &region_rects, page_1idx);
accepted_md = rect_tables.iter().find_map(&evaluate);
}
if accepted_md.is_none() && !region_lines.is_empty() {
let line_tables =
tables::detect_tables_from_lines(&matched, &region_lines, page_1idx);
accepted_md = line_tables.iter().find_map(&evaluate);
}
if accepted_md.is_none() {
let detected = tables::detect_tables(&matched, base_font_size, false);
accepted_md = detected.iter().find_map(&evaluate);
}
match accepted_md {
Some(md) => page_results.push(RegionText {
text: md,
needs_ocr: false,
}),
None => page_results.push(RegionText {
text: String::new(),
needs_ocr: true,
});
}),
}
}
+27
View File
@@ -1648,6 +1648,33 @@ fn test_bits_pilani_page8_table_detection() {
assert!(!region.needs_ocr, "Page 8 table should still be detected");
}
#[test]
fn test_extract_tables_in_regions_uses_line_grid() {
// Stroked-grid table (m/l/S path operators forming a 2x2 grid).
// The heuristic text-only detector handles the same cells already,
// so this guards that the line-backed path doesn't regress: the
// markdown still contains all four data cells.
let buf = synthetic_vector_grid_pdf(false);
let results =
extract_tables_in_regions_mem(&buf, &[(0, vec![[40.0, 50.0, 220.0, 760.0]])]).unwrap();
let region = &results[0].regions[0];
assert!(
!region.needs_ocr,
"stroked-grid table should be extracted, got needs_ocr=true"
);
for tok in ["A1", "B1", "A2", "B2"] {
assert!(
region.text.contains(tok),
"expected '{tok}' in output, got: {}",
region.text
);
}
assert!(
region.text.contains('|'),
"expected pipe-delimited markdown"
);
}
// =========================================================================
// extract_tables_with_structure_mem tests (TSR-aware path)
// =========================================================================