fix(extractor): Scale Td/TD offsets by text matrix to fix text positioning

The Td/TD operators were adding tx/ty offsets directly to the text line
matrix position without multiplying by the matrix scale factors. Per the
PDF spec, Td translates in text space: e_new = tx*a + ty*c + e. When the
text matrix contains font scaling (e.g., Tm [12,0,0,12,x,y] instead of
using Tf for size), all Td offsets were ~12x too small, causing items
from different visual lines to cluster at nearly identical Y coordinates
and items on the same line to have wrong X positions.

This fix:
- Scales Td/TD tx,ty by line_matrix[0..3] per PDF spec
- Adds TL operator tracking for proper text leading
- Fixes T* and ' operators to use TL with matrix scaling
- Fixes XObject form Td handler with same scaling

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Abimael Martell
2026-02-16 09:01:47 -08:00
co-authored by Claude Opus 4.6
parent eb0168f77b
commit 73ade64fac
+32 -9
View File
@@ -979,6 +979,7 @@ fn extract_page_text_items(
// Text state tracking
let mut current_font = String::new();
let mut current_font_size: f32 = 12.0;
let mut text_leading: f32 = 0.0; // TL parameter (in text-space units)
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;
@@ -1058,14 +1059,24 @@ fn extract_page_text_items(
}
}
}
"TL" => {
// Set text leading (used by T*, ', and " operators)
if let Some(tl) = op.operands.first().and_then(get_number) {
text_leading = tl;
}
}
"Td" | "TD" => {
// Move text position
// Move text position: TLM = T(tx,ty) × TLM; Tm = TLM
// tx,ty are in text space — must be scaled by the text line matrix
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;
line_matrix[4] += tx * line_matrix[0] + ty * line_matrix[2];
line_matrix[5] += tx * line_matrix[1] + ty * line_matrix[3];
text_matrix = line_matrix;
if op.operator == "TD" {
text_leading = -ty;
}
}
}
"Tm" => {
@@ -1079,8 +1090,14 @@ fn extract_page_text_items(
}
}
"T*" => {
// Move to start of next line
line_matrix[5] -= current_font_size * 1.2; // Approximate line height
// Move to start of next line: equivalent to 0 -TL Td
let tl = if text_leading != 0.0 {
text_leading
} else {
current_font_size * 1.2
};
line_matrix[4] += (-tl) * line_matrix[2]; // Usually 0 for non-rotated text
line_matrix[5] += (-tl) * line_matrix[3];
text_matrix = line_matrix;
}
"Tj" => {
@@ -1258,8 +1275,14 @@ fn extract_page_text_items(
}
}
"'" => {
// Move to next line and show text
line_matrix[5] -= current_font_size * 1.2;
// Move to next line and show text (equivalent to T* then Tj)
let tl = if text_leading != 0.0 {
text_leading
} else {
current_font_size * 1.2
};
line_matrix[4] += (-tl) * line_matrix[2];
line_matrix[5] += (-tl) * line_matrix[3];
text_matrix = line_matrix;
if !fill_is_white && !op.operands.is_empty() {
if let Some(text) = extract_text_from_operand(
@@ -1503,8 +1526,8 @@ fn extract_form_xobject_text(
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);
text_matrix[4] += tx;
text_matrix[5] += ty;
text_matrix[4] += tx * text_matrix[0] + ty * text_matrix[2];
text_matrix[5] += tx * text_matrix[1] + ty * text_matrix[3];
}
}
"Tm" => {