diff --git a/napi/package.json b/napi/package.json index 379939c..51edf36 100644 --- a/napi/package.json +++ b/napi/package.json @@ -1,6 +1,6 @@ { "name": "@firecrawl/pdf-inspector", - "version": "1.8.2", + "version": "1.8.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", diff --git a/src/lib.rs b/src/lib.rs index 658d1fe..15c53ba 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2152,13 +2152,15 @@ fn try_expand_multi_row_cells( /// * `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 `rowspan==1` cell encloses -/// PDF text items that cluster into two distinct visual lines +/// * `multi_row_in_cell` — at least one non-label `rowspan==1` cell +/// encloses PDF text items that cluster into two distinct visual lines /// separated by a whitespace gap larger than the line height. Cells /// declared as `rowspan>1` are excluded since they are *expected* -/// to span multiple lines. SLANet's row under-detection on -/// tightly-packed tables produces the rowspan==1-but-multi-line -/// pattern (the FNBO failure mode). +/// to span multiple lines. First-row/first-column wraps are ignored +/// unless the in-place row expansion has enough support to repair them, +/// because those are often legitimate wrapped headers or row labels. +/// SLANet's row under-detection on tightly-packed tables produces the +/// rowspan==1-but-multi-line pattern (the FNBO failure mode). fn detect_tsr_quality_issue( buffer: &[u8], input: &TsrTableInput, @@ -2216,6 +2218,8 @@ fn detect_tsr_quality_issue( }; let expanded_cells = try_expand_multi_row_cells(cells, &items, page_h, coords, adaptive_threshold); + let first_row = cells.iter().map(|cell| cell.row).min().unwrap_or(0); + let first_col = cells.iter().map(|cell| cell.col).min().unwrap_or(0); for cell in cells { // rowspan>1 cells are intentionally multi-line — skip them. @@ -2229,14 +2233,30 @@ fn detect_tsr_quality_issue( if cell_items.len() < 2 { continue; } - if cluster_tsr_cell_text_lines(cell_items).len() >= 2 { + if cluster_tsr_cell_text_lines(cell_items).len() < 2 { + continue; + } + if expanded_cells.is_some() { return Ok(Some(TsrQualityIssue::MultiRowInCell { expanded_cells })); } + if !is_wrapped_tsr_label_cell(cell, first_row, first_col) { + return Ok(Some(TsrQualityIssue::MultiRowInCell { + expanded_cells: None, + })); + } } Ok(None) } +fn is_wrapped_tsr_label_cell( + cell: &tables::StructuredCell, + first_row: usize, + first_col: usize, +) -> bool { + cell.is_header || cell.row == first_row || cell.col == first_col +} + /// 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), diff --git a/tests/fixtures/government_positions_women.pdf b/tests/fixtures/government_positions_women.pdf new file mode 100644 index 0000000..60a7917 Binary files /dev/null and b/tests/fixtures/government_positions_women.pdf differ diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index 7614d05..ef6d536 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -2532,6 +2532,61 @@ fn test_auto_expands_under_counted_vector_grid_rows() { ); } +#[test] +fn test_auto_keeps_wrapped_header_vector_grid_doc51() { + use pdf_inspector::{extract_tables_with_structure_auto_mem, TsrTableInput}; + + let buf = std::fs::read("tests/fixtures/government_positions_women.pdf").unwrap(); + let crop = [0.0, 0.0, 612.0, 792.0]; + let grid = detect_vector_grid_in_region_mem(&buf, 0, crop, 200.0) + .unwrap() + .expect("expected doc 51 vector grid"); + assert_eq!( + grid.cell_bboxes.len(), + 36, + "doc 51 should have a 9x4 vector grid" + ); + + let results = extract_tables_with_structure_auto_mem( + &buf, + &[TsrTableInput { + page: 0, + crop_pdf_pt_bbox: crop, + render_dpi: 200.0, + structure_tokens: grid.structure_tokens, + cell_bboxes: grid.cell_bboxes, + }], + ) + .unwrap(); + + assert_eq!(results.len(), 1); + let r = &results[0]; + assert!( + r.fallback_reason.is_none(), + "wrapped header/label text should not trigger heuristic fallback: {:?}\n{}", + r.fallback_reason, + r.markdown + ); + let md = &r.markdown; + assert!(md.contains("Government Position"), "missing header: {md}"); + assert!( + md.contains("Aquino Administration"), + "missing Aquino header: {md}" + ); + assert!( + md.contains("Ramos Administration"), + "missing Ramos header: {md}" + ); + assert!( + md.contains("City Municipal Councilor"), + "row label was truncated: {md}" + ); + assert!( + !md.contains("|Position||Administration"), + "heuristic fallback split the header row: {md}" + ); +} + #[test] fn test_auto_returns_empty_inputs() { use pdf_inspector::extract_tables_with_structure_auto_mem;