Improve text extraction with visual reading order and header detection

Major changes:
- Switch from lopdf.extract_text() to position-aware extraction
- Text is now sorted by visual reading order (top→bottom, left→right)
- Fixed font size calculation to account for text matrix scaling
- Headers are now detected based on font size ratios
- Skip very short text (≤3 chars) for header detection to avoid drop caps

This significantly improves output quality for PDFs with complex layouts.
Before: Title appeared at end, no structure detected
After: Correct reading order, headers marked with # syntax

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-02-07 13:34:37 -08:00
co-authored by Claude Opus 4.5
parent f99d155c52
commit c14e26495d
6 changed files with 107 additions and 27 deletions
+22 -6
View File
@@ -175,14 +175,15 @@ fn extract_page_text_items(
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);
items.push(TextItem {
text,
x: text_matrix[4],
y: text_matrix[5],
width: 0.0, // Would need glyph widths
height: current_font_size,
height: rendered_size,
font: current_font.clone(),
font_size: current_font_size,
font_size: rendered_size,
page: page_num,
});
}
@@ -202,14 +203,15 @@ fn extract_page_text_items(
}
}
if !combined_text.trim().is_empty() {
let rendered_size = effective_font_size(current_font_size, &text_matrix);
items.push(TextItem {
text: combined_text,
x: text_matrix[4],
y: text_matrix[5],
width: 0.0,
height: current_font_size,
height: rendered_size,
font: current_font.clone(),
font_size: current_font_size,
font_size: rendered_size,
page: page_num,
});
}
@@ -225,14 +227,15 @@ fn extract_page_text_items(
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);
items.push(TextItem {
text,
x: text_matrix[4],
y: text_matrix[5],
width: 0.0,
height: current_font_size,
height: rendered_size,
font: current_font.clone(),
font_size: current_font_size,
font_size: rendered_size,
page: page_num,
});
}
@@ -255,6 +258,19 @@ fn get_number(obj: &Object) -> Option<f32> {
}
}
/// 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,
+16 -20
View File
@@ -11,7 +11,7 @@ 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};
pub use markdown::{to_markdown, to_markdown_from_items, MarkdownOptions};
use std::path::Path;
@@ -44,13 +44,13 @@ pub fn process_pdf<P: AsRef<Path>>(path: P) -> Result<PdfProcessResult, PdfError
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());
// Step 2: Full extraction with position-aware reading order
let items = extract_text_with_positions(&path)?;
let markdown = to_markdown_from_items(items, MarkdownOptions::default());
PdfProcessResult {
pdf_type: PdfType::TextBased,
text: Some(text),
text: None, // We now produce markdown directly
markdown: Some(markdown),
page_count: detection.page_count,
processing_time_ms: start.elapsed().as_millis() as u64,
@@ -67,15 +67,13 @@ pub fn process_pdf<P: AsRef<Path>>(path: P) -> Result<PdfProcessResult, PdfError
}
}
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()));
// Try to extract what we can with position-aware reading order
let items = extract_text_with_positions(&path).ok();
let markdown = items.map(|i| to_markdown_from_items(i, MarkdownOptions::default()));
PdfProcessResult {
pdf_type: PdfType::Mixed,
text,
text: None,
markdown,
page_count: detection.page_count,
processing_time_ms: start.elapsed().as_millis() as u64,
@@ -95,13 +93,13 @@ pub fn process_pdf_mem(buffer: &[u8]) -> Result<PdfProcessResult, PdfError> {
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());
// Step 2: Full extraction with position-aware reading order
let items = extractor::extract_text_with_positions_mem(buffer)?;
let markdown = to_markdown_from_items(items, MarkdownOptions::default());
PdfProcessResult {
pdf_type: PdfType::TextBased,
text: Some(text),
text: None,
markdown: Some(markdown),
page_count: detection.page_count,
processing_time_ms: start.elapsed().as_millis() as u64,
@@ -115,14 +113,12 @@ pub fn process_pdf_mem(buffer: &[u8]) -> Result<PdfProcessResult, PdfError> {
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()));
let items = extractor::extract_text_with_positions_mem(buffer).ok();
let markdown = items.map(|i| to_markdown_from_items(i, MarkdownOptions::default()));
PdfProcessResult {
pdf_type: PdfType::Mixed,
text,
text: None,
markdown,
page_count: detection.page_count,
processing_time_ms: start.elapsed().as_millis() as u64,
+2 -1
View File
@@ -140,7 +140,8 @@ pub fn to_markdown_from_lines(lines: Vec<TextLine>, options: MarkdownOptions) ->
}
// Detect headers by font size
if options.detect_headers {
// Skip very short text (likely drop caps or labels)
if options.detect_headers && trimmed.len() > 3 {
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);