extract_tables: try vector-grid detectors before text heuristic (#85)

* 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>

* Bump version from 1.8.9 to 1.8.10

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-05-12 19:25:08 -04:00
committed by GitHub
co-authored by Claude Opus 4.7
parent f26efe6673
commit b5b91470db
3 changed files with 111 additions and 42 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@firecrawl/pdf-inspector", "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.", "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", "main": "index.js",
"types": "index.d.ts", "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 font_cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed_pages));
let mut items_by_page: HashMap<u32, Vec<TextItem>> = HashMap::new(); 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 page_heights: HashMap<u32, f32> = HashMap::new();
let mut gid_pages: HashSet<u32> = HashSet::new(); let mut gid_pages: HashSet<u32> = HashSet::new();
let mut page_thresholds: HashMap<u32, f32> = HashMap::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); let height = get_page_height(&doc, page_id).unwrap_or(792.0);
page_heights.insert(*page_num, height); 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( extractor::content_stream::extract_page_text_items(
&doc, &doc,
page_id, page_id,
@@ -675,6 +677,8 @@ pub fn extract_tables_in_regions_mem(
rotated_pages.insert(*page_num); rotated_pages.insert(*page_num);
} }
items_by_page.insert(*page_num, items); 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()); 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 // content. This avoids rejecting clean tables just because an
// unrelated decorative font on the same page is GID-encoded. // 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 { let matched: Vec<TextItem> = match items {
Some(items) => { Some(items) => items
let bounds = region_bounds(rx1, ry1, rx2, ry2, page_h, coords); .iter()
items .filter(|item| region_overlaps_item(item, bounds))
.iter() .cloned()
.filter(|item| region_overlaps_item(item, bounds)) .collect(),
.cloned()
.collect()
}
None => Vec::new(), None => Vec::new(),
}; };
@@ -736,42 +738,82 @@ pub fn extract_tables_in_regions_mem(
.unwrap_or(12.0) .unwrap_or(12.0)
}; };
// Run heuristic table detection; skip_body_font = false since // Try rect-backed and line-backed vector-grid detectors first,
// the layout model already identified this region as a table. // then fall back to the heuristic text-only detector. Each
let detected = tables::detect_tables(&matched, base_font_size, false); // 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 evaluate = |t: &tables::Table| -> Option<String> {
let md = tables::table_to_markdown(&table); let md = tables::table_to_markdown(t);
if md.trim().is_empty() { let trimmed = md.trim();
page_results.push(RegionText { if trimmed.is_empty() {
text: String::new(), return None;
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,
});
} }
} else { if is_garbage_text(&md)
page_results.push(RegionText { || 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(), text: String::new(),
needs_ocr: true, 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"); 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) // extract_tables_with_structure_mem tests (TSR-aware path)
// ========================================================================= // =========================================================================