feat(detector): Add per-page OCR routing with pages_needing_ocr field
For mixed PDFs, callers can now see exactly which pages need OCR instead of re-analyzing the document. Phase 2 scan iterates all pages for Mixed PDFs (caching sampled results), while TextBased gets empty and Scanned/ImageBased gets all pages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
d8f341bafc
commit
c815de9844
+17
-1
@@ -24,8 +24,13 @@ fn main() {
|
|||||||
let elapsed = start.elapsed();
|
let elapsed = start.elapsed();
|
||||||
|
|
||||||
if json_output {
|
if json_output {
|
||||||
|
let ocr_pages: Vec<String> = result
|
||||||
|
.pages_needing_ocr
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.to_string())
|
||||||
|
.collect();
|
||||||
println!(
|
println!(
|
||||||
r#"{{"pdf_type":"{}","page_count":{},"pages_sampled":{},"pages_with_text":{},"confidence":{:.2},"title":{},"ocr_recommended":{},"detection_time_ms":{}}}"#,
|
r#"{{"pdf_type":"{}","page_count":{},"pages_sampled":{},"pages_with_text":{},"confidence":{:.2},"title":{},"ocr_recommended":{},"pages_needing_ocr":[{}],"detection_time_ms":{}}}"#,
|
||||||
match result.pdf_type {
|
match result.pdf_type {
|
||||||
PdfType::TextBased => "text_based",
|
PdfType::TextBased => "text_based",
|
||||||
PdfType::Scanned => "scanned",
|
PdfType::Scanned => "scanned",
|
||||||
@@ -42,6 +47,7 @@ fn main() {
|
|||||||
.map(|t| format!("\"{}\"", t.replace('"', "\\\"")))
|
.map(|t| format!("\"{}\"", t.replace('"', "\\\"")))
|
||||||
.unwrap_or_else(|| "null".to_string()),
|
.unwrap_or_else(|| "null".to_string()),
|
||||||
result.ocr_recommended,
|
result.ocr_recommended,
|
||||||
|
ocr_pages.join(","),
|
||||||
elapsed.as_millis()
|
elapsed.as_millis()
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
@@ -67,6 +73,16 @@ fn main() {
|
|||||||
"OCR recommended: {}",
|
"OCR recommended: {}",
|
||||||
if result.ocr_recommended { "YES" } else { "NO" }
|
if result.ocr_recommended { "YES" } else { "NO" }
|
||||||
);
|
);
|
||||||
|
if !result.pages_needing_ocr.is_empty() {
|
||||||
|
if result.pages_needing_ocr.len() == result.page_count as usize {
|
||||||
|
println!("Pages needing OCR: all (of {})", result.page_count);
|
||||||
|
} else {
|
||||||
|
println!(
|
||||||
|
"Pages needing OCR: {:?} (of {})",
|
||||||
|
result.pages_needing_ocr, result.page_count
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
if let Some(title) = &result.title {
|
if let Some(title) = &result.title {
|
||||||
println!("Title: {}", title);
|
println!("Title: {}", title);
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -43,8 +43,13 @@ fn main() {
|
|||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let ocr_pages: Vec<String> = result
|
||||||
|
.pages_needing_ocr
|
||||||
|
.iter()
|
||||||
|
.map(|p| p.to_string())
|
||||||
|
.collect();
|
||||||
println!(
|
println!(
|
||||||
r#"{{"pdf_type":"{}","page_count":{},"has_text":{},"processing_time_ms":{},"markdown_length":{},"markdown":"{}"}}"#,
|
r#"{{"pdf_type":"{}","page_count":{},"has_text":{},"processing_time_ms":{},"markdown_length":{},"pages_needing_ocr":[{}],"markdown":"{}"}}"#,
|
||||||
match result.pdf_type {
|
match result.pdf_type {
|
||||||
PdfType::TextBased => "text_based",
|
PdfType::TextBased => "text_based",
|
||||||
PdfType::Scanned => "scanned",
|
PdfType::Scanned => "scanned",
|
||||||
@@ -55,6 +60,7 @@ fn main() {
|
|||||||
result.text.is_some(),
|
result.text.is_some(),
|
||||||
result.processing_time_ms,
|
result.processing_time_ms,
|
||||||
result.markdown.as_ref().map(|m| m.len()).unwrap_or(0),
|
result.markdown.as_ref().map(|m| m.len()).unwrap_or(0),
|
||||||
|
ocr_pages.join(","),
|
||||||
md_escaped
|
md_escaped
|
||||||
);
|
);
|
||||||
} else if raw_output {
|
} else if raw_output {
|
||||||
@@ -120,7 +126,11 @@ fn main() {
|
|||||||
|
|
||||||
if let Some(markdown) = &result.markdown {
|
if let Some(markdown) = &result.markdown {
|
||||||
eprintln!();
|
eprintln!();
|
||||||
|
if result.pages_needing_ocr.is_empty() {
|
||||||
eprintln!("Note: Some pages may contain images that require OCR.");
|
eprintln!("Note: Some pages may contain images that require OCR.");
|
||||||
|
} else {
|
||||||
|
eprintln!("Pages needing OCR: {:?}", result.pages_needing_ocr);
|
||||||
|
}
|
||||||
eprintln!();
|
eprintln!();
|
||||||
|
|
||||||
if let Some(output) = output_file {
|
if let Some(output) = output_file {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
use crate::PdfError;
|
use crate::PdfError;
|
||||||
use lopdf::{Document, Object, ObjectId};
|
use lopdf::{Document, Object, ObjectId};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
|
|
||||||
/// PDF type classification
|
/// PDF type classification
|
||||||
@@ -39,6 +40,9 @@ pub struct PdfTypeResult {
|
|||||||
/// Whether OCR is recommended for better extraction
|
/// Whether OCR is recommended for better extraction
|
||||||
/// True when images provide essential context (e.g., template-based PDFs)
|
/// True when images provide essential context (e.g., template-based PDFs)
|
||||||
pub ocr_recommended: bool,
|
pub ocr_recommended: bool,
|
||||||
|
/// 1-indexed page numbers that need OCR (image-only or insufficient text).
|
||||||
|
/// Empty for TextBased. All pages for Scanned/ImageBased. Specific pages for Mixed.
|
||||||
|
pub pages_needing_ocr: Vec<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Configuration for PDF type detection
|
/// Configuration for PDF type detection
|
||||||
@@ -150,6 +154,9 @@ fn detect_from_document(
|
|||||||
let mut pages_with_template_images = 0u32;
|
let mut pages_with_template_images = 0u32;
|
||||||
let mut total_text_ops = 0u32;
|
let mut total_text_ops = 0u32;
|
||||||
|
|
||||||
|
// Cache Phase 1 results to avoid re-analyzing sampled pages in Phase 2
|
||||||
|
let mut analysis_cache: HashMap<u32, PageAnalysis> = HashMap::new();
|
||||||
|
|
||||||
for page_num in &sample_indices {
|
for page_num in &sample_indices {
|
||||||
if let Some(&page_id) = pages.get(page_num) {
|
if let Some(&page_id) = pages.get(page_num) {
|
||||||
let analysis = analyze_page_content(doc, page_id);
|
let analysis = analyze_page_content(doc, page_id);
|
||||||
@@ -163,6 +170,7 @@ fn detect_from_document(
|
|||||||
pages_with_template_images += 1;
|
pages_with_template_images += 1;
|
||||||
}
|
}
|
||||||
total_text_ops += analysis.text_operator_count;
|
total_text_ops += analysis.text_operator_count;
|
||||||
|
analysis_cache.insert(*page_num, analysis);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -214,6 +222,30 @@ fn detect_from_document(
|
|||||||
(PdfType::TextBased, text_ratio.max(0.5))
|
(PdfType::TextBased, text_ratio.max(0.5))
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Phase 2: Build per-page OCR list
|
||||||
|
let pages_needing_ocr = match pdf_type {
|
||||||
|
PdfType::TextBased => Vec::new(),
|
||||||
|
PdfType::Scanned | PdfType::ImageBased => (1..=total_pages).collect(),
|
||||||
|
PdfType::Mixed => {
|
||||||
|
let mut ocr_pages = Vec::new();
|
||||||
|
for page_num in 1..=total_pages {
|
||||||
|
let analysis = if let Some(cached) = analysis_cache.get(&page_num) {
|
||||||
|
cached.clone()
|
||||||
|
} else if let Some(&page_id) = pages.get(&page_num) {
|
||||||
|
analyze_page_content(doc, page_id)
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
if analysis.text_operator_count < config.min_text_ops_per_page
|
||||||
|
&& (analysis.has_images || analysis.has_template_image)
|
||||||
|
{
|
||||||
|
ocr_pages.push(page_num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ocr_pages
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Try to get title from metadata
|
// Try to get title from metadata
|
||||||
let title = get_document_title(doc);
|
let title = get_document_title(doc);
|
||||||
|
|
||||||
@@ -225,10 +257,12 @@ fn detect_from_document(
|
|||||||
confidence,
|
confidence,
|
||||||
title,
|
title,
|
||||||
ocr_recommended,
|
ocr_recommended,
|
||||||
|
pages_needing_ocr,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Page content analysis result
|
/// Page content analysis result
|
||||||
|
#[derive(Clone)]
|
||||||
struct PageAnalysis {
|
struct PageAnalysis {
|
||||||
text_operator_count: u32,
|
text_operator_count: u32,
|
||||||
has_images: bool,
|
has_images: bool,
|
||||||
|
|||||||
+28
-14
@@ -31,6 +31,8 @@ pub struct PdfProcessResult {
|
|||||||
pub page_count: u32,
|
pub page_count: u32,
|
||||||
/// Processing time in milliseconds
|
/// Processing time in milliseconds
|
||||||
pub processing_time_ms: u64,
|
pub processing_time_ms: u64,
|
||||||
|
/// 1-indexed page numbers that need OCR.
|
||||||
|
pub pages_needing_ocr: Vec<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Process a PDF file with smart detection and extraction
|
/// Process a PDF file with smart detection and extraction
|
||||||
@@ -46,29 +48,34 @@ pub fn process_pdf<P: AsRef<Path>>(path: P) -> Result<PdfProcessResult, PdfError
|
|||||||
|
|
||||||
// Step 1: Smart detection (fast, no full load)
|
// Step 1: Smart detection (fast, no full load)
|
||||||
let detection = detect_pdf_type(&path)?;
|
let detection = detect_pdf_type(&path)?;
|
||||||
|
let page_count = detection.page_count;
|
||||||
|
let pdf_type = detection.pdf_type;
|
||||||
|
let pages_needing_ocr = detection.pages_needing_ocr;
|
||||||
|
|
||||||
let result = match detection.pdf_type {
|
let result = match pdf_type {
|
||||||
PdfType::TextBased => {
|
PdfType::TextBased => {
|
||||||
// Step 2: Full extraction with position-aware reading order
|
// Step 2: Full extraction with position-aware reading order
|
||||||
let items = extract_text_with_positions(&path)?;
|
let items = extract_text_with_positions(&path)?;
|
||||||
let markdown = to_markdown_from_items(items, MarkdownOptions::default());
|
let markdown = to_markdown_from_items(items, MarkdownOptions::default());
|
||||||
|
|
||||||
PdfProcessResult {
|
PdfProcessResult {
|
||||||
pdf_type: PdfType::TextBased,
|
pdf_type,
|
||||||
text: None, // We now produce markdown directly
|
text: None, // We now produce markdown directly
|
||||||
markdown: Some(markdown),
|
markdown: Some(markdown),
|
||||||
page_count: detection.page_count,
|
page_count,
|
||||||
processing_time_ms: start.elapsed().as_millis() as u64,
|
processing_time_ms: start.elapsed().as_millis() as u64,
|
||||||
|
pages_needing_ocr,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PdfType::Scanned | PdfType::ImageBased => {
|
PdfType::Scanned | PdfType::ImageBased => {
|
||||||
// Return early - OCR needed
|
// Return early - OCR needed
|
||||||
PdfProcessResult {
|
PdfProcessResult {
|
||||||
pdf_type: detection.pdf_type,
|
pdf_type,
|
||||||
text: None,
|
text: None,
|
||||||
markdown: None,
|
markdown: None,
|
||||||
page_count: detection.page_count,
|
page_count,
|
||||||
processing_time_ms: start.elapsed().as_millis() as u64,
|
processing_time_ms: start.elapsed().as_millis() as u64,
|
||||||
|
pages_needing_ocr,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PdfType::Mixed => {
|
PdfType::Mixed => {
|
||||||
@@ -77,11 +84,12 @@ pub fn process_pdf<P: AsRef<Path>>(path: P) -> Result<PdfProcessResult, PdfError
|
|||||||
let markdown = items.map(|i| to_markdown_from_items(i, MarkdownOptions::default()));
|
let markdown = items.map(|i| to_markdown_from_items(i, MarkdownOptions::default()));
|
||||||
|
|
||||||
PdfProcessResult {
|
PdfProcessResult {
|
||||||
pdf_type: PdfType::Mixed,
|
pdf_type,
|
||||||
text: None,
|
text: None,
|
||||||
markdown,
|
markdown,
|
||||||
page_count: detection.page_count,
|
page_count,
|
||||||
processing_time_ms: start.elapsed().as_millis() as u64,
|
processing_time_ms: start.elapsed().as_millis() as u64,
|
||||||
|
pages_needing_ocr,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -97,38 +105,44 @@ pub fn process_pdf_mem(buffer: &[u8]) -> Result<PdfProcessResult, PdfError> {
|
|||||||
|
|
||||||
// Step 1: Smart detection (fast, no full load)
|
// Step 1: Smart detection (fast, no full load)
|
||||||
let detection = detector::detect_pdf_type_mem(buffer)?;
|
let detection = detector::detect_pdf_type_mem(buffer)?;
|
||||||
|
let page_count = detection.page_count;
|
||||||
|
let pdf_type = detection.pdf_type;
|
||||||
|
let pages_needing_ocr = detection.pages_needing_ocr;
|
||||||
|
|
||||||
let result = match detection.pdf_type {
|
let result = match pdf_type {
|
||||||
PdfType::TextBased => {
|
PdfType::TextBased => {
|
||||||
// Step 2: Full extraction with position-aware reading order
|
// Step 2: Full extraction with position-aware reading order
|
||||||
let items = extractor::extract_text_with_positions_mem(buffer)?;
|
let items = extractor::extract_text_with_positions_mem(buffer)?;
|
||||||
let markdown = to_markdown_from_items(items, MarkdownOptions::default());
|
let markdown = to_markdown_from_items(items, MarkdownOptions::default());
|
||||||
|
|
||||||
PdfProcessResult {
|
PdfProcessResult {
|
||||||
pdf_type: PdfType::TextBased,
|
pdf_type,
|
||||||
text: None,
|
text: None,
|
||||||
markdown: Some(markdown),
|
markdown: Some(markdown),
|
||||||
page_count: detection.page_count,
|
page_count,
|
||||||
processing_time_ms: start.elapsed().as_millis() as u64,
|
processing_time_ms: start.elapsed().as_millis() as u64,
|
||||||
|
pages_needing_ocr,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PdfType::Scanned | PdfType::ImageBased => PdfProcessResult {
|
PdfType::Scanned | PdfType::ImageBased => PdfProcessResult {
|
||||||
pdf_type: detection.pdf_type,
|
pdf_type,
|
||||||
text: None,
|
text: None,
|
||||||
markdown: None,
|
markdown: None,
|
||||||
page_count: detection.page_count,
|
page_count,
|
||||||
processing_time_ms: start.elapsed().as_millis() as u64,
|
processing_time_ms: start.elapsed().as_millis() as u64,
|
||||||
|
pages_needing_ocr,
|
||||||
},
|
},
|
||||||
PdfType::Mixed => {
|
PdfType::Mixed => {
|
||||||
let items = extractor::extract_text_with_positions_mem(buffer).ok();
|
let items = extractor::extract_text_with_positions_mem(buffer).ok();
|
||||||
let markdown = items.map(|i| to_markdown_from_items(i, MarkdownOptions::default()));
|
let markdown = items.map(|i| to_markdown_from_items(i, MarkdownOptions::default()));
|
||||||
|
|
||||||
PdfProcessResult {
|
PdfProcessResult {
|
||||||
pdf_type: PdfType::Mixed,
|
pdf_type,
|
||||||
text: None,
|
text: None,
|
||||||
markdown,
|
markdown,
|
||||||
page_count: detection.page_count,
|
page_count,
|
||||||
processing_time_ms: start.elapsed().as_millis() as u64,
|
processing_time_ms: start.elapsed().as_millis() as u64,
|
||||||
|
pages_needing_ocr,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -836,3 +836,69 @@ fn test_not_a_pdf_extract_text_mem() {
|
|||||||
let result = pdf_inspector::extractor::extract_text_mem(xml);
|
let result = pdf_inspector::extractor::extract_text_mem(xml);
|
||||||
assert_not_a_pdf(result, "XML");
|
assert_not_a_pdf(result, "XML");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Pages Needing OCR Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pages_needing_ocr_field_accessible() {
|
||||||
|
// Compile-time check: verify the field exists on both structs
|
||||||
|
let detection_result = pdf_inspector::detector::PdfTypeResult {
|
||||||
|
pdf_type: PdfType::TextBased,
|
||||||
|
page_count: 1,
|
||||||
|
pages_sampled: 1,
|
||||||
|
pages_with_text: 1,
|
||||||
|
confidence: 1.0,
|
||||||
|
title: None,
|
||||||
|
ocr_recommended: false,
|
||||||
|
pages_needing_ocr: Vec::new(),
|
||||||
|
};
|
||||||
|
assert!(detection_result.pages_needing_ocr.is_empty());
|
||||||
|
|
||||||
|
let process_result = pdf_inspector::PdfProcessResult {
|
||||||
|
pdf_type: PdfType::TextBased,
|
||||||
|
text: None,
|
||||||
|
markdown: None,
|
||||||
|
page_count: 1,
|
||||||
|
processing_time_ms: 0,
|
||||||
|
pages_needing_ocr: vec![1, 3],
|
||||||
|
};
|
||||||
|
assert_eq!(process_result.pages_needing_ocr, vec![1, 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_text_pdf_process_result_empty_ocr_pages() {
|
||||||
|
// A minimal valid PDF that is text-based should have empty pages_needing_ocr.
|
||||||
|
// We use a minimal PDF buffer with a text content stream.
|
||||||
|
let pdf_bytes = b"%PDF-1.0
|
||||||
|
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
|
||||||
|
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
|
||||||
|
3 0 obj<</Type/Page/MediaBox[0 0 612 792]/Parent 2 0 R/Contents 4 0 R>>endobj
|
||||||
|
4 0 obj<</Length 44>>
|
||||||
|
stream
|
||||||
|
BT /F1 12 Tf 100 700 Td (Hello World) Tj ET
|
||||||
|
endstream
|
||||||
|
endobj
|
||||||
|
xref
|
||||||
|
0 5
|
||||||
|
0000000000 65535 f
|
||||||
|
0000000009 00000 n
|
||||||
|
0000000058 00000 n
|
||||||
|
0000000115 00000 n
|
||||||
|
0000000206 00000 n
|
||||||
|
trailer<</Size 5/Root 1 0 R>>
|
||||||
|
startxref
|
||||||
|
300
|
||||||
|
%%EOF";
|
||||||
|
let result = pdf_inspector::process_pdf_mem(pdf_bytes);
|
||||||
|
// The minimal PDF may fail to parse fully, but if it succeeds,
|
||||||
|
// a text-based PDF should have empty pages_needing_ocr.
|
||||||
|
if let Ok(result) = result {
|
||||||
|
assert!(
|
||||||
|
result.pages_needing_ocr.is_empty(),
|
||||||
|
"Text-based PDF should have empty pages_needing_ocr, got: {:?}",
|
||||||
|
result.pages_needing_ocr
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user