fix(extractor): flag garbled text spans for OCR (#108)
* fix(extractor): flag garbled text spans for OCR * fix(extractor): apply text quality checks to regions * chore(napi): bump npm package version
This commit is contained in:
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "pdf-inspector"
|
name = "pdf-inspector"
|
||||||
version = "0.1.1"
|
version = "0.1.2"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
autobins = false
|
autobins = false
|
||||||
authors = ["Firecrawl Team"]
|
authors = ["Firecrawl Team"]
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@firecrawl/pdf-inspector",
|
"name": "@firecrawl/pdf-inspector",
|
||||||
"version": "1.9.6",
|
"version": "1.9.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",
|
||||||
|
|||||||
+228
-26
@@ -368,6 +368,7 @@ pub fn extract_pages_markdown_mem(
|
|||||||
// 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)?;
|
||||||
|
let text_quality = analyze_text_quality(&all_items);
|
||||||
|
|
||||||
// Compute layout complexity from full document (near-zero cost).
|
// Compute layout complexity from full document (near-zero cost).
|
||||||
let complexity = compute_layout_complexity(&all_items, &all_rects, &all_lines);
|
let complexity = compute_layout_complexity(&all_items, &all_rects, &all_lines);
|
||||||
@@ -416,6 +417,7 @@ pub fn extract_pages_markdown_mem(
|
|||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let has_gid = gid_pages.contains(&page_1idx);
|
let has_gid = gid_pages.contains(&page_1idx);
|
||||||
|
let has_text_quality_issue = text_quality.pages_needing_ocr.contains(&page_1idx);
|
||||||
|
|
||||||
// Build markdown with document-wide font stats
|
// Build markdown with document-wide font stats
|
||||||
let options = MarkdownOptions {
|
let options = MarkdownOptions {
|
||||||
@@ -425,17 +427,22 @@ pub fn extract_pages_markdown_mem(
|
|||||||
..MarkdownOptions::default()
|
..MarkdownOptions::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let md = markdown::to_markdown_from_items_with_rects_and_lines(
|
let md = if has_text_quality_issue {
|
||||||
page_items,
|
String::new()
|
||||||
options,
|
} else {
|
||||||
&page_rects,
|
markdown::to_markdown_from_items_with_rects_and_lines(
|
||||||
&[],
|
page_items,
|
||||||
&page_thresholds,
|
options,
|
||||||
None,
|
&page_rects,
|
||||||
&[],
|
&[],
|
||||||
);
|
&page_thresholds,
|
||||||
|
None,
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
let needs_ocr = md.trim().is_empty()
|
let needs_ocr = has_text_quality_issue
|
||||||
|
|| md.trim().is_empty()
|
||||||
|| has_gid
|
|| has_gid
|
||||||
|| is_garbage_text(&md)
|
|| is_garbage_text(&md)
|
||||||
|| is_cid_garbage(&md)
|
|| is_cid_garbage(&md)
|
||||||
@@ -592,24 +599,23 @@ pub fn extract_text_in_regions_mem(
|
|||||||
for rect in regions {
|
for rect in regions {
|
||||||
let [rx1, ry1, rx2, ry2] = *rect;
|
let [rx1, ry1, rx2, ry2] = *rect;
|
||||||
|
|
||||||
let text = match items {
|
let bounds = region_bounds(rx1, ry1, rx2, ry2, page_h, coords);
|
||||||
Some(items) => collect_text_in_region_with_options(
|
let matched: Vec<TextItem> = match items {
|
||||||
items,
|
Some(items) => items
|
||||||
rx1,
|
.iter()
|
||||||
ry1,
|
.filter(|item| region_overlaps_item(item, bounds))
|
||||||
rx2,
|
.cloned()
|
||||||
ry2,
|
.collect(),
|
||||||
page_h,
|
None => Vec::new(),
|
||||||
coords,
|
|
||||||
adaptive_threshold,
|
|
||||||
),
|
|
||||||
None => String::new(),
|
|
||||||
};
|
};
|
||||||
|
let has_text_quality_issue = region_items_have_decoding_issue(&matched);
|
||||||
|
let text = collect_text_from_matched_items(matched, adaptive_threshold);
|
||||||
|
|
||||||
// Check per-region text quality instead of blanket page-level
|
// Check per-region text quality instead of blanket page-level
|
||||||
// GID rejection. A GID font in a logo elsewhere on the page
|
// GID rejection. A GID font in a logo elsewhere on the page
|
||||||
// shouldn't force GPU OCR for clean text regions.
|
// shouldn't force GPU OCR for clean text regions.
|
||||||
let needs_ocr = text.trim().is_empty()
|
let needs_ocr = has_text_quality_issue
|
||||||
|
|| text.trim().is_empty()
|
||||||
|| is_garbage_text(&text)
|
|| is_garbage_text(&text)
|
||||||
|| is_cid_garbage(&text)
|
|| is_cid_garbage(&text)
|
||||||
|| detect_encoding_issues(&text);
|
|| detect_encoding_issues(&text);
|
||||||
@@ -729,6 +735,14 @@ pub fn extract_tables_in_regions_mem(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if region_items_have_decoding_issue(&matched) {
|
||||||
|
page_results.push(RegionText {
|
||||||
|
text: String::new(),
|
||||||
|
needs_ocr: true,
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// Compute base_font_size as most common font size in the region
|
// Compute base_font_size as most common font size in the region
|
||||||
let base_font_size = {
|
let base_font_size = {
|
||||||
let mut freq: HashMap<i32, usize> = HashMap::new();
|
let mut freq: HashMap<i32, usize> = HashMap::new();
|
||||||
@@ -3401,7 +3415,7 @@ fn process_document(
|
|||||||
})
|
})
|
||||||
.unwrap_or((None, Vec::new()));
|
.unwrap_or((None, Vec::new()));
|
||||||
|
|
||||||
let (markdown, layout, has_encoding_issues, gid_pages) = match extracted {
|
let (markdown, layout, has_encoding_issues, gid_pages, text_quality_pages) = match extracted {
|
||||||
Some(((items, rects, lines), page_thresholds, gid_encoded_pages)) => {
|
Some(((items, rects, lines), page_thresholds, gid_encoded_pages)) => {
|
||||||
// For TextBased PDFs with pages flagged for OCR (Identity-H or
|
// For TextBased PDFs with pages flagged for OCR (Identity-H or
|
||||||
// Type3 fonts without ToUnicode), check whether the CID-as-Unicode
|
// Type3 fonts without ToUnicode), check whether the CID-as-Unicode
|
||||||
@@ -3451,6 +3465,7 @@ fn process_document(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let text_quality = analyze_text_quality(&items);
|
||||||
let layout = compute_layout_complexity(&items, &rects, &lines);
|
let layout = compute_layout_complexity(&items, &rects, &lines);
|
||||||
|
|
||||||
let md = if options.mode == ProcessMode::Analyze {
|
let md = if options.mode == ProcessMode::Analyze {
|
||||||
@@ -3467,14 +3482,22 @@ fn process_document(
|
|||||||
))
|
))
|
||||||
};
|
};
|
||||||
|
|
||||||
let enc = md.as_ref().is_some_and(|m| detect_encoding_issues(m));
|
let enc = text_quality.has_encoding_issues
|
||||||
(md, layout, enc, gid_encoded_pages)
|
|| md.as_ref().is_some_and(|m| detect_encoding_issues(m));
|
||||||
|
(
|
||||||
|
md,
|
||||||
|
layout,
|
||||||
|
enc,
|
||||||
|
gid_encoded_pages,
|
||||||
|
text_quality.pages_needing_ocr,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
None => (
|
None => (
|
||||||
None,
|
None,
|
||||||
LayoutComplexity::default(),
|
LayoutComplexity::default(),
|
||||||
false,
|
false,
|
||||||
std::collections::HashSet::new(),
|
std::collections::HashSet::new(),
|
||||||
|
Vec::new(),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -3516,6 +3539,18 @@ fn process_document(
|
|||||||
}
|
}
|
||||||
pages_needing_ocr.sort_unstable();
|
pages_needing_ocr.sort_unstable();
|
||||||
}
|
}
|
||||||
|
if !text_quality_pages.is_empty() {
|
||||||
|
log::debug!(
|
||||||
|
"pages with suspicious text-layer decoding (need OCR): {:?}",
|
||||||
|
text_quality_pages
|
||||||
|
);
|
||||||
|
for page in text_quality_pages {
|
||||||
|
if !pages_needing_ocr.contains(&page) {
|
||||||
|
pages_needing_ocr.push(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pages_needing_ocr.sort_unstable();
|
||||||
|
}
|
||||||
|
|
||||||
// Detect sparse extraction: when a TEXT-BASED PDF produces very few
|
// Detect sparse extraction: when a TEXT-BASED PDF produces very few
|
||||||
// characters per page, the text is likely embedded in images/forms
|
// characters per page, the text is likely embedded in images/forms
|
||||||
@@ -3601,6 +3636,104 @@ fn detect_encoding_issues(markdown: &str) -> bool {
|
|||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct TextQualityReport {
|
||||||
|
pages_needing_ocr: Vec<u32>,
|
||||||
|
has_encoding_issues: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn analyze_text_quality(items: &[TextItem]) -> TextQualityReport {
|
||||||
|
let mut pages = HashSet::new();
|
||||||
|
|
||||||
|
for item in items {
|
||||||
|
if !matches!(item.item_type, crate::types::ItemType::Text) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if text_span_has_decoding_issue(&item.text) {
|
||||||
|
pages.insert(item.page);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut pages_needing_ocr: Vec<u32> = pages.into_iter().collect();
|
||||||
|
pages_needing_ocr.sort_unstable();
|
||||||
|
TextQualityReport {
|
||||||
|
has_encoding_issues: !pages_needing_ocr.is_empty(),
|
||||||
|
pages_needing_ocr,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn region_items_have_decoding_issue(items: &[TextItem]) -> bool {
|
||||||
|
items.iter().any(|item| {
|
||||||
|
matches!(item.item_type, crate::types::ItemType::Text)
|
||||||
|
&& text_span_has_decoding_issue(&item.text)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn text_span_has_decoding_issue(text: &str) -> bool {
|
||||||
|
let text = text.trim();
|
||||||
|
if text.is_empty() {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_encoding_issues(text)
|
||||||
|
|| has_private_use_text_run(text)
|
||||||
|
|| is_cid_garbage(text)
|
||||||
|
|| has_cid_control_token(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_private_use_text_run(text: &str) -> bool {
|
||||||
|
let mut total = 0usize;
|
||||||
|
let mut private_use = 0usize;
|
||||||
|
let mut current_run = 0usize;
|
||||||
|
let mut longest_run = 0usize;
|
||||||
|
|
||||||
|
for ch in text.chars() {
|
||||||
|
if ch.is_whitespace() {
|
||||||
|
current_run = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
total += 1;
|
||||||
|
if is_private_use_char(ch) {
|
||||||
|
private_use += 1;
|
||||||
|
current_run += 1;
|
||||||
|
longest_run = longest_run.max(current_run);
|
||||||
|
} else {
|
||||||
|
current_run = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if private_use == 0 {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
longest_run >= 3 || (total >= 5 && private_use >= 2 && private_use * 2 >= total)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_cid_control_token(text: &str) -> bool {
|
||||||
|
text.split_whitespace().any(token_has_cid_control)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn token_has_cid_control(token: &str) -> bool {
|
||||||
|
let mut total = 0usize;
|
||||||
|
let mut c1_control = 0usize;
|
||||||
|
|
||||||
|
for ch in token.chars() {
|
||||||
|
total += 1;
|
||||||
|
if ('\u{0080}'..='\u{009F}').contains(&ch) {
|
||||||
|
c1_control += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
total >= 5 && c1_control > 0 && c1_control * 20 >= total
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_private_use_char(ch: char) -> bool {
|
||||||
|
matches!(
|
||||||
|
ch as u32,
|
||||||
|
0xE000..=0xF8FF | 0xF0000..=0xFFFFD | 0x100000..=0x10FFFD
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if extracted text is predominantly garbage (non-alphanumeric).
|
/// Check if extracted text is predominantly garbage (non-alphanumeric).
|
||||||
///
|
///
|
||||||
/// Broken font encodings produce text like "----1-.-.-.___ --.-. .._ I_---."
|
/// Broken font encodings produce text like "----1-.-.-.___ --.-. .._ I_---."
|
||||||
@@ -5543,6 +5676,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn test_text_item_on_page(page: u32, text: &str) -> TextItem {
|
||||||
|
TextItem {
|
||||||
|
page,
|
||||||
|
..test_item(text, 10.0, 10.0, text.len() as f32 * 5.0, 12.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_detect_encoding_issues_fffd() {
|
fn test_detect_encoding_issues_fffd() {
|
||||||
assert!(detect_encoding_issues(
|
assert!(detect_encoding_issues(
|
||||||
@@ -5578,6 +5718,68 @@ mod tests {
|
|||||||
assert!(!detect_encoding_issues(text));
|
assert!(!detect_encoding_issues(text));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_text_quality_flags_localized_cid_mojibake_span() {
|
||||||
|
let items = vec![
|
||||||
|
test_text_item_on_page(
|
||||||
|
1,
|
||||||
|
"Waiting Period 等待期 Maternity and newborn infant care benefit",
|
||||||
|
),
|
||||||
|
test_text_item_on_page(
|
||||||
|
1,
|
||||||
|
"Inpatient and Day-care Benefits DÂB\u{009B}A4gÉ9¶0ÅDÂB\u{009B}Ê(D>öBÑ9¯",
|
||||||
|
),
|
||||||
|
test_text_item_on_page(1, "Covered up to annual maximum. 赔付至年度最高保额。"),
|
||||||
|
test_text_item_on_page(2, "A clean second page should not be routed to OCR."),
|
||||||
|
];
|
||||||
|
|
||||||
|
let quality = analyze_text_quality(&items);
|
||||||
|
|
||||||
|
assert!(quality.has_encoding_issues);
|
||||||
|
assert_eq!(quality.pages_needing_ocr, vec![1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_text_quality_flags_replacement_and_private_use_runs() {
|
||||||
|
let items = vec![
|
||||||
|
test_text_item_on_page(1, "broken \u{FFFD} text"),
|
||||||
|
test_text_item_on_page(3, "\u{E000}\u{E001}\u{E002}"),
|
||||||
|
];
|
||||||
|
|
||||||
|
let quality = analyze_text_quality(&items);
|
||||||
|
|
||||||
|
assert_eq!(quality.pages_needing_ocr, vec![1, 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_text_quality_allows_clean_multilingual_and_latin1_text() {
|
||||||
|
let items = vec![
|
||||||
|
test_text_item_on_page(1, "你好世界,这是一段正常的中文文本。"),
|
||||||
|
test_text_item_on_page(1, "Résumé déjà vu: façade, São Paulo, año 2026."),
|
||||||
|
test_text_item_on_page(1, "A single icon \u{E000} should not force OCR."),
|
||||||
|
];
|
||||||
|
|
||||||
|
let quality = analyze_text_quality(&items);
|
||||||
|
|
||||||
|
assert!(!quality.has_encoding_issues);
|
||||||
|
assert!(quality.pages_needing_ocr.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_region_text_quality_is_scoped_to_matched_items() {
|
||||||
|
let clean_region = vec![
|
||||||
|
test_text_item_on_page(1, "Clean native text"),
|
||||||
|
test_text_item_on_page(1, "Résumé déjà vu"),
|
||||||
|
];
|
||||||
|
let garbled_region = vec![
|
||||||
|
test_text_item_on_page(1, "Clean prefix"),
|
||||||
|
test_text_item_on_page(1, "DÂB\u{009B}A4gÉ9¶0ÅDÂB\u{009B}Ê(D>öBÑ9¯"),
|
||||||
|
];
|
||||||
|
|
||||||
|
assert!(!region_items_have_decoding_issue(&clean_region));
|
||||||
|
assert!(region_items_have_decoding_issue(&garbled_region));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_garbage_text_detection() {
|
fn test_garbage_text_detection() {
|
||||||
// Simulates garbage output from Identity-H fonts without ToUnicode.
|
// Simulates garbage output from Identity-H fonts without ToUnicode.
|
||||||
|
|||||||
Reference in New Issue
Block a user