Initial commit: Rust PDF-to-Markdown library
- Smart PDF type detection (text vs scanned) without full document load - Text extraction using lopdf directly - Markdown conversion with header/list/code detection - CLI tools: detect-pdf, pdf2md Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
//! CLI tool for detecting PDF type (text-based vs scanned)
|
||||
|
||||
use pdf_to_markdown::{detect_pdf_type, PdfType};
|
||||
use std::env;
|
||||
use std::process;
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
|
||||
if args.len() < 2 {
|
||||
eprintln!("Usage: {} <pdf_file>", args[0]);
|
||||
eprintln!(" {} <pdf_file> --json", args[0]);
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
let pdf_path = &args[1];
|
||||
let json_output = args.get(2).map(|a| a == "--json").unwrap_or(false);
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
match detect_pdf_type(pdf_path) {
|
||||
Ok(result) => {
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
if json_output {
|
||||
println!(
|
||||
r#"{{"pdf_type":"{}","page_count":{},"pages_sampled":{},"pages_with_text":{},"confidence":{:.2},"title":{},"detection_time_ms":{}}}"#,
|
||||
match result.pdf_type {
|
||||
PdfType::TextBased => "text_based",
|
||||
PdfType::Scanned => "scanned",
|
||||
PdfType::ImageBased => "image_based",
|
||||
PdfType::Mixed => "mixed",
|
||||
},
|
||||
result.page_count,
|
||||
result.pages_sampled,
|
||||
result.pages_with_text,
|
||||
result.confidence,
|
||||
result.title.as_ref().map(|t| format!("\"{}\"", t.replace('"', "\\\""))).unwrap_or_else(|| "null".to_string()),
|
||||
elapsed.as_millis()
|
||||
);
|
||||
} else {
|
||||
println!("PDF Type Detection Results");
|
||||
println!("==========================");
|
||||
println!("File: {}", pdf_path);
|
||||
println!();
|
||||
println!(
|
||||
"Type: {}",
|
||||
match result.pdf_type {
|
||||
PdfType::TextBased => "TEXT-BASED (extractable text)",
|
||||
PdfType::Scanned => "SCANNED (OCR needed)",
|
||||
PdfType::ImageBased => "IMAGE-BASED (mostly images, OCR may help)",
|
||||
PdfType::Mixed => "MIXED (some text, some images)",
|
||||
}
|
||||
);
|
||||
println!("Confidence: {:.0}%", result.confidence * 100.0);
|
||||
println!();
|
||||
println!("Page count: {}", result.page_count);
|
||||
println!("Pages sampled: {}", result.pages_sampled);
|
||||
println!("Pages with text: {}", result.pages_with_text);
|
||||
if let Some(title) = &result.title {
|
||||
println!("Title: {}", title);
|
||||
}
|
||||
println!();
|
||||
println!("Detection time: {}ms", elapsed.as_millis());
|
||||
println!();
|
||||
|
||||
// Recommendations
|
||||
match result.pdf_type {
|
||||
PdfType::TextBased => {
|
||||
println!("Recommendation: Use direct text extraction (fast)");
|
||||
}
|
||||
PdfType::Scanned => {
|
||||
println!("Recommendation: Use OCR (MinerU or similar)");
|
||||
}
|
||||
PdfType::ImageBased => {
|
||||
println!("Recommendation: Use OCR for best results");
|
||||
}
|
||||
PdfType::Mixed => {
|
||||
println!("Recommendation: Try text extraction first, use OCR for image pages");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if json_output {
|
||||
println!(r#"{{"error":"{}"}}"#, e);
|
||||
} else {
|
||||
eprintln!("Error: {}", e);
|
||||
}
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
//! CLI tool for PDF to Markdown conversion
|
||||
|
||||
use pdf_to_markdown::{process_pdf, PdfType};
|
||||
use std::env;
|
||||
use std::fs;
|
||||
use std::process;
|
||||
use std::time::Instant;
|
||||
|
||||
fn main() {
|
||||
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!();
|
||||
eprintln!("Converts PDF to Markdown with smart type detection.");
|
||||
eprintln!("Returns early if PDF is scanned (OCR needed).");
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
let pdf_path = &args[1];
|
||||
let json_output = args.get(2).map(|a| a == "--json").unwrap_or(false);
|
||||
let output_file = if !json_output { args.get(2) } else { None };
|
||||
|
||||
let start = Instant::now();
|
||||
|
||||
match process_pdf(pdf_path) {
|
||||
Ok(result) => {
|
||||
let _elapsed = start.elapsed();
|
||||
|
||||
if json_output {
|
||||
let md_escaped = result
|
||||
.markdown
|
||||
.as_ref()
|
||||
.map(|m| m.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n"))
|
||||
.unwrap_or_default();
|
||||
|
||||
println!(
|
||||
r#"{{"pdf_type":"{}","page_count":{},"has_text":{},"processing_time_ms":{},"markdown_length":{},"markdown":"{}"}}"#,
|
||||
match result.pdf_type {
|
||||
PdfType::TextBased => "text_based",
|
||||
PdfType::Scanned => "scanned",
|
||||
PdfType::ImageBased => "image_based",
|
||||
PdfType::Mixed => "mixed",
|
||||
},
|
||||
result.page_count,
|
||||
result.text.is_some(),
|
||||
result.processing_time_ms,
|
||||
result.markdown.as_ref().map(|m| m.len()).unwrap_or(0),
|
||||
md_escaped
|
||||
);
|
||||
} else {
|
||||
println!("PDF to Markdown Conversion");
|
||||
println!("==========================");
|
||||
println!("File: {}", pdf_path);
|
||||
println!();
|
||||
|
||||
match result.pdf_type {
|
||||
PdfType::TextBased => {
|
||||
println!("Type: TEXT-BASED (direct extraction)");
|
||||
println!("Pages: {}", result.page_count);
|
||||
println!("Processing time: {}ms", result.processing_time_ms);
|
||||
|
||||
if let Some(markdown) = &result.markdown {
|
||||
if let Some(output) = output_file {
|
||||
fs::write(output, markdown).expect("Failed to write output file");
|
||||
println!();
|
||||
println!("Markdown written to: {}", output);
|
||||
println!("Length: {} characters", markdown.len());
|
||||
} else {
|
||||
println!();
|
||||
println!("--- Markdown Output ---");
|
||||
println!();
|
||||
println!("{}", markdown);
|
||||
}
|
||||
}
|
||||
}
|
||||
PdfType::Scanned | PdfType::ImageBased => {
|
||||
println!(
|
||||
"Type: {} (OCR required)",
|
||||
if result.pdf_type == PdfType::Scanned {
|
||||
"SCANNED"
|
||||
} else {
|
||||
"IMAGE-BASED"
|
||||
}
|
||||
);
|
||||
println!("Pages: {}", result.page_count);
|
||||
println!("Processing time: {}ms", result.processing_time_ms);
|
||||
println!();
|
||||
println!("This PDF requires OCR for text extraction.");
|
||||
println!("Consider using MinerU or similar OCR tool.");
|
||||
process::exit(2);
|
||||
}
|
||||
PdfType::Mixed => {
|
||||
println!("Type: MIXED (partial text extraction)");
|
||||
println!("Pages: {}", result.page_count);
|
||||
println!("Processing time: {}ms", result.processing_time_ms);
|
||||
|
||||
if let Some(markdown) = &result.markdown {
|
||||
println!();
|
||||
println!("Note: Some pages may contain images that require OCR.");
|
||||
println!();
|
||||
|
||||
if let Some(output) = output_file {
|
||||
fs::write(output, markdown).expect("Failed to write output file");
|
||||
println!("Markdown written to: {}", output);
|
||||
println!("Length: {} characters", markdown.len());
|
||||
} else {
|
||||
println!("--- Markdown Output ---");
|
||||
println!();
|
||||
println!("{}", markdown);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if json_output {
|
||||
println!(r#"{{"error":"{}"}}"#, e);
|
||||
} else {
|
||||
eprintln!("Error: {}", e);
|
||||
}
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
+376
@@ -0,0 +1,376 @@
|
||||
//! Smart PDF type detection without full document load
|
||||
//!
|
||||
//! This module detects whether a PDF is text-based, scanned, or image-based
|
||||
//! by sampling content streams for text operators (Tj/TJ) without loading
|
||||
//! all objects.
|
||||
|
||||
use crate::PdfError;
|
||||
use lopdf::{Document, Object, ObjectId};
|
||||
use std::path::Path;
|
||||
|
||||
/// PDF type classification
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PdfType {
|
||||
/// PDF has extractable text (Tj/TJ operators found)
|
||||
TextBased,
|
||||
/// PDF appears to be scanned (images only, no text operators)
|
||||
Scanned,
|
||||
/// PDF contains mostly images with minimal/no text
|
||||
ImageBased,
|
||||
/// PDF has mix of text and image-heavy pages
|
||||
Mixed,
|
||||
}
|
||||
|
||||
/// Result of PDF type detection
|
||||
#[derive(Debug)]
|
||||
pub struct PdfTypeResult {
|
||||
/// Detected PDF type
|
||||
pub pdf_type: PdfType,
|
||||
/// Number of pages in the document
|
||||
pub page_count: u32,
|
||||
/// Number of pages sampled for detection
|
||||
pub pages_sampled: u32,
|
||||
/// Number of pages with text operators found
|
||||
pub pages_with_text: u32,
|
||||
/// Confidence score (0.0 - 1.0)
|
||||
pub confidence: f32,
|
||||
/// Title from metadata (if available)
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
/// Configuration for PDF type detection
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DetectionConfig {
|
||||
/// Maximum number of pages to sample (default: 5)
|
||||
pub max_pages_to_sample: u32,
|
||||
/// Minimum text operator count per page to consider as text-based
|
||||
pub min_text_ops_per_page: u32,
|
||||
/// Threshold ratio of text pages to total pages for classification
|
||||
pub text_page_ratio_threshold: f32,
|
||||
}
|
||||
|
||||
impl Default for DetectionConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
max_pages_to_sample: 5,
|
||||
min_text_ops_per_page: 3,
|
||||
text_page_ratio_threshold: 0.6,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect PDF type from file path
|
||||
pub fn detect_pdf_type<P: AsRef<Path>>(path: P) -> Result<PdfTypeResult, PdfError> {
|
||||
detect_pdf_type_with_config(path, DetectionConfig::default())
|
||||
}
|
||||
|
||||
/// Detect PDF type from file path with custom configuration
|
||||
pub fn detect_pdf_type_with_config<P: AsRef<Path>>(
|
||||
path: P,
|
||||
config: DetectionConfig,
|
||||
) -> Result<PdfTypeResult, PdfError> {
|
||||
// First, load metadata only (fast operation)
|
||||
let metadata = Document::load_metadata(&path)?;
|
||||
|
||||
// Then load the full document for content inspection
|
||||
// We use filtered loading to skip heavy objects we don't need
|
||||
let doc = Document::load(&path)?;
|
||||
|
||||
detect_from_document(&doc, metadata.page_count, &config)
|
||||
}
|
||||
|
||||
/// Detect PDF type from memory buffer
|
||||
pub fn detect_pdf_type_mem(buffer: &[u8]) -> Result<PdfTypeResult, PdfError> {
|
||||
detect_pdf_type_mem_with_config(buffer, DetectionConfig::default())
|
||||
}
|
||||
|
||||
/// Detect PDF type from memory buffer with custom configuration
|
||||
pub fn detect_pdf_type_mem_with_config(
|
||||
buffer: &[u8],
|
||||
config: DetectionConfig,
|
||||
) -> Result<PdfTypeResult, PdfError> {
|
||||
// Load metadata first (fast)
|
||||
let metadata = Document::load_metadata_mem(buffer)?;
|
||||
|
||||
// Load document for inspection
|
||||
let doc = Document::load_mem(buffer)?;
|
||||
|
||||
detect_from_document(&doc, metadata.page_count, &config)
|
||||
}
|
||||
|
||||
/// Internal detection logic on a loaded document
|
||||
fn detect_from_document(
|
||||
doc: &Document,
|
||||
page_count: u32,
|
||||
config: &DetectionConfig,
|
||||
) -> Result<PdfTypeResult, PdfError> {
|
||||
let pages = doc.get_pages();
|
||||
let total_pages = pages.len() as u32;
|
||||
|
||||
// Sample pages for text operator detection
|
||||
let pages_to_sample = std::cmp::min(config.max_pages_to_sample, total_pages);
|
||||
|
||||
// Sample strategy: first page, last page, and evenly distributed pages
|
||||
let sample_indices: Vec<u32> = if pages_to_sample >= total_pages {
|
||||
(1..=total_pages).collect()
|
||||
} else {
|
||||
let mut indices = Vec::with_capacity(pages_to_sample as usize);
|
||||
indices.push(1); // Always sample first page
|
||||
|
||||
if pages_to_sample > 1 {
|
||||
indices.push(total_pages); // Always sample last page
|
||||
}
|
||||
|
||||
// Add evenly distributed pages in between
|
||||
let remaining = pages_to_sample.saturating_sub(2);
|
||||
if remaining > 0 && total_pages > 2 {
|
||||
let step = (total_pages - 2) / (remaining + 1);
|
||||
for i in 1..=remaining {
|
||||
let idx = 1 + (step * i);
|
||||
if idx > 1 && idx < total_pages && !indices.contains(&idx) {
|
||||
indices.push(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
indices.sort();
|
||||
indices.dedup();
|
||||
indices
|
||||
};
|
||||
|
||||
let mut pages_with_text = 0u32;
|
||||
let mut pages_with_images = 0u32;
|
||||
let mut total_text_ops = 0u32;
|
||||
|
||||
for page_num in &sample_indices {
|
||||
if let Some(&page_id) = pages.get(page_num) {
|
||||
let analysis = analyze_page_content(doc, page_id);
|
||||
if analysis.text_operator_count >= config.min_text_ops_per_page {
|
||||
pages_with_text += 1;
|
||||
}
|
||||
if analysis.has_images {
|
||||
pages_with_images += 1;
|
||||
}
|
||||
total_text_ops += analysis.text_operator_count;
|
||||
}
|
||||
}
|
||||
|
||||
let pages_sampled = sample_indices.len() as u32;
|
||||
let text_ratio = if pages_sampled > 0 {
|
||||
pages_with_text as f32 / pages_sampled as f32
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
// Classification logic
|
||||
let (pdf_type, confidence) = if text_ratio >= config.text_page_ratio_threshold {
|
||||
(PdfType::TextBased, text_ratio)
|
||||
} else if pages_with_text == 0 && pages_with_images > 0 {
|
||||
if total_text_ops == 0 {
|
||||
(PdfType::Scanned, 0.95)
|
||||
} else {
|
||||
(PdfType::ImageBased, 0.8)
|
||||
}
|
||||
} else if pages_with_text > 0 && pages_with_images > 0 {
|
||||
(PdfType::Mixed, 0.7)
|
||||
} else if total_text_ops == 0 {
|
||||
(PdfType::Scanned, 0.9)
|
||||
} else {
|
||||
(PdfType::TextBased, text_ratio.max(0.5))
|
||||
};
|
||||
|
||||
// Try to get title from metadata
|
||||
let title = get_document_title(doc);
|
||||
|
||||
Ok(PdfTypeResult {
|
||||
pdf_type,
|
||||
page_count,
|
||||
pages_sampled,
|
||||
pages_with_text,
|
||||
confidence,
|
||||
title,
|
||||
})
|
||||
}
|
||||
|
||||
/// Page content analysis result
|
||||
struct PageAnalysis {
|
||||
text_operator_count: u32,
|
||||
has_images: bool,
|
||||
}
|
||||
|
||||
/// Analyze a page's content stream for text operators and images
|
||||
fn analyze_page_content(doc: &Document, page_id: ObjectId) -> PageAnalysis {
|
||||
let mut text_ops = 0u32;
|
||||
let mut has_images = false;
|
||||
|
||||
// Get content streams for this page
|
||||
let content_streams = doc.get_page_contents(page_id);
|
||||
|
||||
for content_id in content_streams {
|
||||
if let Ok(Object::Stream(stream)) = doc.get_object(content_id) {
|
||||
// Try to decompress and scan content
|
||||
let content = match stream.decompressed_content() {
|
||||
Ok(data) => data,
|
||||
Err(_) => stream.content.clone(),
|
||||
};
|
||||
|
||||
// Scan for text operators (Tj, TJ)
|
||||
let (ops, imgs) = scan_content_for_text_operators(&content);
|
||||
text_ops += ops;
|
||||
has_images = has_images || imgs;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for XObject images in page resources
|
||||
if !has_images {
|
||||
has_images = page_has_images(doc, page_id);
|
||||
}
|
||||
|
||||
PageAnalysis {
|
||||
text_operator_count: text_ops,
|
||||
has_images,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fast scan of content stream bytes for text operators
|
||||
///
|
||||
/// This is a fast heuristic scan that looks for:
|
||||
/// - "Tj" - show text string
|
||||
/// - "TJ" - show text with individual glyph positioning
|
||||
/// - "'" - move to next line and show text
|
||||
/// - "\"" - set word/char spacing, move to next line, show text
|
||||
fn scan_content_for_text_operators(content: &[u8]) -> (u32, bool) {
|
||||
let mut text_ops = 0u32;
|
||||
let mut has_images = false;
|
||||
|
||||
// Simple state machine to find operators
|
||||
let mut i = 0;
|
||||
while i < content.len() {
|
||||
let b = content[i];
|
||||
|
||||
// Look for 'T' followed by 'j' or 'J'
|
||||
if b == b'T' && i + 1 < content.len() {
|
||||
let next = content[i + 1];
|
||||
if next == b'j' || next == b'J' {
|
||||
// Verify it's an operator (followed by whitespace or newline)
|
||||
if i + 2 >= content.len()
|
||||
|| content[i + 2].is_ascii_whitespace()
|
||||
|| content[i + 2] == b'\n'
|
||||
|| content[i + 2] == b'\r'
|
||||
{
|
||||
text_ops += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Look for BT (Begin Text) as additional confirmation
|
||||
if b == b'B' && i + 1 < content.len() && content[i + 1] == b'T' {
|
||||
if i + 2 >= content.len() || content[i + 2].is_ascii_whitespace() {
|
||||
// BT found - text block marker
|
||||
}
|
||||
}
|
||||
|
||||
// Look for 'Do' operator (XObject/image placement)
|
||||
if b == b'D' && i + 1 < content.len() && content[i + 1] == b'o' {
|
||||
if i + 2 >= content.len() || content[i + 2].is_ascii_whitespace() {
|
||||
has_images = true;
|
||||
}
|
||||
}
|
||||
|
||||
i += 1;
|
||||
}
|
||||
|
||||
(text_ops, has_images)
|
||||
}
|
||||
|
||||
/// Check if page has image XObjects in resources
|
||||
fn page_has_images(doc: &Document, page_id: ObjectId) -> bool {
|
||||
if let Ok(page_dict) = doc.get_dictionary(page_id) {
|
||||
// Get Resources
|
||||
let resources = match page_dict.get(b"Resources") {
|
||||
Ok(Object::Reference(id)) => doc.get_dictionary(*id).ok(),
|
||||
Ok(Object::Dictionary(dict)) => Some(dict),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(resources) = resources {
|
||||
// Check XObject dictionary
|
||||
if let Ok(xobject) = resources.get(b"XObject") {
|
||||
let xobject_dict = match xobject {
|
||||
Object::Reference(id) => doc.get_dictionary(*id).ok(),
|
||||
Object::Dictionary(dict) => Some(dict),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
if let Some(xobject_dict) = xobject_dict {
|
||||
for (_, value) in xobject_dict.iter() {
|
||||
if let Ok(xobj_ref) = value.as_reference() {
|
||||
if let Ok(xobj) = doc.get_object(xobj_ref) {
|
||||
if let Ok(stream) = xobj.as_stream() {
|
||||
// Check if it's an Image subtype
|
||||
if let Ok(subtype) = stream.dict.get(b"Subtype") {
|
||||
if let Ok(name) = subtype.as_name() {
|
||||
if name == b"Image" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Get document title from Info dictionary
|
||||
fn get_document_title(doc: &Document) -> Option<String> {
|
||||
let info_ref = doc.trailer.get(b"Info").ok()?.as_reference().ok()?;
|
||||
let info = doc.get_dictionary(info_ref).ok()?;
|
||||
let title_obj = info.get(b"Title").ok()?;
|
||||
|
||||
match title_obj {
|
||||
Object::String(bytes, _) => {
|
||||
// Handle UTF-16BE encoding (BOM: 0xFE 0xFF)
|
||||
if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
|
||||
let utf16: Vec<u16> = bytes[2..]
|
||||
.chunks_exact(2)
|
||||
.map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]]))
|
||||
.collect();
|
||||
Some(String::from_utf16_lossy(&utf16))
|
||||
} else {
|
||||
Some(String::from_utf8_lossy(bytes).to_string())
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_scan_content_operators() {
|
||||
// Sample PDF content stream with text operators
|
||||
let content = b"BT /F1 12 Tf 100 700 Td (Hello World) Tj ET";
|
||||
let (ops, imgs) = scan_content_for_text_operators(content);
|
||||
assert_eq!(ops, 1);
|
||||
assert!(!imgs);
|
||||
|
||||
// Content with TJ array
|
||||
let content2 = b"BT /F1 12 Tf 100 700 Td [(H) 10 (ello)] TJ ET";
|
||||
let (ops2, _) = scan_content_for_text_operators(content2);
|
||||
assert_eq!(ops2, 1);
|
||||
|
||||
// Content with Do (image)
|
||||
let content3 = b"q 100 0 0 100 50 700 cm /Img1 Do Q";
|
||||
let (ops3, imgs3) = scan_content_for_text_operators(content3);
|
||||
assert_eq!(ops3, 0);
|
||||
assert!(imgs3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
//! Text extraction from PDF using lopdf
|
||||
//!
|
||||
//! This module extracts text with position information for structure detection.
|
||||
|
||||
use crate::PdfError;
|
||||
use lopdf::{Document, Object, ObjectId};
|
||||
use std::path::Path;
|
||||
|
||||
/// A text item with position information
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TextItem {
|
||||
/// The text content
|
||||
pub text: String,
|
||||
/// X position on page
|
||||
pub x: f32,
|
||||
/// Y position on page (PDF coordinates, origin at bottom-left)
|
||||
pub y: f32,
|
||||
/// Width of text
|
||||
pub width: f32,
|
||||
/// Height (approximated from font size)
|
||||
pub height: f32,
|
||||
/// Font name
|
||||
pub font: String,
|
||||
/// Font size
|
||||
pub font_size: f32,
|
||||
/// Page number (1-indexed)
|
||||
pub page: u32,
|
||||
}
|
||||
|
||||
/// A line of text (grouped text items)
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TextLine {
|
||||
pub items: Vec<TextItem>,
|
||||
pub y: f32,
|
||||
pub page: u32,
|
||||
}
|
||||
|
||||
impl TextLine {
|
||||
pub fn text(&self) -> String {
|
||||
self.items.iter().map(|i| i.text.as_str()).collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract text from PDF file as plain string
|
||||
pub fn extract_text<P: AsRef<Path>>(path: P) -> Result<String, PdfError> {
|
||||
let doc = Document::load(path)?;
|
||||
extract_text_from_doc(&doc)
|
||||
}
|
||||
|
||||
/// Extract text from PDF memory buffer
|
||||
pub fn extract_text_mem(buffer: &[u8]) -> Result<String, PdfError> {
|
||||
let doc = Document::load_mem(buffer)?;
|
||||
extract_text_from_doc(&doc)
|
||||
}
|
||||
|
||||
/// Extract text from loaded document
|
||||
fn extract_text_from_doc(doc: &Document) -> Result<String, PdfError> {
|
||||
let pages = doc.get_pages();
|
||||
let page_nums: Vec<u32> = pages.keys().cloned().collect();
|
||||
|
||||
doc.extract_text(&page_nums)
|
||||
.map_err(|e| PdfError::Parse(e.to_string()))
|
||||
}
|
||||
|
||||
/// Extract text with position information from PDF file
|
||||
pub fn extract_text_with_positions<P: AsRef<Path>>(path: P) -> Result<Vec<TextItem>, PdfError> {
|
||||
let doc = Document::load(path)?;
|
||||
extract_positioned_text_from_doc(&doc)
|
||||
}
|
||||
|
||||
/// Extract text with positions from memory buffer
|
||||
pub fn extract_text_with_positions_mem(buffer: &[u8]) -> Result<Vec<TextItem>, PdfError> {
|
||||
let doc = Document::load_mem(buffer)?;
|
||||
extract_positioned_text_from_doc(&doc)
|
||||
}
|
||||
|
||||
/// Extract positioned text from loaded document
|
||||
fn extract_positioned_text_from_doc(doc: &Document) -> Result<Vec<TextItem>, PdfError> {
|
||||
let pages = doc.get_pages();
|
||||
let mut all_items = Vec::new();
|
||||
|
||||
for (page_num, &page_id) in pages.iter() {
|
||||
let items = extract_page_text_items(doc, page_id, *page_num)?;
|
||||
all_items.extend(items);
|
||||
}
|
||||
|
||||
Ok(all_items)
|
||||
}
|
||||
|
||||
/// Extract text items from a single page
|
||||
fn extract_page_text_items(
|
||||
doc: &Document,
|
||||
page_id: ObjectId,
|
||||
page_num: u32,
|
||||
) -> Result<Vec<TextItem>, PdfError> {
|
||||
use lopdf::content::Content;
|
||||
|
||||
let mut items = Vec::new();
|
||||
|
||||
// Get fonts for encoding
|
||||
let fonts = doc.get_page_fonts(page_id).unwrap_or_default();
|
||||
|
||||
// Get content
|
||||
let content_data = doc.get_page_content(page_id)
|
||||
.map_err(|e| PdfError::Parse(e.to_string()))?;
|
||||
|
||||
let content = Content::decode(&content_data)
|
||||
.map_err(|e| PdfError::Parse(e.to_string()))?;
|
||||
|
||||
// Text state tracking
|
||||
let mut current_font = String::new();
|
||||
let mut current_font_size: f32 = 12.0;
|
||||
let mut text_matrix = [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0];
|
||||
let mut line_matrix = [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0];
|
||||
let mut in_text_block = false;
|
||||
|
||||
for op in &content.operations {
|
||||
match op.operator.as_str() {
|
||||
"BT" => {
|
||||
// Begin text block
|
||||
in_text_block = true;
|
||||
text_matrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
|
||||
line_matrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0];
|
||||
}
|
||||
"ET" => {
|
||||
// End text block
|
||||
in_text_block = false;
|
||||
}
|
||||
"Tf" => {
|
||||
// Set font and size
|
||||
if op.operands.len() >= 2 {
|
||||
if let Ok(name) = op.operands[0].as_name() {
|
||||
current_font = String::from_utf8_lossy(name).to_string();
|
||||
}
|
||||
if let Ok(size) = op.operands[1].as_f32() {
|
||||
current_font_size = size;
|
||||
} else if let Ok(size) = op.operands[1].as_i64() {
|
||||
current_font_size = size as f32;
|
||||
}
|
||||
}
|
||||
}
|
||||
"Td" | "TD" => {
|
||||
// Move text position
|
||||
if op.operands.len() >= 2 {
|
||||
let tx = get_number(&op.operands[0]).unwrap_or(0.0);
|
||||
let ty = get_number(&op.operands[1]).unwrap_or(0.0);
|
||||
line_matrix[4] += tx;
|
||||
line_matrix[5] += ty;
|
||||
text_matrix = line_matrix;
|
||||
}
|
||||
}
|
||||
"Tm" => {
|
||||
// Set text matrix
|
||||
if op.operands.len() >= 6 {
|
||||
for (i, operand) in op.operands.iter().take(6).enumerate() {
|
||||
text_matrix[i] = get_number(operand).unwrap_or(if i == 0 || i == 3 { 1.0 } else { 0.0 });
|
||||
}
|
||||
line_matrix = text_matrix;
|
||||
}
|
||||
}
|
||||
"T*" => {
|
||||
// Move to start of next line
|
||||
line_matrix[5] -= current_font_size * 1.2; // Approximate line height
|
||||
text_matrix = line_matrix;
|
||||
}
|
||||
"Tj" => {
|
||||
// Show text string
|
||||
if in_text_block && !op.operands.is_empty() {
|
||||
if let Some(text) = extract_text_from_operand(&op.operands[0], doc, &fonts, ¤t_font) {
|
||||
if !text.trim().is_empty() {
|
||||
items.push(TextItem {
|
||||
text,
|
||||
x: text_matrix[4],
|
||||
y: text_matrix[5],
|
||||
width: 0.0, // Would need glyph widths
|
||||
height: current_font_size,
|
||||
font: current_font.clone(),
|
||||
font_size: current_font_size,
|
||||
page: page_num,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"TJ" => {
|
||||
// Show text with positioning
|
||||
if in_text_block && !op.operands.is_empty() {
|
||||
if let Ok(array) = op.operands[0].as_array() {
|
||||
let mut combined_text = String::new();
|
||||
for item in array {
|
||||
if let Some(text) = extract_text_from_operand(item, doc, &fonts, ¤t_font) {
|
||||
combined_text.push_str(&text);
|
||||
}
|
||||
}
|
||||
if !combined_text.trim().is_empty() {
|
||||
items.push(TextItem {
|
||||
text: combined_text,
|
||||
x: text_matrix[4],
|
||||
y: text_matrix[5],
|
||||
width: 0.0,
|
||||
height: current_font_size,
|
||||
font: current_font.clone(),
|
||||
font_size: current_font_size,
|
||||
page: page_num,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"'" => {
|
||||
// Move to next line and show text
|
||||
line_matrix[5] -= current_font_size * 1.2;
|
||||
text_matrix = line_matrix;
|
||||
if !op.operands.is_empty() {
|
||||
if let Some(text) = extract_text_from_operand(&op.operands[0], doc, &fonts, ¤t_font) {
|
||||
if !text.trim().is_empty() {
|
||||
items.push(TextItem {
|
||||
text,
|
||||
x: text_matrix[4],
|
||||
y: text_matrix[5],
|
||||
width: 0.0,
|
||||
height: current_font_size,
|
||||
font: current_font.clone(),
|
||||
font_size: current_font_size,
|
||||
page: page_num,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Helper to get f32 from Object
|
||||
fn get_number(obj: &Object) -> Option<f32> {
|
||||
match obj {
|
||||
Object::Integer(i) => Some(*i as f32),
|
||||
Object::Real(r) => Some(*r as f32),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract text from a text operand, handling encoding
|
||||
fn extract_text_from_operand(
|
||||
obj: &Object,
|
||||
doc: &Document,
|
||||
fonts: &std::collections::BTreeMap<Vec<u8>, &lopdf::Dictionary>,
|
||||
current_font: &str,
|
||||
) -> Option<String> {
|
||||
if let Object::String(bytes, _) = obj {
|
||||
// Try to decode using font encoding
|
||||
if let Some(font_dict) = fonts.get(current_font.as_bytes()) {
|
||||
if let Ok(encoding) = font_dict.get_font_encoding(doc) {
|
||||
if let Ok(text) = Document::decode_text(&encoding, bytes) {
|
||||
return Some(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try UTF-16BE then Latin-1
|
||||
if bytes.len() >= 2 && bytes[0] == 0xFE && bytes[1] == 0xFF {
|
||||
let utf16: Vec<u16> = bytes[2..]
|
||||
.chunks_exact(2)
|
||||
.map(|chunk| u16::from_be_bytes([chunk[0], chunk[1]]))
|
||||
.collect();
|
||||
return Some(String::from_utf16_lossy(&utf16));
|
||||
}
|
||||
|
||||
// Latin-1 fallback
|
||||
Some(bytes.iter().map(|&b| b as char).collect())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Group text items into lines based on Y position
|
||||
pub fn group_into_lines(items: Vec<TextItem>) -> Vec<TextLine> {
|
||||
if items.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
// Sort by page, then by Y (descending for PDF coords), then by X
|
||||
let mut sorted = items;
|
||||
sorted.sort_by(|a, b| {
|
||||
a.page.cmp(&b.page)
|
||||
.then(b.y.partial_cmp(&a.y).unwrap_or(std::cmp::Ordering::Equal))
|
||||
.then(a.x.partial_cmp(&b.x).unwrap_or(std::cmp::Ordering::Equal))
|
||||
});
|
||||
|
||||
let mut lines = Vec::new();
|
||||
let mut current_line: Option<TextLine> = None;
|
||||
let y_tolerance = 3.0; // Tolerance for same-line grouping
|
||||
|
||||
for item in sorted {
|
||||
match &mut current_line {
|
||||
Some(line) if line.page == item.page && (line.y - item.y).abs() < y_tolerance => {
|
||||
// Same line
|
||||
line.items.push(item);
|
||||
}
|
||||
_ => {
|
||||
// New line
|
||||
if let Some(line) = current_line.take() {
|
||||
lines.push(line);
|
||||
}
|
||||
let y = item.y;
|
||||
let page = item.page;
|
||||
current_line = Some(TextLine {
|
||||
items: vec![item],
|
||||
y,
|
||||
page,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(line) = current_line {
|
||||
lines.push(line);
|
||||
}
|
||||
|
||||
lines
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_group_into_lines() {
|
||||
let items = vec![
|
||||
TextItem {
|
||||
text: "Hello".into(),
|
||||
x: 100.0,
|
||||
y: 700.0,
|
||||
width: 50.0,
|
||||
height: 12.0,
|
||||
font: "F1".into(),
|
||||
font_size: 12.0,
|
||||
page: 1,
|
||||
},
|
||||
TextItem {
|
||||
text: "World".into(),
|
||||
x: 160.0,
|
||||
y: 700.0,
|
||||
width: 50.0,
|
||||
height: 12.0,
|
||||
font: "F1".into(),
|
||||
font_size: 12.0,
|
||||
page: 1,
|
||||
},
|
||||
TextItem {
|
||||
text: "Next line".into(),
|
||||
x: 100.0,
|
||||
y: 680.0,
|
||||
width: 80.0,
|
||||
height: 12.0,
|
||||
font: "F1".into(),
|
||||
font_size: 12.0,
|
||||
page: 1,
|
||||
},
|
||||
];
|
||||
|
||||
let lines = group_into_lines(items);
|
||||
assert_eq!(lines.len(), 2);
|
||||
assert_eq!(lines[0].text(), "Hello World");
|
||||
assert_eq!(lines[1].text(), "Next line");
|
||||
}
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
//! Smart PDF detection and text extraction using lopdf
|
||||
//!
|
||||
//! This module provides:
|
||||
//! - Fast detection of scanned vs text-based PDFs without full document load
|
||||
//! - Direct text extraction from text-based PDFs
|
||||
//! - Markdown conversion with structure detection
|
||||
|
||||
pub mod detector;
|
||||
pub mod extractor;
|
||||
pub mod markdown;
|
||||
|
||||
pub use detector::{detect_pdf_type, PdfType, PdfTypeResult};
|
||||
pub use extractor::{extract_text, extract_text_with_positions, TextItem};
|
||||
pub use markdown::{to_markdown, MarkdownOptions};
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// High-level PDF processing result
|
||||
#[derive(Debug)]
|
||||
pub struct PdfProcessResult {
|
||||
/// The detected PDF type
|
||||
pub pdf_type: PdfType,
|
||||
/// Extracted text (if text-based PDF)
|
||||
pub text: Option<String>,
|
||||
/// Markdown output (if text-based PDF)
|
||||
pub markdown: Option<String>,
|
||||
/// Page count
|
||||
pub page_count: u32,
|
||||
/// Processing time in milliseconds
|
||||
pub processing_time_ms: u64,
|
||||
}
|
||||
|
||||
/// Process a PDF file with smart detection and extraction
|
||||
///
|
||||
/// This function will:
|
||||
/// 1. Quickly detect if the PDF is text-based or scanned
|
||||
/// 2. If text-based, extract text and convert to markdown
|
||||
/// 3. If scanned, return early indicating OCR is needed
|
||||
pub fn process_pdf<P: AsRef<Path>>(path: P) -> Result<PdfProcessResult, PdfError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// Step 1: Smart detection (fast, no full load)
|
||||
let detection = detect_pdf_type(&path)?;
|
||||
|
||||
let result = match detection.pdf_type {
|
||||
PdfType::TextBased => {
|
||||
// Step 2: Full extraction for text-based PDFs
|
||||
let text = extract_text(&path)?;
|
||||
let markdown = to_markdown(&text, MarkdownOptions::default());
|
||||
|
||||
PdfProcessResult {
|
||||
pdf_type: PdfType::TextBased,
|
||||
text: Some(text),
|
||||
markdown: Some(markdown),
|
||||
page_count: detection.page_count,
|
||||
processing_time_ms: start.elapsed().as_millis() as u64,
|
||||
}
|
||||
}
|
||||
PdfType::Scanned | PdfType::ImageBased => {
|
||||
// Return early - OCR needed
|
||||
PdfProcessResult {
|
||||
pdf_type: detection.pdf_type,
|
||||
text: None,
|
||||
markdown: None,
|
||||
page_count: detection.page_count,
|
||||
processing_time_ms: start.elapsed().as_millis() as u64,
|
||||
}
|
||||
}
|
||||
PdfType::Mixed => {
|
||||
// Try to extract what we can
|
||||
let text = extract_text(&path).ok();
|
||||
let markdown = text.as_ref().map(|t| to_markdown(t, MarkdownOptions::default()));
|
||||
|
||||
PdfProcessResult {
|
||||
pdf_type: PdfType::Mixed,
|
||||
text,
|
||||
markdown,
|
||||
page_count: detection.page_count,
|
||||
processing_time_ms: start.elapsed().as_millis() as u64,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Process PDF from memory buffer
|
||||
pub fn process_pdf_mem(buffer: &[u8]) -> Result<PdfProcessResult, PdfError> {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
// Step 1: Smart detection (fast, no full load)
|
||||
let detection = detector::detect_pdf_type_mem(buffer)?;
|
||||
|
||||
let result = match detection.pdf_type {
|
||||
PdfType::TextBased => {
|
||||
// Step 2: Full extraction for text-based PDFs
|
||||
let text = extractor::extract_text_mem(buffer)?;
|
||||
let markdown = to_markdown(&text, MarkdownOptions::default());
|
||||
|
||||
PdfProcessResult {
|
||||
pdf_type: PdfType::TextBased,
|
||||
text: Some(text),
|
||||
markdown: Some(markdown),
|
||||
page_count: detection.page_count,
|
||||
processing_time_ms: start.elapsed().as_millis() as u64,
|
||||
}
|
||||
}
|
||||
PdfType::Scanned | PdfType::ImageBased => {
|
||||
PdfProcessResult {
|
||||
pdf_type: detection.pdf_type,
|
||||
text: None,
|
||||
markdown: None,
|
||||
page_count: detection.page_count,
|
||||
processing_time_ms: start.elapsed().as_millis() as u64,
|
||||
}
|
||||
}
|
||||
PdfType::Mixed => {
|
||||
let text = extractor::extract_text_mem(buffer).ok();
|
||||
let markdown = text.as_ref().map(|t| to_markdown(t, MarkdownOptions::default()));
|
||||
|
||||
PdfProcessResult {
|
||||
pdf_type: PdfType::Mixed,
|
||||
text,
|
||||
markdown,
|
||||
page_count: detection.page_count,
|
||||
processing_time_ms: start.elapsed().as_millis() as u64,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PdfError {
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("PDF parsing error: {0}")]
|
||||
Parse(String),
|
||||
#[error("PDF is encrypted")]
|
||||
Encrypted,
|
||||
#[error("Invalid PDF structure")]
|
||||
InvalidStructure,
|
||||
}
|
||||
|
||||
impl From<lopdf::Error> for PdfError {
|
||||
fn from(e: lopdf::Error) -> Self {
|
||||
PdfError::Parse(e.to_string())
|
||||
}
|
||||
}
|
||||
+397
@@ -0,0 +1,397 @@
|
||||
//! Markdown conversion with structure detection
|
||||
//!
|
||||
//! This module converts extracted text to markdown, detecting:
|
||||
//! - Headers (by font size)
|
||||
//! - Lists (bullet points, numbered lists)
|
||||
//! - Code blocks (monospace fonts, indentation)
|
||||
//! - Paragraphs
|
||||
|
||||
use crate::extractor::{TextItem, TextLine, group_into_lines};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Options for markdown conversion
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MarkdownOptions {
|
||||
/// Detect headers by font size
|
||||
pub detect_headers: bool,
|
||||
/// Detect list items
|
||||
pub detect_lists: bool,
|
||||
/// Detect code blocks
|
||||
pub detect_code: bool,
|
||||
/// Base font size for comparison
|
||||
pub base_font_size: Option<f32>,
|
||||
}
|
||||
|
||||
impl Default for MarkdownOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
detect_headers: true,
|
||||
detect_lists: true,
|
||||
detect_code: true,
|
||||
base_font_size: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert plain text to markdown (basic conversion)
|
||||
pub fn to_markdown(text: &str, options: MarkdownOptions) -> String {
|
||||
let mut output = String::new();
|
||||
let mut in_list = false;
|
||||
let mut in_code_block = false;
|
||||
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
|
||||
if trimmed.is_empty() {
|
||||
if in_list {
|
||||
in_list = false;
|
||||
}
|
||||
if in_code_block {
|
||||
output.push_str("```\n");
|
||||
in_code_block = false;
|
||||
}
|
||||
output.push('\n');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect list items
|
||||
if options.detect_lists && is_list_item(trimmed) {
|
||||
let formatted = format_list_item(trimmed);
|
||||
output.push_str(&formatted);
|
||||
output.push('\n');
|
||||
in_list = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect code blocks (indented lines)
|
||||
if options.detect_code && is_code_like(trimmed) {
|
||||
if !in_code_block {
|
||||
output.push_str("```\n");
|
||||
in_code_block = true;
|
||||
}
|
||||
output.push_str(trimmed);
|
||||
output.push('\n');
|
||||
continue;
|
||||
} else if in_code_block {
|
||||
output.push_str("```\n");
|
||||
in_code_block = false;
|
||||
}
|
||||
|
||||
// Regular paragraph text
|
||||
output.push_str(trimmed);
|
||||
output.push('\n');
|
||||
}
|
||||
|
||||
if in_code_block {
|
||||
output.push_str("```\n");
|
||||
}
|
||||
|
||||
output
|
||||
}
|
||||
|
||||
/// Convert positioned text items to markdown with structure detection
|
||||
pub fn to_markdown_from_items(items: Vec<TextItem>, options: MarkdownOptions) -> String {
|
||||
let lines = group_into_lines(items);
|
||||
to_markdown_from_lines(lines, options)
|
||||
}
|
||||
|
||||
/// Convert text lines to markdown
|
||||
pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) -> String {
|
||||
if lines.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
// Calculate font statistics
|
||||
let font_stats = calculate_font_stats(&lines);
|
||||
let base_size = options.base_font_size.unwrap_or(font_stats.most_common_size);
|
||||
|
||||
let mut output = String::new();
|
||||
let mut current_page = 0u32;
|
||||
let mut prev_y = f32::MAX;
|
||||
let mut in_list = false;
|
||||
|
||||
for line in lines {
|
||||
// Page break
|
||||
if line.page != current_page {
|
||||
if current_page > 0 {
|
||||
output.push_str("\n---\n\n");
|
||||
}
|
||||
current_page = line.page;
|
||||
prev_y = f32::MAX;
|
||||
}
|
||||
|
||||
// Paragraph break (large Y gap)
|
||||
let y_gap = prev_y - line.y;
|
||||
if y_gap > base_size * 2.0 && !output.ends_with("\n\n") {
|
||||
if in_list {
|
||||
in_list = false;
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
prev_y = line.y;
|
||||
|
||||
let text = line.text();
|
||||
let trimmed = text.trim();
|
||||
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Detect headers by font size
|
||||
if options.detect_headers {
|
||||
let line_font_size = line.items.first().map(|i| i.font_size).unwrap_or(base_size);
|
||||
if let Some(header_level) = detect_header_level(line_font_size, base_size) {
|
||||
let prefix = "#".repeat(header_level);
|
||||
output.push_str(&format!("{} {}\n\n", prefix, trimmed));
|
||||
in_list = false;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Detect list items
|
||||
if options.detect_lists && is_list_item(trimmed) {
|
||||
let formatted = format_list_item(trimmed);
|
||||
output.push_str(&formatted);
|
||||
output.push('\n');
|
||||
in_list = true;
|
||||
continue;
|
||||
} else if in_list {
|
||||
// Check if continuing list or ending
|
||||
if !trimmed.starts_with(char::is_whitespace) {
|
||||
in_list = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Detect code blocks by font
|
||||
if options.detect_code {
|
||||
let is_mono = line.items.iter().any(|i| is_monospace_font(&i.font));
|
||||
if is_mono {
|
||||
output.push_str(&format!("```\n{}\n```\n", trimmed));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Regular text
|
||||
output.push_str(trimmed);
|
||||
output.push('\n');
|
||||
}
|
||||
|
||||
// Clean up excessive newlines
|
||||
clean_markdown(output)
|
||||
}
|
||||
|
||||
/// Font statistics for a document
|
||||
struct FontStats {
|
||||
most_common_size: f32,
|
||||
}
|
||||
|
||||
fn calculate_font_stats(lines: &[TextLine]) -> FontStats {
|
||||
let mut size_counts: HashMap<i32, usize> = HashMap::new();
|
||||
|
||||
for line in lines {
|
||||
for item in &line.items {
|
||||
let size_key = (item.font_size * 10.0) as i32; // Round to 0.1
|
||||
*size_counts.entry(size_key).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
let most_common_size = size_counts
|
||||
.iter()
|
||||
.max_by_key(|(_, count)| *count)
|
||||
.map(|(size, _)| *size as f32 / 10.0)
|
||||
.unwrap_or(12.0);
|
||||
|
||||
FontStats {
|
||||
most_common_size,
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect header level from font size
|
||||
fn detect_header_level(font_size: f32, base_size: f32) -> Option<usize> {
|
||||
let ratio = font_size / base_size;
|
||||
|
||||
if ratio >= 2.0 {
|
||||
Some(1) // H1
|
||||
} else if ratio >= 1.5 {
|
||||
Some(2) // H2
|
||||
} else if ratio >= 1.25 {
|
||||
Some(3) // H3
|
||||
} else if ratio >= 1.1 {
|
||||
Some(4) // H4
|
||||
} else {
|
||||
None // Regular text
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if text looks like a list item
|
||||
fn is_list_item(text: &str) -> bool {
|
||||
let trimmed = text.trim_start();
|
||||
|
||||
// Bullet patterns
|
||||
if trimmed.starts_with("• ")
|
||||
|| trimmed.starts_with("- ")
|
||||
|| trimmed.starts_with("* ")
|
||||
|| trimmed.starts_with("○ ")
|
||||
|| trimmed.starts_with("● ")
|
||||
|| trimmed.starts_with("◦ ")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
// Numbered list patterns: "1.", "1)", "(1)", "a.", "a)"
|
||||
let first_chars: String = trimmed.chars().take(5).collect();
|
||||
if first_chars.contains(|c: char| c.is_ascii_digit()) {
|
||||
// Check for "1.", "1)", "10."
|
||||
if let Some(idx) = first_chars.find(|c: char| c == '.' || c == ')') {
|
||||
let prefix = &first_chars[..idx];
|
||||
if prefix.chars().all(|c| c.is_ascii_digit()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Letter list: "a.", "a)", "(a)"
|
||||
if trimmed.len() >= 2 {
|
||||
let first = trimmed.chars().next().unwrap();
|
||||
let second = trimmed.chars().nth(1).unwrap();
|
||||
if first.is_ascii_alphabetic() && (second == '.' || second == ')') {
|
||||
return true;
|
||||
}
|
||||
if first == '(' && trimmed.chars().nth(2) == Some(')') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Format list item to markdown
|
||||
fn format_list_item(text: &str) -> String {
|
||||
let trimmed = text.trim_start();
|
||||
|
||||
// Convert various bullet styles to markdown
|
||||
if trimmed.starts_with("• ")
|
||||
|| trimmed.starts_with("○ ")
|
||||
|| trimmed.starts_with("● ")
|
||||
|| trimmed.starts_with("◦ ")
|
||||
{
|
||||
return format!("- {}", &trimmed[2..].trim_start());
|
||||
}
|
||||
|
||||
if trimmed.starts_with("- ") || trimmed.starts_with("* ") {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
|
||||
// Keep numbered lists as-is (markdown supports them)
|
||||
trimmed.to_string()
|
||||
}
|
||||
|
||||
/// Check if text looks like code
|
||||
fn is_code_like(text: &str) -> bool {
|
||||
let trimmed = text.trim();
|
||||
|
||||
// Code patterns
|
||||
let code_patterns = [
|
||||
// Language keywords
|
||||
"import ", "export ", "from ", "const ", "let ", "var ", "function ",
|
||||
"class ", "def ", "pub fn ", "fn ", "async fn ", "impl ",
|
||||
// Syntax patterns
|
||||
"=> ", "-> ", ":: ", ":= ",
|
||||
// Common code endings
|
||||
];
|
||||
|
||||
for pattern in &code_patterns {
|
||||
if trimmed.starts_with(pattern) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for code-like syntax
|
||||
let special_chars: usize = trimmed.chars()
|
||||
.filter(|c| matches!(c, '{' | '}' | '(' | ')' | '[' | ']' | ';' | '=' | '<' | '>'))
|
||||
.count();
|
||||
|
||||
if special_chars >= 3 && trimmed.len() < 200 {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Ends with semicolon or braces
|
||||
if trimmed.ends_with(';') || trimmed.ends_with('{') || trimmed.ends_with('}') {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Check if font name indicates monospace
|
||||
fn is_monospace_font(font_name: &str) -> bool {
|
||||
let lower = font_name.to_lowercase();
|
||||
let patterns = [
|
||||
"courier", "consolas", "monaco", "menlo", "mono", "fixed",
|
||||
"terminal", "typewriter", "source code", "fira code",
|
||||
"jetbrains", "inconsolata", "dejavu sans mono", "liberation mono",
|
||||
];
|
||||
|
||||
patterns.iter().any(|p| lower.contains(p))
|
||||
}
|
||||
|
||||
/// Clean up markdown output
|
||||
fn clean_markdown(mut text: String) -> String {
|
||||
// Remove excessive newlines (more than 2 in a row)
|
||||
while text.contains("\n\n\n") {
|
||||
text = text.replace("\n\n\n", "\n\n");
|
||||
}
|
||||
|
||||
// Ensure ends with single newline
|
||||
text = text.trim_end().to_string();
|
||||
text.push('\n');
|
||||
|
||||
text
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_list_item() {
|
||||
assert!(is_list_item("• Item one"));
|
||||
assert!(is_list_item("- Item two"));
|
||||
assert!(is_list_item("* Item three"));
|
||||
assert!(is_list_item("1. First"));
|
||||
assert!(is_list_item("2) Second"));
|
||||
assert!(is_list_item("a. Letter item"));
|
||||
assert!(!is_list_item("Regular text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_list_item() {
|
||||
assert_eq!(format_list_item("• Item"), "- Item");
|
||||
assert_eq!(format_list_item("- Item"), "- Item");
|
||||
assert_eq!(format_list_item("1. First"), "1. First");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_code_like() {
|
||||
assert!(is_code_like("const x = 5;"));
|
||||
assert!(is_code_like("function foo() {"));
|
||||
assert!(is_code_like("import React from 'react'"));
|
||||
assert!(!is_code_like("This is regular text."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_header_level() {
|
||||
assert_eq!(detect_header_level(24.0, 12.0), Some(1));
|
||||
assert_eq!(detect_header_level(18.0, 12.0), Some(2));
|
||||
assert_eq!(detect_header_level(15.0, 12.0), Some(3));
|
||||
assert_eq!(detect_header_level(12.0, 12.0), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_to_markdown() {
|
||||
let text = "• First item\n• Second item\n\nRegular paragraph.";
|
||||
let md = to_markdown(text, MarkdownOptions::default());
|
||||
assert!(md.contains("- First item"));
|
||||
assert!(md.contains("- Second item"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user