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:
co-authored by
Claude Opus 4.6
parent
aa387503c3
commit
a199768c4e
@@ -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 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
|
||||
struct MarkedContentEntry {
|
||||
actual_text: Option<String>,
|
||||
@@ -280,6 +288,11 @@ pub(crate) fn extract_page_text_items(
|
||||
let combined = multiply_matrices(&text_matrix, &ctm);
|
||||
let rendered_size = effective_font_size(current_font_size, &combined);
|
||||
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 {
|
||||
text_matrix[4] += w_ts * text_matrix[0];
|
||||
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
|
||||
if !sub_items.is_empty() {
|
||||
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 base_font = font_base_names
|
||||
.get(¤t_font)
|
||||
@@ -495,6 +513,11 @@ pub(crate) fn extract_page_text_items(
|
||||
) {
|
||||
if !text.trim().is_empty() {
|
||||
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 (x, y) = (combined[4], combined[5]);
|
||||
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
|
||||
if let Some(start_tm) = actual_text_start_tm.take() {
|
||||
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 (x, y) = (combined[4], combined[5]);
|
||||
// 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_subscript_items(items);
|
||||
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).
|
||||
/// Some PDFs emit a full-page clip path for every text block, producing
|
||||
/// thousands of identical rects. After dedup these collapse to one rect,
|
||||
|
||||
BIN
Binary file not shown.
@@ -4,8 +4,8 @@ use pdf_inspector::detector::{DetectionConfig, ScanStrategy};
|
||||
use pdf_inspector::extractor::group_into_lines;
|
||||
use pdf_inspector::types::TextLine;
|
||||
use pdf_inspector::{
|
||||
detect_pdf_type, extract_text, extract_text_with_positions, to_markdown, MarkdownOptions,
|
||||
PdfError, PdfType, TextItem,
|
||||
detect_pdf_type, extract_text, extract_text_with_positions, process_pdf_with_options,
|
||||
to_markdown, MarkdownOptions, PdfError, PdfOptions, PdfType, TextItem,
|
||||
};
|
||||
|
||||
// Helper to create test TextItems
|
||||
@@ -1061,3 +1061,49 @@ fn test_identity_h_no_tounicode_suppresses_garbage() {
|
||||
&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"
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user