From b5b91470db9632fba24cad92a65461ac1189c7fc Mon Sep 17 00:00:00 2001 From: Abimael Martell Date: Tue, 12 May 2026 19:25:08 -0400 Subject: [PATCH] extract_tables: try vector-grid detectors before text heuristic (#85) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * Bump version from 1.8.9 to 1.8.10 Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- napi/package.json | 2 +- src/lib.rs | 124 +++++++++++++++++++++++++------------ tests/integration_tests.rs | 27 ++++++++ 3 files changed, 111 insertions(+), 42 deletions(-) diff --git a/napi/package.json b/napi/package.json index f15ca8e..20602ec 100644 --- a/napi/package.json +++ b/napi/package.json @@ -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", diff --git a/src/lib.rs b/src/lib.rs index aa80f3a..fb49959 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -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> = HashMap::new(); + let mut rects_by_page: HashMap> = HashMap::new(); + let mut lines_by_page: HashMap> = HashMap::new(); let mut page_heights: HashMap = HashMap::new(); let mut gid_pages: HashSet = HashSet::new(); let mut page_thresholds: HashMap = 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 = 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 = 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 = 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 { + 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 = None; + if !region_rects.is_empty() { + let (rect_tables, _) = + tables::detect_tables_from_rects(&matched, ®ion_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, ®ion_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, - }); + }), } } diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 4a51aa1..4b400bb 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -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) // =========================================================================