Files
pdf-inspector/src/bin/pdf2md.rs
T
Abimael MartellandClaude Opus 4.6 587c4bed95 feat(detect): flag Identity-H fonts without ToUnicode for OCR (#7)
* feat(detect): flag Identity-H fonts without ToUnicode for OCR

Cyrillic (and other non-Latin) PDFs with Type0/Identity-H encoded fonts
and no ToUnicode CMap produce garbage text from direct extraction. Two
fixes:

1. Detector: new `page_has_identity_h_no_tounicode` check adds affected
   pages to `pages_needing_ocr` regardless of PDF classification.
2. Extraction: extend garbage-text safety net to TextBased PDFs — when
   extracted text is <50% alphanumeric, drop the markdown, set
   `has_encoding_issues`, and flag all pages for OCR.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: replace integration tests with synthetic unit tests

Remove PDF fixture dependencies from detector and lib tests. Use
in-memory lopdf documents to test Identity-H/ToUnicode detection logic.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-19 15:40:48 -07:00

344 lines
14 KiB
Rust

//! CLI tool for PDF to Markdown conversion
use pdf_inspector::{process_pdf_with_options, LayoutComplexity, PdfOptions, PdfType, ProcessMode};
use std::collections::HashSet;
use std::env;
use std::fmt::Write;
use std::fs;
use std::process;
/// Escape a string for embedding in a JSON string value.
///
/// Handles all characters that the JSON spec requires to be escaped:
/// backslash, double-quote, and control characters U+0000..U+001F.
fn json_escape(s: &str) -> String {
let mut out = String::with_capacity(s.len() + 16);
for ch in s.chars() {
match ch {
'\\' => out.push_str("\\\\"),
'"' => out.push_str("\\\""),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
'\x08' => out.push_str("\\b"),
'\x0C' => out.push_str("\\f"),
c if c < '\x20' => {
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
}
out
}
/// Parse a page specification like "1,3,5-10,20" into a HashSet of page numbers.
fn parse_page_spec(spec: &str) -> Result<HashSet<u32>, String> {
let mut pages = HashSet::new();
for part in spec.split(',') {
let part = part.trim();
if let Some((start, end)) = part.split_once('-') {
let start: u32 = start
.trim()
.parse()
.map_err(|_| format!("invalid page number: {}", start.trim()))?;
let end: u32 = end
.trim()
.parse()
.map_err(|_| format!("invalid page number: {}", end.trim()))?;
if start == 0 || end == 0 {
return Err("page numbers are 1-indexed".to_string());
}
if start > end {
return Err(format!("invalid range: {}-{}", start, end));
}
for p in start..=end {
pages.insert(p);
}
} else {
let p: u32 = part
.parse()
.map_err(|_| format!("invalid page number: {}", part))?;
if p == 0 {
return Err("page numbers are 1-indexed".to_string());
}
pages.insert(p);
}
}
Ok(pages)
}
fn print_layout_info(layout: &LayoutComplexity) {
if layout.is_complex {
eprintln!("Layout: COMPLEX");
if !layout.pages_with_tables.is_empty() {
eprintln!(" Pages with tables: {:?}", layout.pages_with_tables);
}
if !layout.pages_with_columns.is_empty() {
eprintln!(" Pages with columns: {:?}", layout.pages_with_columns);
}
} else {
eprintln!("Layout: simple");
}
}
fn main() {
env_logger::init();
let args: Vec<String> = env::args().collect();
if args.len() < 2 {
eprintln!("Usage: {} <pdf_file> [output_file]", args[0]);
eprintln!(" {} <pdf_file> --json", args[0]);
eprintln!(" {} <pdf_file> --raw", args[0]);
eprintln!();
eprintln!("Converts PDF to Markdown with smart type detection.");
eprintln!("Returns early if PDF is scanned (OCR needed).");
eprintln!();
eprintln!("Options:");
eprintln!(" --json Output result as JSON");
eprintln!(" --raw Output only markdown (no headers)");
eprintln!(" --pages Insert page break markers (<!-- Page N -->)");
eprintln!(" --select-pages N Only process specified pages (e.g. 1,3,5-10)");
eprintln!(" --detect-only Only detect PDF type (no extraction)");
eprintln!(" --analyze Detect + extract + layout analysis (no markdown)");
process::exit(1);
}
let pdf_path = &args[1];
let json_output = args.iter().any(|a| a == "--json");
let raw_output = args.iter().any(|a| a == "--raw");
let page_numbers = args.iter().any(|a| a == "--pages");
let detect_only = args.iter().any(|a| a == "--detect-only");
let analyze = args.iter().any(|a| a == "--analyze");
// Parse --select-pages value
let page_filter = args
.iter()
.position(|a| a == "--select-pages")
.map(|i| {
args.get(i + 1)
.unwrap_or_else(|| {
eprintln!("Error: --select-pages requires a value (e.g. 1,3,5-10)");
process::exit(1);
})
.as_str()
})
.map(|spec| {
parse_page_spec(spec).unwrap_or_else(|e| {
eprintln!("Error: invalid --select-pages value: {}", e);
process::exit(1);
})
});
let output_file = args
.get(2)
.filter(|a| !a.starts_with("--"))
.map(|s| s.as_str());
let process_mode = if detect_only {
ProcessMode::DetectOnly
} else if analyze {
ProcessMode::Analyze
} else {
ProcessMode::Full
};
let mut options = PdfOptions::new().mode(process_mode);
options.markdown.include_page_numbers = page_numbers;
if let Some(pages) = page_filter {
options.page_filter = Some(pages);
}
match process_pdf_with_options(pdf_path, options) {
Ok(result) => {
if detect_only || analyze {
// Non-full modes: output detection/analysis info
let pdf_type_str = match result.pdf_type {
PdfType::TextBased => "text_based",
PdfType::Scanned => "scanned",
PdfType::ImageBased => "image_based",
PdfType::Mixed => "mixed",
};
if json_output {
let ocr_pages: Vec<String> = result
.pages_needing_ocr
.iter()
.map(|p| p.to_string())
.collect();
let table_pages: Vec<String> = result
.layout
.pages_with_tables
.iter()
.map(|p| p.to_string())
.collect();
let col_pages: Vec<String> = result
.layout
.pages_with_columns
.iter()
.map(|p| p.to_string())
.collect();
println!(
r#"{{"pdf_type":"{}","page_count":{},"processing_time_ms":{},"pages_needing_ocr":[{}],"is_complex":{},"pages_with_tables":[{}],"pages_with_columns":[{}],"has_encoding_issues":{}}}"#,
pdf_type_str,
result.page_count,
result.processing_time_ms,
ocr_pages.join(","),
result.layout.is_complex,
table_pages.join(","),
col_pages.join(","),
result.has_encoding_issues,
);
} else {
eprintln!("Type: {}", pdf_type_str);
eprintln!("Pages: {}", result.page_count);
eprintln!("Processing time: {}ms", result.processing_time_ms);
if !result.pages_needing_ocr.is_empty() {
eprintln!("Pages needing OCR: {:?}", result.pages_needing_ocr);
}
if analyze {
print_layout_info(&result.layout);
}
}
} else if json_output {
let md_escaped = result
.markdown
.as_ref()
.map(|m| json_escape(m))
.unwrap_or_default();
let ocr_pages: Vec<String> = result
.pages_needing_ocr
.iter()
.map(|p| p.to_string())
.collect();
let table_pages: Vec<String> = result
.layout
.pages_with_tables
.iter()
.map(|p| p.to_string())
.collect();
let col_pages: Vec<String> = result
.layout
.pages_with_columns
.iter()
.map(|p| p.to_string())
.collect();
println!(
r#"{{"pdf_type":"{}","page_count":{},"has_text":{},"processing_time_ms":{},"markdown_length":{},"pages_needing_ocr":[{}],"is_complex":{},"pages_with_tables":[{}],"pages_with_columns":[{}],"has_encoding_issues":{},"markdown":"{}"}}"#,
match result.pdf_type {
PdfType::TextBased => "text_based",
PdfType::Scanned => "scanned",
PdfType::ImageBased => "image_based",
PdfType::Mixed => "mixed",
},
result.page_count,
result.markdown.is_some(),
result.processing_time_ms,
result.markdown.as_ref().map(|m| m.len()).unwrap_or(0),
ocr_pages.join(","),
result.layout.is_complex,
table_pages.join(","),
col_pages.join(","),
result.has_encoding_issues,
md_escaped
);
} else if raw_output {
// Raw output - just the markdown, no headers
match result.pdf_type {
PdfType::TextBased | PdfType::Mixed => {
if let Some(markdown) = &result.markdown {
print!("{}", markdown);
}
}
PdfType::Scanned | PdfType::ImageBased => {
eprintln!("Error: PDF requires OCR (type: {:?})", result.pdf_type);
process::exit(2);
}
}
} else {
// Verbose output with headers
eprintln!("PDF to Markdown Conversion");
eprintln!("==========================");
eprintln!("File: {}", pdf_path);
eprintln!();
match result.pdf_type {
PdfType::TextBased => {
eprintln!("Type: TEXT-BASED (direct extraction)");
eprintln!("Pages: {}", result.page_count);
eprintln!("Processing time: {}ms", result.processing_time_ms);
print_layout_info(&result.layout);
if !result.pages_needing_ocr.is_empty() {
eprintln!("Pages needing OCR: {:?}", result.pages_needing_ocr);
}
if let Some(markdown) = &result.markdown {
if let Some(output) = output_file {
fs::write(output, markdown).expect("Failed to write output file");
eprintln!();
eprintln!("Markdown written to: {}", output);
eprintln!("Length: {} characters", markdown.len());
} else {
eprintln!();
eprintln!("--- Markdown Output ---");
eprintln!();
println!("{}", markdown);
}
}
}
PdfType::Scanned | PdfType::ImageBased => {
eprintln!(
"Type: {} (OCR required)",
if result.pdf_type == PdfType::Scanned {
"SCANNED"
} else {
"IMAGE-BASED"
}
);
eprintln!("Pages: {}", result.page_count);
eprintln!("Processing time: {}ms", result.processing_time_ms);
eprintln!();
eprintln!("This PDF requires OCR for text extraction.");
eprintln!("Consider using MinerU or similar OCR tool.");
process::exit(2);
}
PdfType::Mixed => {
eprintln!("Type: MIXED (partial text extraction)");
eprintln!("Pages: {}", result.page_count);
eprintln!("Processing time: {}ms", result.processing_time_ms);
print_layout_info(&result.layout);
if let Some(markdown) = &result.markdown {
eprintln!();
if result.pages_needing_ocr.is_empty() {
eprintln!("Note: Some pages may contain images that require OCR.");
} else {
eprintln!("Pages needing OCR: {:?}", result.pages_needing_ocr);
}
eprintln!();
if let Some(output) = output_file {
fs::write(output, markdown).expect("Failed to write output file");
eprintln!("Markdown written to: {}", output);
eprintln!("Length: {} characters", markdown.len());
} else {
eprintln!("--- Markdown Output ---");
eprintln!();
println!("{}", markdown);
}
}
}
}
}
}
Err(e) => {
if json_output {
println!(r#"{{"error":"{}"}}"#, e);
} else {
eprintln!("Error: {}", e);
}
process::exit(1);
}
}
}