diff --git a/napi/package.json b/napi/package.json index b5ef06a..1042537 100644 --- a/napi/package.json +++ b/napi/package.json @@ -1,6 +1,6 @@ { "name": "@firecrawl/pdf-inspector", - "version": "1.7.0", + "version": "1.7.1", "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/napi/src/lib.rs b/napi/src/lib.rs index 6dbac26..8abc9e8 100644 --- a/napi/src/lib.rs +++ b/napi/src/lib.rs @@ -422,6 +422,52 @@ pub fn extract_tables_with_structure_cells( }) } +/// One result from `extractTablesWithStructureAuto` — markdown plus a +/// diagnostic flag identifying which path produced it. +/// +/// `fallbackReason` is `null` when the TSR-hybrid path produced the +/// markdown directly. When stage 1's quality check fires (the cells +/// look like a SLANet detection pathology — phantom rows or multi-row +/// content in a single cell), the heuristic table extractor is run on +/// the same region instead, and `fallbackReason` carries the diagnostic +/// label (`"phantom_empty_row"`, `"multi_row_in_cell"`). +#[napi(object)] +pub struct TableExtractionResultJs { + pub markdown: String, + pub fallback_reason: Option, +} + +/// Auto-fallback variant of [`extractTablesWithStructure`]. +/// +/// Runs the TSR-hybrid path, checks the resulting cells for known +/// SLANet detection pathologies, and falls back to the heuristic +/// `extractTablesInRegions` for any input where the TSR path looks +/// compromised. +/// +/// On clean inputs this returns identical markdown to +/// `extractTablesWithStructure`; on flagged inputs the heuristic +/// markdown replaces the TSR markdown and `fallbackReason` is set. +#[napi] +pub fn extract_tables_with_structure_auto( + buffer: Buffer, + inputs: Vec, +) -> Result> { + let bytes: Vec = buffer.to_vec(); + let parsed = parse_tsr_inputs(&inputs); + + catch_panic("extract_tables_with_structure_auto", move || { + let result = pdf_inspector::extract_tables_with_structure_auto_mem(&bytes, &parsed) + .map_err(|e| to_napi_err(e, "extract_tables_with_structure_auto"))?; + Ok(result + .into_iter() + .map(|r| TableExtractionResultJs { + markdown: r.markdown, + fallback_reason: r.fallback_reason, + }) + .collect()) + }) +} + fn parse_tsr_inputs(inputs: &[TsrTableInputJs]) -> Vec { inputs .iter() diff --git a/src/lib.rs b/src/lib.rs index 09b90fc..d759a7f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1185,6 +1185,200 @@ pub fn extract_tables_with_structure_mem( .collect()) } +/// Markdown for one extracted table plus a diagnostic flag describing +/// which path produced it. +/// +/// `fallback_reason` is `None` when the TSR-hybrid path produced the +/// markdown directly; `Some()` when stage 1's quality +/// check fired and the heuristic `extract_tables_in_regions_mem` was +/// substituted instead. The reason string is stable enough to use as a +/// metric label (e.g. `phantom_empty_row`, `multi_row_in_cell`). +#[derive(Debug, Clone)] +pub struct TableExtractionResult { + pub markdown: String, + pub fallback_reason: Option, +} + +/// Detect quality issues in the TSR-hybrid output for a single input. +/// +/// Returns `Some(reason)` if the cells look like they reflect a known +/// SLANet detection pathology that the heuristic table extractor would +/// likely handle better. Reasons (also used as metric labels): +/// +/// * `phantom_empty_row` — a row whose every cell is empty, surrounded +/// above and below by rows with content. SLANet sometimes emits an +/// extra row that doesn't correspond to any visible PDF row. +/// * `multi_row_in_cell` — at least one cell's matched PDF text items +/// span more than 1.3× either the smallest cell height or the tallest +/// contained item's own height, meaning the cell has absorbed text +/// from two adjacent visual rows. SLANet's row under-detection on +/// tightly-packed tables produces this. +fn detect_tsr_quality_issue( + buffer: &[u8], + input: &TsrTableInput, + cells: &[tables::StructuredCell], +) -> Result, PdfError> { + if cells.is_empty() { + return Ok(None); + } + + // Phantom row: cheap, computed from cell metadata alone. + let max_row = cells.iter().map(|c| c.row).max().unwrap_or(0); + if max_row >= 2 { + let mut row_has_content = vec![false; max_row + 1]; + for cell in cells { + if !cell.text.trim().is_empty() { + row_has_content[cell.row] = true; + } + } + for r in 1..max_row { + if !row_has_content[r] && row_has_content[r - 1] && row_has_content[r + 1] { + return Ok(Some("phantom_empty_row".to_string())); + } + } + } + + // Multi-row-in-cell: re-extract PDF text items in the page and check + // whether any non-empty cell's bbox encloses items whose y-centers + // span across multiple visual lines. This is the FNBO failure mode — + // a tall TSR cell catches text from two adjacent PDF rows. + let (doc, _page_count) = load_document_from_mem(buffer)?; + let pages = doc.get_pages(); + let page_1idx = input.page + 1; + let Some(&page_id) = pages.get(&page_1idx) else { + return Ok(None); + }; + let page_h = get_page_height(&doc, page_id).unwrap_or(792.0); + let mut needed: HashSet = HashSet::new(); + needed.insert(page_1idx); + let font_cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed)); + let ((mut items, _rects, _lines), _has_gid, coords_rotated) = + extractor::content_stream::extract_page_text_items( + &doc, + page_id, + page_1idx, + &font_cmaps, + false, + )?; + let _ = text_utils::fix_letterspaced_items(&mut items); + let coords = if coords_rotated { + RegionCoordSpace::Rotated90Ccw + } else { + RegionCoordSpace::Standard + }; + + // Use the minimum non-empty cell height as the typical-row baseline. + // The pathology is that some cells are abnormally tall (multi-row), + // so taking the median or mean would scale with the bad cells. The + // smallest cell is likely a tightly-bound single-row cell, which is + // a better proxy for a real row's height. + let mut heights: Vec = cells + .iter() + .map(|c| (c.page_pt_bbox[3] - c.page_pt_bbox[1]).abs()) + .filter(|h| *h > 0.0) + .collect(); + heights.sort_by(|a, b| a.total_cmp(b)); + let typical_row_h = heights.first().copied().unwrap_or(15.0).max(5.0); + + for cell in cells { + if cell.text.trim().is_empty() { + continue; + } + let [x1, y1, x2, y2] = cell.page_pt_bbox; + if x1 >= x2 || y1 >= y2 { + continue; + } + let bounds = region_bounds(x1, y1, x2, y2, page_h, coords); + let mut min_y = f32::INFINITY; + let mut max_y = f32::NEG_INFINITY; + let mut max_item_h = 0f32; + let mut count = 0u32; + for item in &items { + if tsr_region_contains_item(item, bounds) { + let cy = item.y + item.height * 0.5; + min_y = min_y.min(cy); + max_y = max_y.max(cy); + max_item_h = max_item_h.max(item.height); + count += 1; + } + } + if count < 2 { + continue; + } + // Items on the same visual line have y-centers within ~one + // line-height. Flag a cell whose items span > 1.3× both the + // typical row height AND the largest item's own height — + // either signal alone is a strong indicator of multi-line text + // inside a cell that should be a single row. + let span = max_y - min_y; + let row_threshold = typical_row_h * 1.3; + let item_threshold = max_item_h.max(5.0) * 1.3; + if span > row_threshold || span > item_threshold { + return Ok(Some("multi_row_in_cell".to_string())); + } + } + + Ok(None) +} + +/// Auto-fallback variant of [`extract_tables_with_structure_mem`]: +/// runs the TSR-hybrid path, checks the resulting cells for known +/// SLANet detection pathologies (phantom rows, multi-row-in-cell text), +/// and falls back to the heuristic [`extract_tables_in_regions_mem`] +/// for any input where the TSR path looks compromised. +/// +/// On clean inputs this is identical to the markdown variant. +/// On flagged inputs the heuristic markdown replaces the TSR markdown +/// and the result's `fallback_reason` is set to the diagnostic label. +/// +/// Use this from production callers that want self-healing output. +/// Use [`extract_tables_with_structure_mem`] when you want raw TSR +/// output regardless of quality (e.g. eval harnesses comparing the +/// two paths). +pub fn extract_tables_with_structure_auto_mem( + buffer: &[u8], + inputs: &[TsrTableInput], +) -> Result, PdfError> { + let tsr_cells = extract_tables_with_structure_cells_mem(buffer, inputs)?; + let mut results = Vec::with_capacity(inputs.len()); + + for (i, input) in inputs.iter().enumerate() { + let cells = &tsr_cells[i]; + let issue = detect_tsr_quality_issue(buffer, input, cells)?; + + let result = match issue { + None => TableExtractionResult { + markdown: if cells.is_empty() { + String::new() + } else { + tables::cells_to_markdown(cells) + }, + fallback_reason: None, + }, + Some(reason) => { + // Fall back to heuristic on the input's table region. + // The crop's PDF-pt bbox IS the table region. + let heuristic = extract_tables_in_regions_mem( + buffer, + &[(input.page, vec![input.crop_pdf_pt_bbox])], + )?; + let md = heuristic + .into_iter() + .next() + .and_then(|page_result| page_result.regions.into_iter().next().map(|r| r.text)) + .unwrap_or_default(); + TableExtractionResult { + markdown: md, + fallback_reason: Some(reason), + } + } + }; + results.push(result); + } + + Ok(results) +} + /// Get page height in points from MediaBox. fn get_page_height(doc: &Document, page_id: lopdf::ObjectId) -> Option { let page_dict = doc.get_dictionary(page_id).ok()?; diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 4a6c163..2037057 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -2052,6 +2052,144 @@ fn test_extract_tables_with_structure_separator_after_thead() { assert_eq!(mds[0], "|Department|Core Courses|\n|---|---|\n|BIO|8.23|\n"); } +// ========================================================================= +// extract_tables_with_structure_auto_mem tests (TSR + heuristic fallback) +// ========================================================================= + +#[test] +fn test_auto_passes_through_clean_tsr_output() { + use pdf_inspector::{extract_tables_with_structure_auto_mem, TsrTableInput}; + + let buf = synthetic_dense_table_pdf(); + let tokens: Vec = [ + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "
", + ] + .into_iter() + .map(String::from) + .collect(); + // Cells fit each visible row cleanly. Same shape as the existing + // dense-overlap regression test — TSR should produce clean output + // and the auto wrapper should pass through with no fallback. + let cell_bboxes = vec![ + poly(10.0, 72.0, 100.0, 112.0), + poly(90.0, 72.0, 180.0, 112.0), + poly(10.0, 88.8, 100.0, 128.8), + poly(90.0, 88.8, 180.0, 128.8), + poly(10.0, 105.6, 100.0, 145.6), + poly(90.0, 105.6, 180.0, 145.6), + ]; + + let results = extract_tables_with_structure_auto_mem( + &buf, + &[TsrTableInput { + page: 0, + crop_pdf_pt_bbox: [0.0, 0.0, 200.0, 800.0], + render_dpi: 72.0, + structure_tokens: tokens, + cell_bboxes, + }], + ) + .unwrap(); + assert_eq!(results.len(), 1); + assert!( + results[0].fallback_reason.is_none(), + "expected no fallback, got {:?}", + results[0].fallback_reason + ); + assert!(results[0].markdown.contains("Oak Street")); + assert!(results[0].markdown.contains("Boardwalk")); + assert!(!results[0].markdown.contains("Oak Street Boardwalk")); +} + +#[test] +fn test_auto_falls_back_on_multi_row_in_cell() { + use pdf_inspector::{extract_tables_with_structure_auto_mem, TsrTableInput}; + + let buf = synthetic_dense_table_pdf(); + // TSR returns only 2 rows for what's actually 3 visible PDF rows. + // Row 1's cells are tall enough to encompass both Oak Street and + // Boardwalk text — the FNBO row-undercount pattern. + let tokens: Vec = [ + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "", + "
", + ] + .into_iter() + .map(String::from) + .collect(); + // Header row at top-left y=[88, 105] (covers "Branch Name"/"Deposits" + // at native y=700, top-left y≈92-103). The "data" row at top-left + // y=[105, 145] is intentionally tall — covers BOTH the Oak Street + // line (top-left y≈108-119) AND the Boardwalk line (y≈124-135). + let cell_bboxes = vec![ + poly(10.0, 88.0, 100.0, 105.0), + poly(90.0, 88.0, 180.0, 105.0), + poly(10.0, 105.0, 100.0, 145.0), + poly(90.0, 105.0, 180.0, 145.0), + ]; + + let results = extract_tables_with_structure_auto_mem( + &buf, + &[TsrTableInput { + page: 0, + crop_pdf_pt_bbox: [0.0, 0.0, 200.0, 800.0], + render_dpi: 72.0, + structure_tokens: tokens, + cell_bboxes, + }], + ) + .unwrap(); + assert_eq!(results.len(), 1); + assert_eq!( + results[0].fallback_reason.as_deref(), + Some("multi_row_in_cell"), + "expected multi_row_in_cell fallback, got {:?}", + results[0].fallback_reason + ); + // The heuristic-fallback markdown should preserve all three PDF rows. + let md = &results[0].markdown; + assert!(md.contains("Oak Street"), "missing Oak Street: {md}"); + assert!(md.contains("Boardwalk"), "missing Boardwalk: {md}"); + assert!(md.contains("100"), "missing 100: {md}"); + assert!(md.contains("200"), "missing 200: {md}"); +} + +#[test] +fn test_auto_returns_empty_inputs() { + use pdf_inspector::extract_tables_with_structure_auto_mem; + let buf = synthetic_dense_table_pdf(); + let results = extract_tables_with_structure_auto_mem(&buf, &[]).unwrap(); + assert!(results.is_empty()); +} + // ========================================================================= // extract_pages_markdown_mem tests // =========================================================================