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 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(¤t_font)
|
.get(¤t_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,
|
||||||
|
|||||||
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::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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user