feat: add extractTablesInRegions NAPI binding for region-based table extraction (#27)
Adds a new function that takes a PDF buffer and page+bbox regions (same interface as extractTextInRegions), runs heuristic table detection on items within each region, and returns markdown pipe-tables. Falls back to needs_ocr=true when no table structure is found or text quality is suspect. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
8e3084183c
commit
8e8ab4a19d
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "firecrawl-pdf-inspector",
|
"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.",
|
"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",
|
||||||
|
|||||||
+53
-21
@@ -255,7 +255,42 @@ pub fn extract_text_in_regions(
|
|||||||
page_regions: Vec<PageRegions>,
|
page_regions: Vec<PageRegions>,
|
||||||
) -> Result<Vec<PageRegionTexts>> {
|
) -> Result<Vec<PageRegionTexts>> {
|
||||||
let bytes: Vec<u8> = buffer.to_vec();
|
let bytes: Vec<u8> = 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<PageRegions>,
|
||||||
|
) -> Result<Vec<PageRegionTexts>> {
|
||||||
|
let bytes: Vec<u8> = 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()
|
.iter()
|
||||||
.map(|pr| {
|
.map(|pr| {
|
||||||
let bboxes: Vec<[f32; 4]> = pr
|
let bboxes: Vec<[f32; 4]> = pr
|
||||||
@@ -271,25 +306,22 @@ pub fn extract_text_in_regions(
|
|||||||
.collect();
|
.collect();
|
||||||
(pr.page, bboxes)
|
(pr.page, bboxes)
|
||||||
})
|
})
|
||||||
.collect();
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
catch_panic("extract_text_in_regions", move || {
|
fn to_page_region_texts(results: Vec<pdf_inspector::PageRegionResult>) -> Vec<PageRegionTexts> {
|
||||||
let results = pdf_inspector::extract_text_in_regions_mem(&bytes, ®ions)
|
results
|
||||||
.map_err(|e| to_napi_err(e, "extract_text_in_regions"))?;
|
.into_iter()
|
||||||
|
.map(|page_result| PageRegionTexts {
|
||||||
Ok(results
|
page: page_result.page,
|
||||||
.into_iter()
|
regions: page_result
|
||||||
.map(|page_result| PageRegionTexts {
|
.regions
|
||||||
page: page_result.page,
|
.into_iter()
|
||||||
regions: page_result
|
.map(|r| RegionText {
|
||||||
.regions
|
text: r.text,
|
||||||
.into_iter()
|
needs_ocr: r.needs_ocr,
|
||||||
.map(|r| RegionText {
|
})
|
||||||
text: r.text,
|
.collect(),
|
||||||
needs_ocr: r.needs_ocr,
|
})
|
||||||
})
|
.collect()
|
||||||
.collect(),
|
|
||||||
})
|
|
||||||
.collect())
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
+149
@@ -444,6 +444,155 @@ pub fn extract_text_in_regions_mem(
|
|||||||
Ok(results)
|
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<Vec<PageRegionResult>, PdfError> {
|
||||||
|
validate_pdf_bytes(buffer)?;
|
||||||
|
let (doc, _page_count) = load_document_from_mem(buffer)?;
|
||||||
|
let pages = doc.get_pages();
|
||||||
|
|
||||||
|
let needed_pages: HashSet<u32> = 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<u32, Vec<TextItem>> = HashMap::new();
|
||||||
|
let mut page_heights: HashMap<u32, f32> = HashMap::new();
|
||||||
|
let mut gid_pages: HashSet<u32> = HashSet::new();
|
||||||
|
let mut page_thresholds: HashMap<u32, f32> = HashMap::new();
|
||||||
|
let mut rotated_pages: HashSet<u32> = 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<TextItem> = 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<i32, usize> = 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.
|
/// Get page height in points from MediaBox.
|
||||||
fn get_page_height(doc: &Document, page_id: lopdf::ObjectId) -> Option<f32> {
|
fn get_page_height(doc: &Document, page_id: lopdf::ObjectId) -> Option<f32> {
|
||||||
let page_dict = doc.get_dictionary(page_id).ok()?;
|
let page_dict = doc.get_dictionary(page_id).ok()?;
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ use pdf_inspector::detector::{DetectionConfig, ScanStrategy};
|
|||||||
use pdf_inspector::extractor::group_into_lines;
|
use pdf_inspector::extractor::group_into_lines;
|
||||||
use pdf_inspector::types::TextLine;
|
use pdf_inspector::types::TextLine;
|
||||||
use pdf_inspector::{
|
use pdf_inspector::{
|
||||||
detect_pdf_type, extract_text, extract_text_in_regions_mem, extract_text_with_positions,
|
detect_pdf_type, extract_tables_in_regions_mem, extract_text, extract_text_in_regions_mem,
|
||||||
process_pdf_mem, process_pdf_with_options, to_markdown, MarkdownOptions, PdfError, PdfOptions,
|
extract_text_with_positions, process_pdf_mem, process_pdf_with_options, to_markdown,
|
||||||
PdfType, TextItem,
|
MarkdownOptions, PdfError, PdfOptions, PdfType, TextItem,
|
||||||
};
|
};
|
||||||
use std::collections::HashSet;
|
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());
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user