Compare commits

..
7 Commits
Author SHA1 Message Date
Abimael Martell ebb01ab6a3 Merge pull request #22 from firecrawl/perf/skip-truetype-fallback-in-region-extract
Publish npm package / Build aarch64-apple-darwin (push) Has been cancelled
Publish npm package / Build x86_64-unknown-linux-gnu (push) Has been cancelled
Publish npm package / Publish to npm (push) Has been cancelled
perf: skip TrueType fallback in extract_text_in_regions (22,000x faster font parsing)
2026-04-02 18:14:05 -07:00
Abimael MartellandClaude Opus 4.6 53eff94147 Bump napi package version to 0.3.2
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 18:13:20 -07:00
Abimael MartellandClaude Opus 4.6 30244078d8 fix: sort panic and missing CID garbage check in extract_text_in_regions_mem
Two bugs in collect_text_in_region / extract_text_in_regions_mem:

1. The threshold-based sort comparator in collect_text_in_region was not
   transitive, causing Rust's sort to panic on certain PDFs. Replaced with
   strict total_cmp ordering — the line-grouping phase already handles
   fuzzy Y matching via threshold.

2. The needs_ocr check was missing is_cid_garbage, so Identity-H fonts
   with CID garbage (C1 control chars, high Latin mojibake) could pass
   all quality checks and be served as real text with needs_ocr=false.

Also adds 7 integration tests for extract_text_in_regions_mem (previously
had zero coverage): basic extraction, Identity-H needs_ocr, multiple
regions, nonexistent page, empty region, invalid input, and a fast-vs-normal
comparison test across all text-based fixtures.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 18:13:11 -07:00
Abimael MartellandClaude Opus 4.6 57673ebb69 perf: skip expensive TrueType fallback in extract_text_in_regions
FontCMaps::from_doc can spend 4.5+ seconds decompressing and parsing
large embedded TrueType fonts for CID fonts with sparse ToUnicode
CMaps. For extract_text_in_regions (hybrid OCR pipeline), this is
unnecessary — fonts that can't be decoded cheaply will produce
empty/garbage text, triggering needs_ocr=true and GPU OCR fallback.

Changes:
- Add FontCMaps::from_doc_pages_fast() that skips TrueType font
  fallback parsing (build_fallback_cmap_for_type0) and Identity-H/V
  second pass entirely
- Add FontCMaps::from_doc_pages() for filtered page sets
- extract_text_in_regions_mem uses fast mode
- Restructure fallback chain: try cheap fallbacks first, only attempt
  expensive TrueType parsing when needed and not in fast mode

Benchmark on nihms-1771367.pdf (19-page chemistry paper):
- FontCMaps fast:  201µs
- FontCMaps slow:  4.47s
- 22,000x speedup on font parsing

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 18:13:11 -07:00
Abimael MartellandClaude Opus 4.6 2d1c6f9ff8 Bump napi package version to 0.3.1
Publish npm package / Build aarch64-apple-darwin (push) Has been cancelled
Publish npm package / Build x86_64-unknown-linux-gnu (push) Has been cancelled
Publish npm package / Publish to npm (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-02 15:12:47 -07:00
Abimael Martell 55b32d542e Merge pull request #21 from firecrawl/fix/catch-unwind-napi-panics
fix: catch_unwind in NAPI layer to prevent process abort on panic
2026-04-02 15:12:28 -07:00
Abimael MartellandClaude Opus 4.6 79e53f779d wrap all NAPI functions in catch_unwind to prevent process abort on panic
Rust panics in NAPI modules abort the Node.js process with no chance
to report errors. This wraps every exported function in catch_unwind,
converting panics into JS Error exceptions that can be caught and
reported to Sentry.

Buffer data is extracted to Vec<u8> before the catch_unwind boundary
to satisfy UnwindSafe requirements.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:10:15 -07:00
6 changed files with 391 additions and 105 deletions
-2
View File
@@ -55,5 +55,3 @@ path = "src/bin/detect_pdf.rs"
[[bin]]
name = "dump_ops"
path = "src/bin/dump_ops.rs"
+1 -1
View File
@@ -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
View File
@@ -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, &regions)
.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, &regions)
.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
View File
@@ -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
View File
@@ -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
View File
@@ -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 &regions {
assert_eq!(r.regions.len(), 1);
}
// First page should have non-empty text
let first = &regions[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 &regions {
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(&region.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,
&region.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,
);
}
}
}
}