Files
pdf-inspector/src/extractor.rs
T
2026-02-07 19:57:05 -08:00

668 lines
23 KiB
Rust

//! 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)
}
/// Multiply two 2D transformation matrices
/// Matrix format: [a, b, c, d, e, f] representing:
/// | a b 0 |
/// | c d 0 |
/// | e f 1 |
fn multiply_matrices(m1: &[f32; 6], m2: &[f32; 6]) -> [f32; 6] {
[
m1[0] * m2[0] + m1[1] * m2[2],
m1[0] * m2[1] + m1[1] * m2[3],
m1[2] * m2[0] + m1[3] * m2[2],
m1[2] * m2[1] + m1[3] * m2[3],
m1[4] * m2[0] + m1[5] * m2[2] + m2[4],
m1[4] * m2[1] + m1[5] * m2[3] + m2[5],
]
}
/// 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()))?;
// Graphics state tracking
let mut ctm = [1.0f32, 0.0, 0.0, 1.0, 0.0, 0.0]; // Current Transformation Matrix
let mut ctm_stack: Vec<[f32; 6]> = Vec::new();
// 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() {
"q" => {
// Save graphics state
ctm_stack.push(ctm);
}
"Q" => {
// Restore graphics state
if let Some(saved) = ctm_stack.pop() {
ctm = saved;
}
}
"cm" => {
// Concatenate matrix to CTM
if op.operands.len() >= 6 {
let new_matrix = [
get_number(&op.operands[0]).unwrap_or(1.0),
get_number(&op.operands[1]).unwrap_or(0.0),
get_number(&op.operands[2]).unwrap_or(0.0),
get_number(&op.operands[3]).unwrap_or(1.0),
get_number(&op.operands[4]).unwrap_or(0.0),
get_number(&op.operands[5]).unwrap_or(0.0),
];
ctm = multiply_matrices(&new_matrix, &ctm);
}
}
"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, &current_font)
{
if !text.trim().is_empty() {
let rendered_size =
effective_font_size(current_font_size, &text_matrix);
// Transform position through CTM
let combined = multiply_matrices(&text_matrix, &ctm);
let (x, y) = (combined[4], combined[5]);
items.push(TextItem {
text,
x,
y,
width: 0.0, // Would need glyph widths
height: rendered_size,
font: current_font.clone(),
font_size: rendered_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, &current_font)
{
combined_text.push_str(&text);
}
}
if !combined_text.trim().is_empty() {
let rendered_size =
effective_font_size(current_font_size, &text_matrix);
// Transform position through CTM
let combined = multiply_matrices(&text_matrix, &ctm);
let (x, y) = (combined[4], combined[5]);
items.push(TextItem {
text: combined_text,
x,
y,
width: 0.0,
height: rendered_size,
font: current_font.clone(),
font_size: rendered_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, &current_font)
{
if !text.trim().is_empty() {
let rendered_size =
effective_font_size(current_font_size, &text_matrix);
// Transform position through CTM
let combined = multiply_matrices(&text_matrix, &ctm);
let (x, y) = (combined[4], combined[5]);
items.push(TextItem {
text,
x,
y,
width: 0.0,
height: rendered_size,
font: current_font.clone(),
font_size: rendered_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),
_ => None,
}
}
/// Compute effective font size from base size and text matrix
/// Text matrix is [a, b, c, d, tx, ty] where a,d are scale factors
fn effective_font_size(base_size: f32, text_matrix: &[f32; 6]) -> f32 {
// The scale factor is typically the magnitude of the transformation
// For most PDFs, text_matrix[0] (a) is the horizontal scale
// and text_matrix[3] (d) is the vertical scale
let scale_x = (text_matrix[0].powi(2) + text_matrix[1].powi(2)).sqrt();
let scale_y = (text_matrix[2].powi(2) + text_matrix[3].powi(2)).sqrt();
// Use the larger of the two scales (usually they're equal for non-rotated text)
let scale = scale_x.max(scale_y);
base_size * scale
}
/// 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
}
}
/// Represents a column region on a page
#[derive(Debug, Clone)]
struct ColumnRegion {
x_min: f32,
x_max: f32,
}
/// Detect column boundaries on a page based on X-position gaps
fn detect_columns(items: &[TextItem], page: u32) -> Vec<ColumnRegion> {
// Get items for this page
let page_items: Vec<&TextItem> = items.iter().filter(|i| i.page == page).collect();
if page_items.is_empty() {
return vec![];
}
// Find page bounds
let x_min = page_items.iter().map(|i| i.x).fold(f32::INFINITY, f32::min);
let x_max = page_items
.iter()
.map(|i| i.x + i.width.max(50.0)) // Estimate right edge
.fold(f32::NEG_INFINITY, f32::max);
let page_width = x_max - x_min;
if page_width < 200.0 {
// Page too narrow for multi-column, single column
return vec![ColumnRegion { x_min, x_max }];
}
// Need enough items to reliably detect columns
if page_items.len() < 20 {
return vec![ColumnRegion { x_min, x_max }];
}
// Collect all X positions (left edge of each text item)
let mut x_positions: Vec<f32> = page_items.iter().map(|i| i.x).collect();
x_positions.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal));
// Find gaps in X positions
// A gap > 20% of page width suggests column boundary
let gap_threshold = page_width * 0.20;
let mut column_boundaries = vec![x_min];
for window in x_positions.windows(2) {
let gap = window[1] - window[0];
if gap > gap_threshold {
// Found a column boundary - use midpoint of gap
let boundary = (window[0] + window[1]) / 2.0;
column_boundaries.push(boundary);
}
}
column_boundaries.push(x_max + 1.0);
// Convert boundaries to column regions
let mut columns = Vec::new();
for i in 0..column_boundaries.len() - 1 {
columns.push(ColumnRegion {
x_min: column_boundaries[i],
x_max: column_boundaries[i + 1],
});
}
// Only use multi-column if we have exactly 2 columns
// (most common case; 3+ columns are rare and error-prone)
if columns.len() == 2 {
// Verify both columns have substantial content
let col_counts: Vec<usize> = columns
.iter()
.map(|col| {
page_items
.iter()
.filter(|i| i.x >= col.x_min && i.x < col.x_max)
.count()
})
.collect();
// Each column should have at least 20% of the content
let total: usize = col_counts.iter().sum();
let min_threshold = total / 5;
if col_counts.iter().all(|&c| c >= min_threshold) {
return columns;
}
}
// For 3+ detected columns, try merging adjacent small columns
if columns.len() > 2 {
let col_counts: Vec<usize> = columns
.iter()
.map(|col| {
page_items
.iter()
.filter(|i| i.x >= col.x_min && i.x < col.x_max)
.count()
})
.collect();
// Find the largest gap between columns that have substantial content
let total: usize = col_counts.iter().sum();
let min_items = total / 5; // 20% minimum
// Find first and last columns with enough content
let first_substantial = col_counts.iter().position(|&c| c >= min_items);
let last_substantial = col_counts.iter().rposition(|&c| c >= min_items);
if let (Some(first), Some(last)) = (first_substantial, last_substantial) {
if first != last {
// Create two columns: merge everything before the gap and after
return vec![
ColumnRegion {
x_min: columns[0].x_min,
x_max: columns[first].x_max,
},
ColumnRegion {
x_min: columns[last].x_min,
x_max: columns[columns.len() - 1].x_max,
},
];
}
}
}
// Default to single column
vec![ColumnRegion { x_min, x_max }]
}
/// Group text items into lines, with multi-column support
pub fn group_into_lines(items: Vec<TextItem>) -> Vec<TextLine> {
if items.is_empty() {
return Vec::new();
}
// Get unique pages
let mut pages: Vec<u32> = items.iter().map(|i| i.page).collect();
pages.sort();
pages.dedup();
let mut all_lines = Vec::new();
for page in pages {
let page_items: Vec<TextItem> = items.iter().filter(|i| i.page == page).cloned().collect();
// Detect columns for this page
let columns = detect_columns(&page_items, page);
if columns.len() <= 1 {
// Single column - use simple sorting
let lines = group_single_column(page_items);
all_lines.extend(lines);
} else {
// Multi-column - process each column separately, then concatenate
for column in &columns {
let col_items: Vec<TextItem> = page_items
.iter()
.filter(|i| i.x >= column.x_min && i.x < column.x_max)
.cloned()
.collect();
let lines = group_single_column(col_items);
all_lines.extend(lines);
}
}
}
all_lines
}
/// Determine if Y-sorting should be used instead of stream order.
/// Returns true if the stream order appears chaotic (items jump around in Y position).
fn should_use_y_sorting(items: &[TextItem]) -> bool {
if items.len() < 5 {
return false; // Not enough items to judge
}
// Sample Y positions from stream order
let y_positions: Vec<f32> = items.iter().map(|i| i.y).collect();
// Count "order violations" - cases where Y increases (going up) when it should decrease
// In proper reading order, Y should generally decrease (top to bottom)
let mut large_jumps_up = 0;
let mut large_jumps_down = 0;
let jump_threshold = 50.0; // Significant Y jump
for window in y_positions.windows(2) {
let delta = window[1] - window[0];
if delta > jump_threshold {
large_jumps_up += 1; // Y increased significantly (jumped up on page)
} else if delta < -jump_threshold {
large_jumps_down += 1; // Y decreased significantly (normal reading direction)
}
}
// If there are many upward jumps relative to downward jumps, order is chaotic
// A well-ordered document should have mostly downward progression
let total_jumps = large_jumps_up + large_jumps_down;
if total_jumps < 3 {
return false; // Not enough jumps to judge
}
// If more than 40% of large jumps are upward, use Y-sorting
let chaos_ratio = large_jumps_up as f32 / total_jumps as f32;
chaos_ratio > 0.4
}
/// Group items from a single column into lines
/// Uses heuristics to decide between PDF stream order and Y-position sorting.
fn group_single_column(items: Vec<TextItem>) -> Vec<TextLine> {
if items.is_empty() {
return Vec::new();
}
// Decide whether to use stream order or Y-sorting
let use_y_sorting = should_use_y_sorting(&items);
let items = if use_y_sorting {
// Sort by Y descending (top to bottom in PDF coords)
let mut sorted = items;
sorted.sort_by(|a, b| {
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))
});
sorted
} else {
items
};
// Group items into lines
let mut lines: Vec<TextLine> = Vec::new();
let y_tolerance = 3.0;
for item in items {
// Only check the most recent line for merging
let should_merge = lines.last().map_or(false, |last_line| {
last_line.page == item.page && (last_line.y - item.y).abs() < y_tolerance
});
if should_merge {
// Add to the most recent line
lines.last_mut().unwrap().items.push(item);
} else {
// Create new line
let y = item.y;
let page = item.page;
lines.push(TextLine {
items: vec![item],
y,
page,
});
}
}
// Sort items within each line by X position (left to right)
for line in &mut lines {
line.items
.sort_by(|a, b| a.x.partial_cmp(&b.x).unwrap_or(std::cmp::Ordering::Equal));
}
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");
}
}