Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebb01ab6a3 | ||
|
|
53eff94147 | ||
|
|
30244078d8 | ||
|
|
57673ebb69 | ||
|
|
2d1c6f9ff8 | ||
|
|
55b32d542e | ||
|
|
79e53f779d |
@@ -55,5 +55,3 @@ path = "src/bin/detect_pdf.rs"
|
||||
[[bin]]
|
||||
name = "dump_ops"
|
||||
path = "src/bin/dump_ops.rs"
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "firecrawl-pdf-inspector",
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.2",
|
||||
"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",
|
||||
|
||||
+106
-63
@@ -3,6 +3,7 @@
|
||||
use napi::bindgen_prelude::*;
|
||||
use napi_derive::napi;
|
||||
use std::collections::HashSet;
|
||||
use std::panic;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Result types
|
||||
@@ -116,6 +117,30 @@ fn to_napi_err(e: impl std::fmt::Display, ctx: &str) -> Error {
|
||||
Error::new(Status::GenericFailure, format!("{ctx}: {e}"))
|
||||
}
|
||||
|
||||
/// Run a closure, catching any Rust panic and converting it to a NAPI error.
|
||||
/// Prevents process abort from unwind panics in the native module.
|
||||
fn catch_panic<F, T>(ctx: &str, f: F) -> Result<T>
|
||||
where
|
||||
F: FnOnce() -> Result<T> + panic::UnwindSafe,
|
||||
{
|
||||
match panic::catch_unwind(f) {
|
||||
Ok(result) => result,
|
||||
Err(payload) => {
|
||||
let msg = if let Some(s) = payload.downcast_ref::<&str>() {
|
||||
s.to_string()
|
||||
} else if let Some(s) = payload.downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else {
|
||||
"unknown panic".to_string()
|
||||
};
|
||||
Err(Error::new(
|
||||
Status::GenericFailure,
|
||||
format!("{ctx}: Rust panic: {msg}"),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public NAPI API
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -123,21 +148,27 @@ fn to_napi_err(e: impl std::fmt::Display, ctx: &str) -> Error {
|
||||
/// Process a PDF from a Buffer: detect type, extract text, and convert to Markdown.
|
||||
#[napi]
|
||||
pub fn process_pdf(buffer: Buffer, pages: Option<Vec<u32>>) -> Result<PdfResult> {
|
||||
let mut opts = pdf_inspector::PdfOptions::new();
|
||||
if let Some(p) = pages {
|
||||
opts = opts.pages(p);
|
||||
}
|
||||
let result = pdf_inspector::process_pdf_mem_with_options(&buffer, opts)
|
||||
.map_err(|e| to_napi_err(e, "process_pdf"))?;
|
||||
Ok(to_napi_result(result))
|
||||
let bytes: Vec<u8> = buffer.to_vec();
|
||||
catch_panic("process_pdf", move || {
|
||||
let mut opts = pdf_inspector::PdfOptions::new();
|
||||
if let Some(p) = pages {
|
||||
opts = opts.pages(p);
|
||||
}
|
||||
let result = pdf_inspector::process_pdf_mem_with_options(&bytes, opts)
|
||||
.map_err(|e| to_napi_err(e, "process_pdf"))?;
|
||||
Ok(to_napi_result(result))
|
||||
})
|
||||
}
|
||||
|
||||
/// Fast detection only — no text extraction or markdown.
|
||||
#[napi]
|
||||
pub fn detect_pdf(buffer: Buffer) -> Result<PdfResult> {
|
||||
let result =
|
||||
pdf_inspector::detect_pdf_mem(&buffer).map_err(|e| to_napi_err(e, "detect_pdf"))?;
|
||||
Ok(to_napi_result(result))
|
||||
let bytes: Vec<u8> = buffer.to_vec();
|
||||
catch_panic("detect_pdf", move || {
|
||||
let result =
|
||||
pdf_inspector::detect_pdf_mem(&bytes).map_err(|e| to_napi_err(e, "detect_pdf"))?;
|
||||
Ok(to_napi_result(result))
|
||||
})
|
||||
}
|
||||
|
||||
/// Lightweight PDF classification — returns type, page count, and OCR pages.
|
||||
@@ -145,21 +176,27 @@ pub fn detect_pdf(buffer: Buffer) -> Result<PdfResult> {
|
||||
/// Pages in pagesNeedingOcr are 0-indexed.
|
||||
#[napi]
|
||||
pub fn classify_pdf(buffer: Buffer) -> Result<PdfClassification> {
|
||||
let result =
|
||||
pdf_inspector::classify_pdf_mem(&buffer).map_err(|e| to_napi_err(e, "classify_pdf"))?;
|
||||
|
||||
Ok(PdfClassification {
|
||||
pdf_type: pdf_type_string(result.pdf_type),
|
||||
page_count: result.page_count,
|
||||
pages_needing_ocr: result.pages_needing_ocr,
|
||||
confidence: result.confidence as f64,
|
||||
let bytes: Vec<u8> = buffer.to_vec();
|
||||
catch_panic("classify_pdf", move || {
|
||||
let result =
|
||||
pdf_inspector::classify_pdf_mem(&bytes).map_err(|e| to_napi_err(e, "classify_pdf"))?;
|
||||
Ok(PdfClassification {
|
||||
pdf_type: pdf_type_string(result.pdf_type),
|
||||
page_count: result.page_count,
|
||||
pages_needing_ocr: result.pages_needing_ocr,
|
||||
confidence: result.confidence as f64,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract plain text from a PDF Buffer.
|
||||
#[napi]
|
||||
pub fn extract_text(buffer: Buffer) -> Result<String> {
|
||||
pdf_inspector::extractor::extract_text_mem(&buffer).map_err(|e| to_napi_err(e, "extract_text"))
|
||||
let bytes: Vec<u8> = buffer.to_vec();
|
||||
catch_panic("extract_text", move || {
|
||||
pdf_inspector::extractor::extract_text_mem(&bytes)
|
||||
.map_err(|e| to_napi_err(e, "extract_text"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract text with position information from a PDF Buffer.
|
||||
@@ -168,35 +205,38 @@ pub fn extract_text_with_positions(
|
||||
buffer: Buffer,
|
||||
pages: Option<Vec<u32>>,
|
||||
) -> Result<Vec<TextItem>> {
|
||||
let items = match pages {
|
||||
Some(p) => {
|
||||
let page_set: HashSet<u32> = p.into_iter().collect();
|
||||
pdf_inspector::extractor::extract_text_with_positions_mem_pages(
|
||||
&buffer,
|
||||
Some(&page_set),
|
||||
)
|
||||
.map_err(|e| to_napi_err(e, "extract_text_with_positions"))?
|
||||
}
|
||||
None => pdf_inspector::extractor::extract_text_with_positions_mem(&buffer)
|
||||
.map_err(|e| to_napi_err(e, "extract_text_with_positions"))?,
|
||||
};
|
||||
let bytes: Vec<u8> = buffer.to_vec();
|
||||
catch_panic("extract_text_with_positions", move || {
|
||||
let items = match pages {
|
||||
Some(p) => {
|
||||
let page_set: HashSet<u32> = p.into_iter().collect();
|
||||
pdf_inspector::extractor::extract_text_with_positions_mem_pages(
|
||||
&bytes,
|
||||
Some(&page_set),
|
||||
)
|
||||
.map_err(|e| to_napi_err(e, "extract_text_with_positions"))?
|
||||
}
|
||||
None => pdf_inspector::extractor::extract_text_with_positions_mem(&bytes)
|
||||
.map_err(|e| to_napi_err(e, "extract_text_with_positions"))?,
|
||||
};
|
||||
|
||||
Ok(items
|
||||
.into_iter()
|
||||
.map(|item| TextItem {
|
||||
text: item.text,
|
||||
x: item.x as f64,
|
||||
y: item.y as f64,
|
||||
width: item.width as f64,
|
||||
height: item.height as f64,
|
||||
font: item.font,
|
||||
font_size: item.font_size as f64,
|
||||
page: item.page,
|
||||
is_bold: item.is_bold,
|
||||
is_italic: item.is_italic,
|
||||
item_type: item_type_string(&item.item_type),
|
||||
})
|
||||
.collect())
|
||||
Ok(items
|
||||
.into_iter()
|
||||
.map(|item| TextItem {
|
||||
text: item.text,
|
||||
x: item.x as f64,
|
||||
y: item.y as f64,
|
||||
width: item.width as f64,
|
||||
height: item.height as f64,
|
||||
font: item.font,
|
||||
font_size: item.font_size as f64,
|
||||
page: item.page,
|
||||
is_bold: item.is_bold,
|
||||
is_italic: item.is_italic,
|
||||
item_type: item_type_string(&item.item_type),
|
||||
})
|
||||
.collect())
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract text within bounding-box regions from a PDF.
|
||||
@@ -214,6 +254,7 @@ pub fn extract_text_in_regions(
|
||||
buffer: Buffer,
|
||||
page_regions: Vec<PageRegions>,
|
||||
) -> Result<Vec<PageRegionTexts>> {
|
||||
let bytes: Vec<u8> = buffer.to_vec();
|
||||
let regions: Vec<(u32, Vec<[f32; 4]>)> = page_regions
|
||||
.iter()
|
||||
.map(|pr| {
|
||||
@@ -232,21 +273,23 @@ pub fn extract_text_in_regions(
|
||||
})
|
||||
.collect();
|
||||
|
||||
let results = pdf_inspector::extract_text_in_regions_mem(&buffer, ®ions)
|
||||
.map_err(|e| to_napi_err(e, "extract_text_in_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(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())
|
||||
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())
|
||||
})
|
||||
}
|
||||
|
||||
+13
-26
@@ -344,11 +344,15 @@ pub fn extract_text_in_regions_mem(
|
||||
) -> Result<Vec<PageRegionResult>, PdfError> {
|
||||
validate_pdf_bytes(buffer)?;
|
||||
let (doc, _page_count) = load_document_from_mem(buffer)?;
|
||||
let font_cmaps = FontCMaps::from_doc(&doc);
|
||||
let pages = doc.get_pages();
|
||||
|
||||
// Build a set of pages we need to extract
|
||||
let needed_pages: HashSet<u32> = page_regions.iter().map(|(p, _)| p + 1).collect(); // to 1-indexed
|
||||
// Build a set of pages we need to extract (1-indexed for lopdf)
|
||||
let needed_pages: HashSet<u32> = page_regions.iter().map(|(p, _)| p + 1).collect();
|
||||
|
||||
// Fast mode: skip expensive TrueType font fallback parsing.
|
||||
// Fonts that can't be decoded from ToUnicode alone will produce empty/garbage
|
||||
// text, triggering needs_ocr=true → GPU OCR fallback in the pipeline.
|
||||
let font_cmaps = FontCMaps::from_doc_pages_fast(&doc, Some(&needed_pages));
|
||||
|
||||
// Extract text items for needed pages only
|
||||
let mut items_by_page: HashMap<u32, Vec<TextItem>> = HashMap::new();
|
||||
@@ -402,6 +406,7 @@ pub fn extract_text_in_regions_mem(
|
||||
let needs_ocr = text.trim().is_empty()
|
||||
|| page_has_gid
|
||||
|| is_garbage_text(&text)
|
||||
|| is_cid_garbage(&text)
|
||||
|| detect_encoding_issues(&text);
|
||||
|
||||
page_results.push(RegionText { text, needs_ocr });
|
||||
@@ -451,7 +456,7 @@ fn obj_to_f32(obj: &lopdf::Object) -> Option<f32> {
|
||||
|
||||
/// Collect text items that fall within a region bbox (top-left origin, PDF points)
|
||||
/// and return them as a single string in reading order.
|
||||
fn collect_text_in_region(
|
||||
pub fn collect_text_in_region(
|
||||
items: &[TextItem],
|
||||
rx1: f32,
|
||||
ry1: f32,
|
||||
@@ -478,29 +483,11 @@ fn collect_text_in_region(
|
||||
}
|
||||
|
||||
// Sort top→bottom (descending Y in bottom-left coords), then left→right.
|
||||
// Uses total_cmp to avoid panics on NaN values from bogus font metrics.
|
||||
// Uses strict total_cmp ordering to guarantee transitivity (required by
|
||||
// Rust's sort). The line-grouping phase below handles fuzzy Y matching.
|
||||
matched.sort_by(|a, b| {
|
||||
let fs_a = if a.font_size.is_finite() {
|
||||
a.font_size
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let fs_b = if b.font_size.is_finite() {
|
||||
b.font_size
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let line_threshold = fs_a.max(fs_b) * 0.5;
|
||||
let ay = if a.y.is_finite() { a.y } else { 0.0 };
|
||||
let by = if b.y.is_finite() { b.y } else { 0.0 };
|
||||
let y_diff = by - ay; // descending Y = top to bottom
|
||||
if y_diff.abs() < line_threshold {
|
||||
let ax = if a.x.is_finite() { a.x } else { 0.0 };
|
||||
let bx = if b.x.is_finite() { b.x } else { 0.0 };
|
||||
ax.total_cmp(&bx)
|
||||
} else {
|
||||
by.total_cmp(&ay)
|
||||
}
|
||||
b.y.total_cmp(&a.y) // descending Y = top to bottom
|
||||
.then(a.x.total_cmp(&b.x)) // ascending X = left to right
|
||||
});
|
||||
|
||||
// Group into lines and join
|
||||
|
||||
+81
-11
@@ -1771,15 +1771,49 @@ impl FontCMaps {
|
||||
/// Iterates every page, collects fonts (including Form XObject fonts),
|
||||
/// and parses any `/ToUnicode` streams via lopdf's decompression.
|
||||
pub fn from_doc(doc: &Document) -> Self {
|
||||
Self::from_doc_pages(doc, None)
|
||||
}
|
||||
|
||||
/// Build FontCMaps for specific pages only. Pass `None` for all pages.
|
||||
pub fn from_doc_pages(doc: &Document, page_filter: Option<&HashSet<u32>>) -> Self {
|
||||
Self::from_doc_pages_inner(doc, page_filter, false)
|
||||
}
|
||||
|
||||
/// Build FontCMaps in fast mode: skip expensive TrueType font fallback
|
||||
/// parsing. Fonts that can't be decoded from their ToUnicode CMap alone
|
||||
/// will be missing, causing text extraction to produce empty/garbage text
|
||||
/// which triggers `needs_ocr` fallback. This is ideal for hybrid OCR
|
||||
/// pipelines where GPU OCR is always available as a fallback.
|
||||
pub fn from_doc_pages_fast(doc: &Document, page_filter: Option<&HashSet<u32>>) -> Self {
|
||||
Self::from_doc_pages_inner(doc, page_filter, true)
|
||||
}
|
||||
|
||||
fn from_doc_pages_inner(
|
||||
doc: &Document,
|
||||
page_filter: Option<&HashSet<u32>>,
|
||||
skip_truetype_fallback: bool,
|
||||
) -> Self {
|
||||
let mut by_obj_num: HashMap<u32, CMapEntry> = HashMap::new();
|
||||
|
||||
for (_page_num, &page_id) in doc.get_pages().iter() {
|
||||
for (page_num, &page_id) in doc.get_pages().iter() {
|
||||
if let Some(filter) = page_filter {
|
||||
if !filter.contains(page_num) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Page-level fonts (includes inherited parent resources)
|
||||
let fonts = doc.get_page_fonts(page_id).unwrap_or_default();
|
||||
Self::collect_cmaps_from_fonts(&fonts, doc, &mut by_obj_num);
|
||||
Self::collect_cmaps_from_fonts_inner(
|
||||
&fonts,
|
||||
doc,
|
||||
&mut by_obj_num,
|
||||
skip_truetype_fallback,
|
||||
);
|
||||
|
||||
// Fonts inside Form XObjects referenced by this page
|
||||
Self::collect_cmaps_from_xobjects(doc, page_id, &mut by_obj_num);
|
||||
if !skip_truetype_fallback {
|
||||
// Fonts inside Form XObjects referenced by this page
|
||||
Self::collect_cmaps_from_xobjects(doc, page_id, &mut by_obj_num);
|
||||
}
|
||||
}
|
||||
|
||||
FontCMaps { by_obj_num }
|
||||
@@ -1792,6 +1826,15 @@ impl FontCMaps {
|
||||
fonts: &std::collections::BTreeMap<Vec<u8>, &lopdf::Dictionary>,
|
||||
doc: &Document,
|
||||
by_obj_num: &mut HashMap<u32, CMapEntry>,
|
||||
) {
|
||||
Self::collect_cmaps_from_fonts_inner(fonts, doc, by_obj_num, false);
|
||||
}
|
||||
|
||||
fn collect_cmaps_from_fonts_inner(
|
||||
fonts: &std::collections::BTreeMap<Vec<u8>, &lopdf::Dictionary>,
|
||||
doc: &Document,
|
||||
by_obj_num: &mut HashMap<u32, CMapEntry>,
|
||||
skip_truetype_fallback: bool,
|
||||
) {
|
||||
// First pass: collect ToUnicode CMaps
|
||||
for font_dict in fonts.values() {
|
||||
@@ -1825,13 +1868,32 @@ impl FontCMaps {
|
||||
);
|
||||
let (mut primary, mut remapped) =
|
||||
try_remap_subset_cmap(cmap, font_dict, doc, obj_num);
|
||||
let mut fallback = build_fallback_tounicode_from_encoding(font_dict, doc)
|
||||
.or_else(|| build_fallback_cmap_for_type0(font_dict, doc))
|
||||
.or_else(|| build_fallback_cmap_for_simple(font_dict, doc));
|
||||
|
||||
// If the ToUnicode map is extremely sparse, prefer the fallback
|
||||
// (often a better mapping for Symbol/Wingdings/Arabic CID fonts).
|
||||
// Only build expensive fallbacks when the primary CMap is sparse.
|
||||
// build_fallback_cmap_for_type0 can take seconds on large embedded
|
||||
// TrueType fonts (decompressing + parsing 100K+ byte font files).
|
||||
// Skip entirely when the primary CMap is sufficient.
|
||||
let primary_entries = primary.char_map.len() + primary.ranges.len();
|
||||
let mut fallback = if primary_entries < 10 && !skip_truetype_fallback {
|
||||
// Try cheap fallback first; only attempt expensive TrueType
|
||||
// parsing if cheap fallbacks don't yield results.
|
||||
let cheap = build_fallback_tounicode_from_encoding(font_dict, doc)
|
||||
.or_else(|| build_fallback_cmap_for_simple(font_dict, doc));
|
||||
if cheap.is_some() {
|
||||
cheap
|
||||
} else {
|
||||
build_fallback_cmap_for_type0(font_dict, doc)
|
||||
}
|
||||
} else if primary_entries < 10 {
|
||||
// Fast mode: only try cheap fallbacks, skip TrueType parsing.
|
||||
// Regions using this font will get needs_ocr=true.
|
||||
build_fallback_tounicode_from_encoding(font_dict, doc)
|
||||
.or_else(|| build_fallback_cmap_for_simple(font_dict, doc))
|
||||
} else {
|
||||
// Primary is rich enough; only try the cheap encoding fallback
|
||||
build_fallback_tounicode_from_encoding(font_dict, doc)
|
||||
};
|
||||
|
||||
if primary_entries < 10 {
|
||||
if let Some(fb) = fallback.take() {
|
||||
debug!(
|
||||
@@ -1852,8 +1914,12 @@ impl FontCMaps {
|
||||
);
|
||||
} else {
|
||||
// ToUnicode present but parse failed; try fallbacks to avoid empty decoding.
|
||||
let fallback = build_fallback_cmap_for_type0(font_dict, doc)
|
||||
.or_else(|| build_fallback_cmap_for_simple(font_dict, doc));
|
||||
let fallback = if skip_truetype_fallback {
|
||||
build_fallback_cmap_for_simple(font_dict, doc)
|
||||
} else {
|
||||
build_fallback_cmap_for_type0(font_dict, doc)
|
||||
.or_else(|| build_fallback_cmap_for_simple(font_dict, doc))
|
||||
};
|
||||
if let Some(fb) = fallback {
|
||||
debug!(
|
||||
"ToUnicode CMap obj={} parse failed; using fallback (entries={})",
|
||||
@@ -1874,6 +1940,10 @@ impl FontCMaps {
|
||||
|
||||
// Second pass: Identity-H/V fonts without ToUnicode
|
||||
// Try: (1) embedded TrueType/OpenType cmap, (2) predefined CID→Unicode mapping
|
||||
// Skip entirely in fast mode — these fonts require expensive TrueType parsing.
|
||||
if skip_truetype_fallback {
|
||||
return;
|
||||
}
|
||||
for font_dict in fonts.values() {
|
||||
if font_dict.get(b"ToUnicode").is_ok() {
|
||||
continue;
|
||||
|
||||
+190
-2
@@ -4,9 +4,11 @@ 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_with_positions, process_pdf_with_options,
|
||||
to_markdown, MarkdownOptions, PdfError, PdfOptions, PdfType, TextItem,
|
||||
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,
|
||||
};
|
||||
use std::collections::HashSet;
|
||||
|
||||
// Helper to create test TextItems
|
||||
fn make_text_item(text: &str, x: f32, y: f32, font_size: f32, page: u32) -> TextItem {
|
||||
@@ -1107,3 +1109,189 @@ fn test_rotated_table_layout_correction() {
|
||||
"District data should be in a markdown table row"
|
||||
);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// extract_text_in_regions_mem tests
|
||||
// =========================================================================
|
||||
|
||||
/// Build full-page region args for `page_count` pages.
|
||||
/// Uses a generously large bbox (1200x1200) to capture any page size.
|
||||
fn full_page_regions(page_count: u32) -> Vec<(u32, Vec<[f32; 4]>)> {
|
||||
(0..page_count)
|
||||
.map(|p| (p, vec![[0.0, 0.0, 1200.0, 1200.0]]))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Normalize text for comparison: lowercase, strip non-alphanumeric, split into words.
|
||||
fn normalize_words(text: &str) -> HashSet<String> {
|
||||
text.split(|c: char| !c.is_alphanumeric())
|
||||
.map(|w| w.to_lowercase())
|
||||
.filter(|w| w.len() > 3)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Fraction of normalized words in `a` that also appear in `b`.
|
||||
fn word_overlap_ratio(a: &str, b: &str) -> f64 {
|
||||
let words_a = normalize_words(a);
|
||||
if words_a.is_empty() {
|
||||
return if normalize_words(b).is_empty() {
|
||||
1.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
}
|
||||
let words_b = normalize_words(b);
|
||||
let overlap = words_a.intersection(&words_b).count();
|
||||
overlap as f64 / words_a.len() as f64
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_regions_mem_basic_text_pdf() {
|
||||
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
||||
let result = process_pdf_mem(&buf).unwrap();
|
||||
let page_count = result.page_count;
|
||||
|
||||
let regions = extract_text_in_regions_mem(&buf, &full_page_regions(page_count)).unwrap();
|
||||
assert_eq!(regions.len(), page_count as usize);
|
||||
|
||||
// Each result should have exactly 1 region (we passed one per page)
|
||||
for r in ®ions {
|
||||
assert_eq!(r.regions.len(), 1);
|
||||
}
|
||||
|
||||
// First page should have non-empty text
|
||||
let first = ®ions[0].regions[0];
|
||||
assert!(!first.text.trim().is_empty(), "First page should have text");
|
||||
assert_eq!(regions[0].page, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_regions_mem_identity_h_needs_ocr() {
|
||||
let buf = std::fs::read("tests/fixtures/shinagawa_identity_h.pdf").unwrap();
|
||||
let regions =
|
||||
extract_text_in_regions_mem(&buf, &[(0, vec![[0.0, 0.0, 1200.0, 1200.0]])]).unwrap();
|
||||
assert_eq!(regions.len(), 1);
|
||||
assert!(
|
||||
regions[0].regions[0].needs_ocr,
|
||||
"Identity-H font without ToUnicode should trigger needs_ocr"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_regions_mem_multiple_regions_per_page() {
|
||||
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
||||
let regions = extract_text_in_regions_mem(
|
||||
&buf,
|
||||
&[(
|
||||
0,
|
||||
vec![
|
||||
[0.0, 0.0, 300.0, 100.0], // small top-left
|
||||
[0.0, 0.0, 1200.0, 1200.0], // full page
|
||||
],
|
||||
)],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(regions.len(), 1);
|
||||
assert_eq!(regions[0].regions.len(), 2);
|
||||
|
||||
let small_len = regions[0].regions[0].text.len();
|
||||
let full_len = regions[0].regions[1].text.len();
|
||||
assert!(
|
||||
full_len >= small_len,
|
||||
"Full-page region ({full_len}) should have at least as much text as small region ({small_len})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_regions_mem_nonexistent_page() {
|
||||
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
||||
let regions =
|
||||
extract_text_in_regions_mem(&buf, &[(9999, vec![[0.0, 0.0, 1200.0, 1200.0]])]).unwrap();
|
||||
assert_eq!(regions.len(), 1);
|
||||
assert!(
|
||||
regions[0].regions[0].needs_ocr,
|
||||
"Nonexistent page should trigger needs_ocr"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_regions_mem_empty_region() {
|
||||
let buf = std::fs::read("tests/fixtures/nexo-price-en.pdf").unwrap();
|
||||
let regions = extract_text_in_regions_mem(&buf, &[(0, vec![[0.0, 0.0, 0.0, 0.0]])]).unwrap();
|
||||
assert_eq!(regions.len(), 1);
|
||||
assert!(
|
||||
regions[0].regions[0].needs_ocr,
|
||||
"Zero-area region should trigger needs_ocr"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_extract_regions_mem_not_a_pdf() {
|
||||
let result = extract_text_in_regions_mem(b"not a pdf", &[(0, vec![[0.0, 0.0, 100.0, 100.0]])]);
|
||||
assert!(result.is_err(), "Non-PDF input should return an error");
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Fast vs normal extraction comparison
|
||||
// =========================================================================
|
||||
|
||||
/// For each text-based fixture PDF, compare `extract_text_in_regions_mem` (fast path)
|
||||
/// against `process_pdf_mem` (normal path). If the fast path claims needs_ocr=false
|
||||
/// for a page, verify the extracted text has meaningful overlap with the normal
|
||||
/// markdown output — catching silent quality regressions.
|
||||
#[test]
|
||||
fn test_extract_regions_fast_vs_normal_comparison() {
|
||||
let fixtures = [
|
||||
"tests/fixtures/nexo-price-en.pdf",
|
||||
"tests/fixtures/td9264.pdf",
|
||||
"tests/fixtures/p1244-1996.pdf",
|
||||
"tests/fixtures/real-estate-pricing.pdf",
|
||||
"tests/fixtures/2013-app2.pdf",
|
||||
"tests/fixtures/firecrawl_docs_tagged.pdf",
|
||||
"tests/fixtures/thermo-freon12.pdf",
|
||||
];
|
||||
|
||||
for fixture in &fixtures {
|
||||
let buf = std::fs::read(fixture).unwrap();
|
||||
let normal = process_pdf_mem(&buf).unwrap();
|
||||
let normal_md = normal.markdown.as_deref().unwrap_or("");
|
||||
let page_count = normal.page_count;
|
||||
let ocr_pages: HashSet<u32> = normal.pages_needing_ocr.iter().copied().collect();
|
||||
|
||||
let regions = extract_text_in_regions_mem(&buf, &full_page_regions(page_count)).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
regions.len(),
|
||||
page_count as usize,
|
||||
"{fixture}: result count should match page count"
|
||||
);
|
||||
|
||||
for pr in ®ions {
|
||||
let region = &pr.regions[0];
|
||||
if !region.needs_ocr && !region.text.trim().is_empty() {
|
||||
// Fast path claims this text is trustworthy.
|
||||
// Check that its words appear in the normal markdown output.
|
||||
let overlap = word_overlap_ratio(®ion.text, normal_md);
|
||||
assert!(
|
||||
overlap >= 0.3,
|
||||
"{fixture} page {}: fast path says needs_ocr=false but only {:.0}% word \
|
||||
overlap with normal extraction (threshold 30%). \
|
||||
Fast text sample: {:?}",
|
||||
pr.page,
|
||||
overlap * 100.0,
|
||||
®ion.text[..region.text.len().min(200)],
|
||||
);
|
||||
}
|
||||
|
||||
// If fast path flags needs_ocr but normal path didn't, that's overly
|
||||
// conservative but not a bug — just worth knowing.
|
||||
if region.needs_ocr && !ocr_pages.contains(&(pr.page + 1)) {
|
||||
eprintln!(
|
||||
"INFO: {fixture} page {}: fast path says needs_ocr=true but normal path extracted fine (conservative, not a bug)",
|
||||
pr.page,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user