fix(layout): detect and correct rotated page text (#10)

PDFs that embed landscape content in portrait pages via a rotated text
matrix (e.g. [0, b, -b, 0, tx, ty] for 90° CCW) produced garbled output
because the layout engine assumed x=horizontal, y=vertical.

Track the dominant text direction from combined matrices during extraction.
When ≥67% of text operators are rotated, swap x↔y coordinates (with
y-negation for correct reading order) for all text items, rects, and lines.
Also estimate text widths from char count × font size since scale_x ≈ 0
for rotated text.

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-03-23 12:14:44 -07:00
committed by GitHub
co-authored by Claude Opus 4.6
parent aa387503c3
commit a199768c4e
3 changed files with 161 additions and 2 deletions
+113
View File
@@ -128,6 +128,14 @@ pub(crate) fn extract_page_text_items(
let mut line_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; let mut in_text_block = false;
// Track text direction votes: (horizontal_count, rotated_count).
// For each text item, if |combined[0]| > |combined[1]| the text runs
// horizontally (normal); otherwise it's rotated ~90°.
let mut rotation_votes = RotationVotes {
horizontal: 0,
rotated: 0,
};
// Marked content tracking: (ActualText, MCID) per nesting level // Marked content tracking: (ActualText, MCID) per nesting level
struct MarkedContentEntry { struct MarkedContentEntry {
actual_text: Option<String>, actual_text: Option<String>,
@@ -280,6 +288,11 @@ pub(crate) fn extract_page_text_items(
let combined = multiply_matrices(&text_matrix, &ctm); let combined = multiply_matrices(&text_matrix, &ctm);
let rendered_size = effective_font_size(current_font_size, &combined); let rendered_size = effective_font_size(current_font_size, &combined);
let (x, y) = (combined[4], combined[5]); let (x, y) = (combined[4], combined[5]);
if combined[0].abs() >= combined[1].abs() {
rotation_votes.horizontal += 1;
} else {
rotation_votes.rotated += 1;
}
let width = if let Some(w_ts) = w_ts_opt { let width = if let Some(w_ts) = w_ts_opt {
text_matrix[4] += w_ts * text_matrix[0]; text_matrix[4] += w_ts * text_matrix[0];
text_matrix[5] += w_ts * text_matrix[1]; text_matrix[5] += w_ts * text_matrix[1];
@@ -422,6 +435,11 @@ pub(crate) fn extract_page_text_items(
// Emit one TextItem per sub-item // Emit one TextItem per sub-item
if !sub_items.is_empty() { if !sub_items.is_empty() {
let combined = multiply_matrices(&text_matrix, &ctm); let combined = multiply_matrices(&text_matrix, &ctm);
if combined[0].abs() >= combined[1].abs() {
rotation_votes.horizontal += 1;
} else {
rotation_votes.rotated += 1;
}
let rendered_size = effective_font_size(current_font_size, &combined); let rendered_size = effective_font_size(current_font_size, &combined);
let base_font = font_base_names let base_font = font_base_names
.get(&current_font) .get(&current_font)
@@ -495,6 +513,11 @@ pub(crate) fn extract_page_text_items(
) { ) {
if !text.trim().is_empty() { if !text.trim().is_empty() {
let combined = multiply_matrices(&text_matrix, &ctm); let combined = multiply_matrices(&text_matrix, &ctm);
if combined[0].abs() >= combined[1].abs() {
rotation_votes.horizontal += 1;
} else {
rotation_votes.rotated += 1;
}
let rendered_size = effective_font_size(current_font_size, &combined); let rendered_size = effective_font_size(current_font_size, &combined);
let (x, y) = (combined[4], combined[5]); let (x, y) = (combined[4], combined[5]);
let base_font = font_base_names let base_font = font_base_names
@@ -589,6 +612,11 @@ pub(crate) fn extract_page_text_items(
// Compute width from text matrix advancement during BDC..EMC // Compute width from text matrix advancement during BDC..EMC
if let Some(start_tm) = actual_text_start_tm.take() { if let Some(start_tm) = actual_text_start_tm.take() {
let combined = multiply_matrices(&start_tm, &ctm); let combined = multiply_matrices(&start_tm, &ctm);
if combined[0].abs() >= combined[1].abs() {
rotation_votes.horizontal += 1;
} else {
rotation_votes.rotated += 1;
}
let rendered_size = effective_font_size(current_font_size, &combined); let rendered_size = effective_font_size(current_font_size, &combined);
let (x, y) = (combined[4], combined[5]); let (x, y) = (combined[4], combined[5]);
// Width in device space from text matrix delta // Width in device space from text matrix delta
@@ -879,11 +907,96 @@ pub(crate) fn extract_page_text_items(
} }
} }
// Detect dominant text rotation and transform coordinates if needed.
// Some PDFs embed landscape content in portrait pages using a rotated text
// matrix (e.g. [0, b, -b, 0, tx, ty] for 90° CCW). The layout engine
// assumes x=horizontal, y=vertical — so we swap coordinates to match.
let (items, rects, lines) = correct_rotated_page(items, rects, lines, &rotation_votes);
let items = super::merge_text_items(items); let items = super::merge_text_items(items);
let items = super::merge_subscript_items(items); let items = super::merge_subscript_items(items);
Ok(((items, rects, lines), has_gid_fonts)) Ok(((items, rects, lines), has_gid_fonts))
} }
/// Counts of text operators with horizontal vs rotated combined matrices.
struct RotationVotes {
horizontal: u32,
rotated: u32,
}
/// Detect if most text items on a page are rotated 90° or 270°, and if so,
/// swap x↔y coordinates (plus widths/heights) so the layout engine sees
/// them as horizontal text on a landscape page.
fn correct_rotated_page(
mut items: Vec<TextItem>,
mut rects: Vec<PdfRect>,
mut lines: Vec<PdfLine>,
votes: &RotationVotes,
) -> (Vec<TextItem>, Vec<PdfRect>, Vec<PdfLine>) {
if items.len() < 2 {
return (items, rects, lines);
}
// Use the combined-matrix direction votes collected during extraction.
// For normal text, combined[0] (the x-component of the text x-axis) is
// large; for 90° rotated text, combined[1] dominates instead.
let total_votes = votes.horizontal + votes.rotated;
if total_votes == 0 || votes.rotated * 3 < total_votes * 2 {
// Less than ~67% of text operators are rotated → not a rotated page
return (items, rects, lines);
}
log::debug!(
"detected rotated page text: {}/{} text ops are rotated — swapping coordinates",
votes.rotated,
total_votes
);
// For 90° CCW rotation (the common case: Tm = [0, b, -b, 0, tx, ty]):
// device x increases = visual "down" → negate when mapping to y
// device y increases = visual "right" → use directly as x
// The layout engine sorts by y descending (highest = top of page), so
// we negate old_x so that visual-top (low device x) gets high new_y.
for item in &mut items {
let new_x = item.y;
let new_y = -item.x;
item.x = new_x;
item.y = new_y;
// For rotated text, the "width" along the reading direction was
// lost (computed as 0 due to scale_x ≈ 0). Estimate from text
// length × approximate char width. font_size is the rendered
// height in device space, which for 90° rotation corresponds to
// the horizontal extent of one em.
if item.width < 0.5 {
let char_count = item.text.chars().count() as f32;
item.width = char_count * item.font_size * 0.5;
}
}
// Transform rectangles
for rect in &mut rects {
let new_x = rect.y;
let new_y = -(rect.x + rect.width.abs());
rect.x = new_x;
rect.y = new_y;
std::mem::swap(&mut rect.width, &mut rect.height);
}
// Transform lines
for line in &mut lines {
let new_x1 = line.y1;
let new_y1 = -line.x1;
let new_x2 = line.y2;
let new_y2 = -line.x2;
line.x1 = new_x1;
line.y1 = new_y1;
line.x2 = new_x2;
line.y2 = new_y2;
}
(items, rects, lines)
}
/// Remove near-duplicate rects (same coordinates within 0.5 pt tolerance). /// Remove near-duplicate rects (same coordinates within 0.5 pt tolerance).
/// Some PDFs emit a full-page clip path for every text block, producing /// Some PDFs emit a full-page clip path for every text block, producing
/// thousands of identical rects. After dedup these collapse to one rect, /// thousands of identical rects. After dedup these collapse to one rect,
Binary file not shown.
+48 -2
View File
@@ -4,8 +4,8 @@ use pdf_inspector::detector::{DetectionConfig, ScanStrategy};
use pdf_inspector::extractor::group_into_lines; use pdf_inspector::extractor::group_into_lines;
use pdf_inspector::types::TextLine; use pdf_inspector::types::TextLine;
use pdf_inspector::{ use pdf_inspector::{
detect_pdf_type, extract_text, extract_text_with_positions, to_markdown, MarkdownOptions, detect_pdf_type, extract_text, extract_text_with_positions, process_pdf_with_options,
PdfError, PdfType, TextItem, to_markdown, MarkdownOptions, PdfError, PdfOptions, PdfType, TextItem,
}; };
// Helper to create test TextItems // Helper to create test TextItems
@@ -1061,3 +1061,49 @@ fn test_identity_h_no_tounicode_suppresses_garbage() {
&md[..md.len().min(100)] &md[..md.len().min(100)]
); );
} }
#[test]
fn test_rotated_table_layout_correction() {
// tnagriculture_06_12.pdf has landscape content in a portrait page via
// a 90° CCW text matrix [0, b, -b, 0, tx, ty]. Without rotation
// correction, the table is read sideways (jumbled numbers).
let result =
process_pdf_with_options("tests/fixtures/tnagriculture_06_12.pdf", PdfOptions::new())
.unwrap();
let md = result.markdown.unwrap_or_default();
// Title should appear near the top
assert!(
md.contains("DISTRICT WISE PRODUCTION OF SPICES AND CONDIMENTS"),
"Should extract the table title"
);
// District names should be readable (not jumbled with numbers)
assert!(
md.contains("Ariyalur"),
"Should extract district name Ariyalur"
);
assert!(
md.contains("Coimbatore"),
"Should extract district name Coimbatore"
);
// Spice column headers should appear
assert!(
md.contains("CARDAMOM"),
"Should extract spice header CARDAMOM"
);
assert!(
md.contains("RED CHILLIES"),
"Should extract spice header RED CHILLIES"
);
// Table should be formatted as markdown table (has pipe delimiters)
let has_table_row = md
.lines()
.any(|l: &str| l.contains('|') && l.contains("Ariyalur"));
assert!(
has_table_row,
"District data should be in a markdown table row"
);
}