Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c5fed2bdd |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "firecrawl-pdf-inspector",
|
"name": "firecrawl-pdf-inspector",
|
||||||
"version": "0.6.0",
|
"version": "0.7.0",
|
||||||
"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",
|
||||||
|
|||||||
+40
-16
@@ -328,27 +328,51 @@ pub struct PageMarkdownResult {
|
|||||||
pub needs_ocr: bool,
|
pub needs_ocr: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract formatted markdown for specific pages of a PDF.
|
/// Combined per-page markdown extraction and layout classification result.
|
||||||
|
#[napi(object)]
|
||||||
|
pub struct PagesExtractionResult {
|
||||||
|
/// Per-page markdown results.
|
||||||
|
pub pages: Vec<PageMarkdownResult>,
|
||||||
|
/// 1-indexed pages where tables were detected.
|
||||||
|
pub pages_with_tables: Vec<u32>,
|
||||||
|
/// 1-indexed pages where multi-column layout was detected.
|
||||||
|
pub pages_with_columns: Vec<u32>,
|
||||||
|
/// 1-indexed pages that need OCR (scanned/image-based).
|
||||||
|
pub pages_needing_ocr: Vec<u32>,
|
||||||
|
/// True if any page has tables or columns.
|
||||||
|
pub is_complex: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract formatted markdown for specific pages of a PDF, with layout
|
||||||
|
/// classification metadata.
|
||||||
///
|
///
|
||||||
/// Returns per-page markdown so callers can mix direct extraction
|
/// Returns per-page markdown and classification data (tables, columns,
|
||||||
/// (for simple text pages) with GPU OCR (for complex/scanned pages).
|
/// OCR needs) from a single parse. Font statistics are computed from the
|
||||||
///
|
/// full document so header detection is consistent across pages.
|
||||||
/// Font statistics are computed from the full document so header
|
|
||||||
/// detection is consistent across pages.
|
|
||||||
#[napi]
|
#[napi]
|
||||||
pub fn extract_pages_markdown(buffer: Buffer, pages: Vec<u32>) -> Result<Vec<PageMarkdownResult>> {
|
pub fn extract_pages_markdown(
|
||||||
|
buffer: Buffer,
|
||||||
|
pages: Vec<u32>,
|
||||||
|
) -> Result<PagesExtractionResult> {
|
||||||
let bytes: Vec<u8> = buffer.to_vec();
|
let bytes: Vec<u8> = buffer.to_vec();
|
||||||
catch_panic("extract_pages_markdown", move || {
|
catch_panic("extract_pages_markdown", move || {
|
||||||
let results = pdf_inspector::extract_pages_markdown_mem(&bytes, &pages)
|
let result = pdf_inspector::extract_pages_markdown_mem(&bytes, &pages)
|
||||||
.map_err(|e| to_napi_err(e, "extract_pages_markdown"))?;
|
.map_err(|e| to_napi_err(e, "extract_pages_markdown"))?;
|
||||||
Ok(results
|
Ok(PagesExtractionResult {
|
||||||
.into_iter()
|
pages: result
|
||||||
.map(|r| PageMarkdownResult {
|
.pages
|
||||||
page: r.page,
|
.into_iter()
|
||||||
markdown: r.markdown,
|
.map(|r| PageMarkdownResult {
|
||||||
needs_ocr: r.needs_ocr,
|
page: r.page,
|
||||||
})
|
markdown: r.markdown,
|
||||||
.collect())
|
needs_ocr: r.needs_ocr,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
pages_with_tables: result.pages_with_tables,
|
||||||
|
pages_with_columns: result.pages_with_columns,
|
||||||
|
pages_needing_ocr: result.pages_needing_ocr,
|
||||||
|
is_complex: result.is_complex,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+38
-4
@@ -315,7 +315,23 @@ pub struct PageMarkdown {
|
|||||||
pub needs_ocr: bool,
|
pub needs_ocr: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract formatted markdown for specific pages of a PDF.
|
/// Combined per-page markdown extraction and layout classification result.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct PagesExtractionResult {
|
||||||
|
/// Per-page markdown results.
|
||||||
|
pub pages: Vec<PageMarkdown>,
|
||||||
|
/// 1-indexed pages where tables were detected.
|
||||||
|
pub pages_with_tables: Vec<u32>,
|
||||||
|
/// 1-indexed pages where multi-column layout was detected.
|
||||||
|
pub pages_with_columns: Vec<u32>,
|
||||||
|
/// 1-indexed pages that need OCR (scanned/image-based).
|
||||||
|
pub pages_needing_ocr: Vec<u32>,
|
||||||
|
/// True if any page has tables or columns.
|
||||||
|
pub is_complex: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract formatted markdown for specific pages of a PDF, with layout
|
||||||
|
/// classification metadata.
|
||||||
///
|
///
|
||||||
/// Unlike [`process_pdf_mem`] which returns one concatenated markdown string,
|
/// Unlike [`process_pdf_mem`] which returns one concatenated markdown string,
|
||||||
/// this returns per-page markdown so callers can mix direct extraction
|
/// this returns per-page markdown so callers can mix direct extraction
|
||||||
@@ -325,26 +341,34 @@ pub struct PageMarkdown {
|
|||||||
/// detection thresholds are consistent regardless of which pages are
|
/// detection thresholds are consistent regardless of which pages are
|
||||||
/// requested. Per-page `needs_ocr` is set when the page has GID-encoded
|
/// requested. Per-page `needs_ocr` is set when the page has GID-encoded
|
||||||
/// fonts, encoding issues, or garbage text.
|
/// fonts, encoding issues, or garbage text.
|
||||||
|
///
|
||||||
|
/// Layout complexity (tables, columns) is computed from the full document
|
||||||
|
/// at near-zero cost since the items/rects/lines are already in memory.
|
||||||
pub fn extract_pages_markdown_mem(
|
pub fn extract_pages_markdown_mem(
|
||||||
buffer: &[u8],
|
buffer: &[u8],
|
||||||
pages: &[u32],
|
pages: &[u32],
|
||||||
) -> Result<Vec<PageMarkdown>, PdfError> {
|
) -> Result<PagesExtractionResult, PdfError> {
|
||||||
validate_pdf_bytes(buffer)?;
|
validate_pdf_bytes(buffer)?;
|
||||||
let (doc, page_count) = load_document_from_mem(buffer)?;
|
let (doc, page_count) = load_document_from_mem(buffer)?;
|
||||||
let font_cmaps = FontCMaps::from_doc(&doc);
|
let font_cmaps = FontCMaps::from_doc(&doc);
|
||||||
|
|
||||||
// Extract ALL pages to get accurate, document-wide font stats.
|
// Extract ALL pages to get accurate, document-wide font stats.
|
||||||
let ((all_items, all_rects, _all_lines), page_thresholds, gid_pages) =
|
let ((all_items, all_rects, all_lines), page_thresholds, gid_pages) =
|
||||||
extractor::extract_positioned_text_from_doc(&doc, &font_cmaps, None)?;
|
extractor::extract_positioned_text_from_doc(&doc, &font_cmaps, None)?;
|
||||||
|
|
||||||
|
// Compute layout complexity from full document (near-zero cost).
|
||||||
|
let complexity = compute_layout_complexity(&all_items, &all_rects, &all_lines);
|
||||||
|
|
||||||
// Compute font stats from full document (cross-page consistency).
|
// Compute font stats from full document (cross-page consistency).
|
||||||
let font_stats = markdown::analysis::calculate_font_stats_from_items(&all_items);
|
let font_stats = markdown::analysis::calculate_font_stats_from_items(&all_items);
|
||||||
|
|
||||||
let mut results = Vec::with_capacity(pages.len());
|
let mut results = Vec::with_capacity(pages.len());
|
||||||
|
let mut pages_needing_ocr = Vec::new();
|
||||||
|
|
||||||
for &page_0idx in pages {
|
for &page_0idx in pages {
|
||||||
// Out-of-range pages → empty + needs_ocr
|
// Out-of-range pages → empty + needs_ocr
|
||||||
if page_0idx >= page_count {
|
if page_0idx >= page_count {
|
||||||
|
pages_needing_ocr.push(page_0idx + 1);
|
||||||
results.push(PageMarkdown {
|
results.push(PageMarkdown {
|
||||||
page: page_0idx,
|
page: page_0idx,
|
||||||
markdown: String::new(),
|
markdown: String::new(),
|
||||||
@@ -394,6 +418,10 @@ pub fn extract_pages_markdown_mem(
|
|||||||
|| is_cid_garbage(&md)
|
|| is_cid_garbage(&md)
|
||||||
|| detect_encoding_issues(&md);
|
|| detect_encoding_issues(&md);
|
||||||
|
|
||||||
|
if needs_ocr {
|
||||||
|
pages_needing_ocr.push(page_1idx);
|
||||||
|
}
|
||||||
|
|
||||||
results.push(PageMarkdown {
|
results.push(PageMarkdown {
|
||||||
page: page_0idx,
|
page: page_0idx,
|
||||||
markdown: if needs_ocr { String::new() } else { md },
|
markdown: if needs_ocr { String::new() } else { md },
|
||||||
@@ -401,7 +429,13 @@ pub fn extract_pages_markdown_mem(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(results)
|
Ok(PagesExtractionResult {
|
||||||
|
pages: results,
|
||||||
|
pages_with_tables: complexity.pages_with_tables,
|
||||||
|
pages_with_columns: complexity.pages_with_columns,
|
||||||
|
pages_needing_ocr,
|
||||||
|
is_complex: complexity.is_complex,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
+74
-27
@@ -1445,55 +1445,56 @@ fn test_extract_tables_in_regions_nonexistent_page() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn test_extract_pages_markdown_basic() {
|
fn test_extract_pages_markdown_basic() {
|
||||||
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
||||||
let results = extract_pages_markdown_mem(&buf, &[0, 1]).unwrap();
|
let result = extract_pages_markdown_mem(&buf, &[0, 1]).unwrap();
|
||||||
|
|
||||||
assert_eq!(results.len(), 2);
|
assert_eq!(result.pages.len(), 2);
|
||||||
assert_eq!(results[0].page, 0);
|
assert_eq!(result.pages[0].page, 0);
|
||||||
assert_eq!(results[1].page, 1);
|
assert_eq!(result.pages[1].page, 1);
|
||||||
// Text-based PDF should produce non-empty markdown
|
// Text-based PDF should produce non-empty markdown
|
||||||
assert!(!results[0].markdown.is_empty());
|
assert!(!result.pages[0].markdown.is_empty());
|
||||||
assert!(!results[0].needs_ocr);
|
assert!(!result.pages[0].needs_ocr);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_extract_pages_markdown_page_ordering() {
|
fn test_extract_pages_markdown_page_ordering() {
|
||||||
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
||||||
// Request pages in non-sequential order
|
// Request pages in non-sequential order
|
||||||
let results = extract_pages_markdown_mem(&buf, &[1, 0]).unwrap();
|
let result = extract_pages_markdown_mem(&buf, &[1, 0]).unwrap();
|
||||||
|
|
||||||
assert_eq!(results.len(), 2);
|
assert_eq!(result.pages.len(), 2);
|
||||||
// Results should match input order, not document order
|
// Results should match input order, not document order
|
||||||
assert_eq!(results[0].page, 1);
|
assert_eq!(result.pages[0].page, 1);
|
||||||
assert_eq!(results[1].page, 0);
|
assert_eq!(result.pages[1].page, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_extract_pages_markdown_out_of_range() {
|
fn test_extract_pages_markdown_out_of_range() {
|
||||||
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
||||||
let results = extract_pages_markdown_mem(&buf, &[9999]).unwrap();
|
let result = extract_pages_markdown_mem(&buf, &[9999]).unwrap();
|
||||||
|
|
||||||
assert_eq!(results.len(), 1);
|
assert_eq!(result.pages.len(), 1);
|
||||||
assert_eq!(results[0].page, 9999);
|
assert_eq!(result.pages[0].page, 9999);
|
||||||
assert!(results[0].markdown.is_empty());
|
assert!(result.pages[0].markdown.is_empty());
|
||||||
assert!(results[0].needs_ocr);
|
assert!(result.pages[0].needs_ocr);
|
||||||
|
assert!(result.pages_needing_ocr.contains(&10000)); // 1-indexed
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_extract_pages_markdown_empty_pages_list() {
|
fn test_extract_pages_markdown_empty_pages_list() {
|
||||||
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
||||||
let results = extract_pages_markdown_mem(&buf, &[]).unwrap();
|
let result = extract_pages_markdown_mem(&buf, &[]).unwrap();
|
||||||
assert!(results.is_empty());
|
assert!(result.pages.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_extract_pages_markdown_single_page() {
|
fn test_extract_pages_markdown_single_page() {
|
||||||
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
||||||
let results = extract_pages_markdown_mem(&buf, &[0]).unwrap();
|
let result = extract_pages_markdown_mem(&buf, &[0]).unwrap();
|
||||||
|
|
||||||
assert_eq!(results.len(), 1);
|
assert_eq!(result.pages.len(), 1);
|
||||||
assert_eq!(results[0].page, 0);
|
assert_eq!(result.pages[0].page, 0);
|
||||||
assert!(!results[0].markdown.is_empty());
|
assert!(!result.pages[0].markdown.is_empty());
|
||||||
assert!(!results[0].needs_ocr);
|
assert!(!result.pages[0].needs_ocr);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1506,10 +1507,55 @@ fn test_extract_pages_markdown_invalid_buffer() {
|
|||||||
fn test_extract_pages_markdown_gid_pages_need_ocr() {
|
fn test_extract_pages_markdown_gid_pages_need_ocr() {
|
||||||
// shinagawa_identity_h.pdf has GID-encoded fonts
|
// shinagawa_identity_h.pdf has GID-encoded fonts
|
||||||
let buf = std::fs::read("tests/fixtures/shinagawa_identity_h.pdf").unwrap();
|
let buf = std::fs::read("tests/fixtures/shinagawa_identity_h.pdf").unwrap();
|
||||||
let results = extract_pages_markdown_mem(&buf, &[0]).unwrap();
|
let result = extract_pages_markdown_mem(&buf, &[0]).unwrap();
|
||||||
|
|
||||||
assert_eq!(results.len(), 1);
|
assert_eq!(result.pages.len(), 1);
|
||||||
assert!(results[0].needs_ocr);
|
assert!(result.pages[0].needs_ocr);
|
||||||
|
assert!(result.pages_needing_ocr.contains(&1)); // 1-indexed
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_pages_markdown_classification_with_tables() {
|
||||||
|
// nexo-price-en.pdf is known to have tables
|
||||||
|
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
||||||
|
let page_count = process_pdf_mem(&buf).unwrap().page_count;
|
||||||
|
let page_indices: Vec<u32> = (0..page_count).collect();
|
||||||
|
let result = extract_pages_markdown_mem(&buf, &page_indices).unwrap();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!result.pages_with_tables.is_empty(),
|
||||||
|
"nexo-price-en.pdf should have pages with tables"
|
||||||
|
);
|
||||||
|
assert!(result.is_complex);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_pages_markdown_simple_pdf_no_complexity() {
|
||||||
|
// bare_name_struct.pdf is a simple document with a heading and code block
|
||||||
|
let buf = std::fs::read("tests/fixtures/bare_name_struct.pdf").unwrap();
|
||||||
|
let result = extract_pages_markdown_mem(&buf, &[0]).unwrap();
|
||||||
|
|
||||||
|
assert!(result.pages_with_tables.is_empty());
|
||||||
|
assert!(result.pages_with_columns.is_empty());
|
||||||
|
assert!(!result.is_complex);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_extract_pages_markdown_classification_matches_process_pdf() {
|
||||||
|
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
||||||
|
let full = process_pdf_mem(&buf).unwrap();
|
||||||
|
let page_count = full.page_count;
|
||||||
|
let page_indices: Vec<u32> = (0..page_count).collect();
|
||||||
|
let result = extract_pages_markdown_mem(&buf, &page_indices).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
result.pages_with_tables, full.layout.pages_with_tables,
|
||||||
|
"pages_with_tables should match process_pdf"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
result.pages_with_columns, full.layout.pages_with_columns,
|
||||||
|
"pages_with_columns should match process_pdf"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -1523,12 +1569,13 @@ fn test_extract_pages_markdown_consistency_with_process_pdf() {
|
|||||||
// Get per-page output for all pages
|
// Get per-page output for all pages
|
||||||
let page_count = full.page_count;
|
let page_count = full.page_count;
|
||||||
let page_indices: Vec<u32> = (0..page_count).collect();
|
let page_indices: Vec<u32> = (0..page_count).collect();
|
||||||
let per_page = extract_pages_markdown_mem(&buf, &page_indices).unwrap();
|
let result = extract_pages_markdown_mem(&buf, &page_indices).unwrap();
|
||||||
|
|
||||||
// Concatenated per-page markdown should contain substantial overlap with
|
// Concatenated per-page markdown should contain substantial overlap with
|
||||||
// the full output (exact match not expected due to header/footer stripping
|
// the full output (exact match not expected due to header/footer stripping
|
||||||
// and cross-page paragraph merging differences)
|
// and cross-page paragraph merging differences)
|
||||||
let concat: String = per_page
|
let concat: String = result
|
||||||
|
.pages
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p| p.markdown.as_str())
|
.map(|p| p.markdown.as_str())
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
|
|||||||
Reference in New Issue
Block a user