diff --git a/napi/package.json b/napi/package.json index 590bf96..eddd937 100644 --- a/napi/package.json +++ b/napi/package.json @@ -1,6 +1,6 @@ { "name": "firecrawl-pdf-inspector", - "version": "0.3.6", + "version": "0.3.7", "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 2af24f1..e993a84 100644 --- a/napi/src/lib.rs +++ b/napi/src/lib.rs @@ -255,7 +255,42 @@ pub fn extract_text_in_regions( page_regions: Vec, ) -> Result> { let bytes: Vec = buffer.to_vec(); - let regions: Vec<(u32, Vec<[f32; 4]>)> = page_regions + let regions = parse_page_regions(&page_regions); + + catch_panic("extract_text_in_regions", move || { + let results = pdf_inspector::extract_text_in_regions_mem(&bytes, ®ions) + .map_err(|e| to_napi_err(e, "extract_text_in_regions"))?; + Ok(to_page_region_texts(results)) + }) +} + +/// Extract markdown tables within bounding-box regions from a PDF. +/// +/// Like `extractTextInRegions` but runs table detection on items within each +/// region and returns markdown pipe-tables instead of flat text. +/// +/// When table structure is detected, `text` contains a markdown pipe-table and +/// `needsOcr` is `false`. When no table is found, `text` is empty and +/// `needsOcr` is `true` so the caller can fall back to GPU OCR. +/// +/// Coordinates are PDF points with top-left origin. +#[napi] +pub fn extract_tables_in_regions( + buffer: Buffer, + page_regions: Vec, +) -> Result> { + let bytes: Vec = buffer.to_vec(); + let regions = parse_page_regions(&page_regions); + + catch_panic("extract_tables_in_regions", move || { + let results = pdf_inspector::extract_tables_in_regions_mem(&bytes, ®ions) + .map_err(|e| to_napi_err(e, "extract_tables_in_regions"))?; + Ok(to_page_region_texts(results)) + }) +} + +fn parse_page_regions(page_regions: &[PageRegions]) -> Vec<(u32, Vec<[f32; 4]>)> { + page_regions .iter() .map(|pr| { let bboxes: Vec<[f32; 4]> = pr @@ -271,25 +306,22 @@ pub fn extract_text_in_regions( .collect(); (pr.page, bboxes) }) - .collect(); + .collect() +} - catch_panic("extract_text_in_regions", move || { - let results = pdf_inspector::extract_text_in_regions_mem(&bytes, ®ions) - .map_err(|e| to_napi_err(e, "extract_text_in_regions"))?; - - Ok(results - .into_iter() - .map(|page_result| PageRegionTexts { - page: page_result.page, - regions: page_result - .regions - .into_iter() - .map(|r| RegionText { - text: r.text, - needs_ocr: r.needs_ocr, - }) - .collect(), - }) - .collect()) - }) +fn to_page_region_texts(results: Vec) -> Vec { + results + .into_iter() + .map(|page_result| PageRegionTexts { + page: page_result.page, + regions: page_result + .regions + .into_iter() + .map(|r| RegionText { + text: r.text, + needs_ocr: r.needs_ocr, + }) + .collect(), + }) + .collect() } diff --git a/src/lib.rs b/src/lib.rs index 194e0f6..352c82e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -444,6 +444,155 @@ pub fn extract_text_in_regions_mem( Ok(results) } +/// Extract tables within bounding-box regions from a PDF in memory. +/// +/// Similar to [`extract_text_in_regions_mem`] but runs table detection on items +/// within each region and returns markdown pipe-tables instead of flat text. +/// +/// When table structure is detected, `text` contains a markdown pipe-table and +/// `needs_ocr` is `false`. When no table is found (too few items, poor alignment, +/// GID fonts, etc.), `text` is empty and `needs_ocr` is `true` so the caller can +/// fall back to GPU OCR. +pub fn extract_tables_in_regions_mem( + buffer: &[u8], + page_regions: &[(u32, Vec<[f32; 4]>)], +) -> Result, PdfError> { + validate_pdf_bytes(buffer)?; + let (doc, _page_count) = load_document_from_mem(buffer)?; + let pages = doc.get_pages(); + + let needed_pages: HashSet = page_regions.iter().map(|(p, _)| p + 1).collect(); + let font_cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed_pages)); + + let mut items_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(); + let mut rotated_pages: HashSet = HashSet::new(); + + for (page_num, &page_id) in pages.iter() { + if !needed_pages.contains(page_num) { + continue; + } + 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) = + extractor::content_stream::extract_page_text_items( + &doc, + page_id, + *page_num, + &font_cmaps, + false, + )?; + let threshold = text_utils::fix_letterspaced_items(&mut items); + if threshold > 0.10 { + page_thresholds.insert(*page_num, threshold); + } + if has_gid { + gid_pages.insert(*page_num); + } + if coords_rotated { + rotated_pages.insert(*page_num); + } + items_by_page.insert(*page_num, items); + } + + let mut results = Vec::with_capacity(page_regions.len()); + + for (page_0idx, regions) in page_regions { + let page_1idx = page_0idx + 1; + let items = items_by_page.get(&page_1idx); + let page_h = page_heights.get(&page_1idx).copied().unwrap_or(792.0); + let page_has_gid = gid_pages.contains(&page_1idx); + let coords = if rotated_pages.contains(&page_1idx) { + RegionCoordSpace::Rotated90Ccw + } else { + RegionCoordSpace::Standard + }; + + let mut page_results = Vec::with_capacity(regions.len()); + + for rect in regions { + let [rx1, ry1, rx2, ry2] = *rect; + + // If page has GID font issues, bail early + if page_has_gid { + page_results.push(RegionText { + text: String::new(), + needs_ocr: true, + }); + continue; + } + + 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() + } + None => Vec::new(), + }; + + if matched.is_empty() { + page_results.push(RegionText { + text: String::new(), + needs_ocr: true, + }); + continue; + } + + // Compute base_font_size as most common font size in the region + let base_font_size = { + let mut freq: HashMap = HashMap::new(); + for item in &matched { + *freq.entry((item.font_size * 10.0) as i32).or_default() += 1; + } + freq.into_iter() + .max_by_key(|(_, count)| *count) + .map(|(size, _)| size as f32 / 10.0) + .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); + + 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 { + let needs_ocr = + is_garbage_text(&md) || is_cid_garbage(&md) || detect_encoding_issues(&md); + page_results.push(RegionText { + text: md, + needs_ocr, + }); + } + } else { + page_results.push(RegionText { + text: String::new(), + needs_ocr: true, + }); + } + } + + results.push(PageRegionResult { + page: *page_0idx, + regions: page_results, + }); + } + + 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 f3c90a0..5ba7d81 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -4,9 +4,9 @@ use pdf_inspector::detector::{DetectionConfig, ScanStrategy}; use pdf_inspector::extractor::group_into_lines; use pdf_inspector::types::TextLine; use pdf_inspector::{ - detect_pdf_type, extract_text, extract_text_in_regions_mem, extract_text_with_positions, - process_pdf_mem, process_pdf_with_options, to_markdown, MarkdownOptions, PdfError, PdfOptions, - PdfType, TextItem, + detect_pdf_type, extract_tables_in_regions_mem, extract_text, extract_text_in_regions_mem, + extract_text_with_positions, process_pdf_mem, process_pdf_with_options, to_markdown, + MarkdownOptions, PdfError, PdfOptions, PdfType, TextItem, }; use std::collections::HashSet; @@ -1344,3 +1344,95 @@ fn test_extract_regions_fast_vs_normal_comparison() { } } } + +// ========================================================================= +// extract_tables_in_regions_mem tests +// ========================================================================= + +#[test] +fn test_extract_tables_in_regions_table_pdf() { + // tnagriculture has a clear table with district names and spice columns + let buf = std::fs::read("tests/fixtures/tnagriculture_06_12.pdf").unwrap(); + let results = + extract_tables_in_regions_mem(&buf, &[(0, vec![[0.0, 0.0, 1200.0, 1200.0]])]).unwrap(); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].regions.len(), 1); + + let region = &results[0].regions[0]; + // Should detect a table with pipe-delimited markdown + if !region.needs_ocr { + assert!( + region.text.contains('|'), + "Table output should contain pipe delimiters" + ); + // Should have separator row + assert!( + region.text.lines().any(|l| l.contains("---")), + "Table output should contain separator row" + ); + } +} + +#[test] +fn test_extract_tables_in_regions_non_table_region() { + // Use a small region that likely won't contain enough items for a table + let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap(); + let results = + extract_tables_in_regions_mem(&buf, &[(0, vec![[0.0, 0.0, 50.0, 50.0]])]).unwrap(); + + assert_eq!(results.len(), 1); + assert_eq!(results[0].regions.len(), 1); + + let region = &results[0].regions[0]; + // Small region with few items should fall back to needs_ocr + assert!( + region.needs_ocr, + "Non-table region should set needs_ocr = true" + ); + assert!( + region.text.is_empty(), + "Non-table region should have empty text" + ); +} + +#[test] +fn test_extract_tables_in_regions_empty_region() { + let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap(); + let results = extract_tables_in_regions_mem(&buf, &[(0, vec![[0.0, 0.0, 0.0, 0.0]])]).unwrap(); + + assert_eq!(results.len(), 1); + let region = &results[0].regions[0]; + assert!(region.needs_ocr); + assert!(region.text.is_empty()); +} + +#[test] +fn test_extract_tables_in_regions_identity_h_needs_ocr() { + let buf = std::fs::read("tests/fixtures/shinagawa_identity_h.pdf").unwrap(); + let results = + extract_tables_in_regions_mem(&buf, &[(0, vec![[0.0, 0.0, 1200.0, 1200.0]])]).unwrap(); + + assert_eq!(results.len(), 1); + let region = &results[0].regions[0]; + assert!(region.needs_ocr, "Identity-H font should trigger needs_ocr"); +} + +#[test] +fn test_extract_tables_in_regions_not_a_pdf() { + let result = + extract_tables_in_regions_mem(b"not a pdf", &[(0, vec![[0.0, 0.0, 100.0, 100.0]])]); + assert!(result.is_err()); +} + +#[test] +fn test_extract_tables_in_regions_nonexistent_page() { + let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap(); + let results = + extract_tables_in_regions_mem(&buf, &[(9999, vec![[0.0, 0.0, 1200.0, 1200.0]])]).unwrap(); + + assert_eq!(results.len(), 1); + let region = &results[0].regions[0]; + assert!(region.needs_ocr); + assert!(region.text.is_empty()); +}