Compare commits

...
Author SHA1 Message Date
Abimael Martell e482978008 chore: allow clippy::collapsible_match for Rust 1.95
Rust 1.95 introduced the collapsible_match lint which flags `if`
blocks inside match arms that could be converted to match guards.
The content-stream parsers use this pattern extensively for
readability (match on PDF operator name, then check preconditions
like `in_text_block && !op.operands.is_empty()`). Allow crate-wide
rather than refactoring 21 match arms across the parser files.
2026-04-16 17:10:09 -07:00
Abimael Martell b88eb24e94 fix: use first-glyph position for ActualText ligature items
The content stream parser emitted ActualText items (from BDC/Span
marked content) at the text matrix position captured at BDC entry.
When the content stream contains Td operators between BDC and the
first glyph Tj — common in PDFs generated by Google Docs, Figma,
and web-to-PDF tools — the BDC-entry position is on the previous
visual line while the actual glyph renders on the correct line.

This caused ligature glyphs (fi, fl, ff, etc.) wrapped in
ActualText spans to be positioned one text-leading offset above
their surrounding text. Downstream merge logic couldn't reconnect
them (different y-band), producing broken words like "fi" + "ndings"
instead of "findings" throughout the document.

Fix: capture the text matrix at the first Tj/TJ operation inside
the BDC block (after any Td repositioning), and use that as the
ActualText item's rendering position at EMC time. Falls back to the
BDC-entry position when no glyph ops occur inside the block.

One new variable, five insertion points, zero behavior change on
PDFs that don't use ActualText marked content.
2026-04-16 16:53:49 -07:00
2 changed files with 27 additions and 3 deletions
+21 -3
View File
@@ -216,6 +216,7 @@ pub(crate) fn extract_page_text_items(
let mut marked_content_stack: Vec<MarkedContentEntry> = Vec::new(); let mut marked_content_stack: Vec<MarkedContentEntry> = Vec::new();
let mut suppress_glyph_extraction = false; let mut suppress_glyph_extraction = false;
let mut actual_text_start_tm: Option<[f32; 6]> = None; // text matrix at BDC entry let mut actual_text_start_tm: Option<[f32; 6]> = None; // text matrix at BDC entry
let mut actual_text_glyph_tm: Option<[f32; 6]> = None; // text matrix at first glyph inside BDC
/// Get the innermost MCID from the marked content stack. /// Get the innermost MCID from the marked content stack.
fn current_mcid(stack: &[MarkedContentEntry]) -> Option<i64> { fn current_mcid(stack: &[MarkedContentEntry]) -> Option<i64> {
stack.iter().rev().find_map(|e| e.mcid) stack.iter().rev().find_map(|e| e.mcid)
@@ -349,8 +350,15 @@ pub(crate) fn extract_page_text_items(
) )
}) })
}); });
// ActualText: suppress glyph extraction, just advance text matrix // ActualText: suppress glyph extraction, just advance text matrix.
// Capture the FIRST glyph's text matrix as the rendering position
// for the ActualText item. Td ops between BDC and the first Tj
// may have moved the position to the correct line — the BDC-entry
// position (actual_text_start_tm) can be on the previous line.
if suppress_glyph_extraction { if suppress_glyph_extraction {
if actual_text_glyph_tm.is_none() {
actual_text_glyph_tm = Some(text_matrix);
}
if let Some(w_ts) = w_ts_opt { 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];
@@ -425,6 +433,10 @@ pub(crate) fn extract_page_text_items(
let font_info = font_widths.get(&current_font); let font_info = font_widths.get(&current_font);
let is_invisible = (text_rendering_mode == 3 && !include_invisible) let is_invisible = (text_rendering_mode == 3 && !include_invisible)
|| suppress_glyph_extraction; || suppress_glyph_extraction;
// Capture first-glyph position for ActualText
if suppress_glyph_extraction && actual_text_glyph_tm.is_none() {
actual_text_glyph_tm = Some(text_matrix);
}
// Compute space threshold based on font metrics when available // Compute space threshold based on font metrics when available
let space_threshold = if let Some(font_info) = font_info { let space_threshold = if let Some(font_info) = font_info {
@@ -700,6 +712,7 @@ pub(crate) fn extract_page_text_items(
if actual_text.is_some() { if actual_text.is_some() {
suppress_glyph_extraction = true; suppress_glyph_extraction = true;
actual_text_start_tm = Some(text_matrix); actual_text_start_tm = Some(text_matrix);
actual_text_glyph_tm = None; // reset — will be captured at first Tj/TJ
} }
marked_content_stack.push(MarkedContentEntry { actual_text, mcid }); marked_content_stack.push(MarkedContentEntry { actual_text, mcid });
} }
@@ -707,8 +720,13 @@ pub(crate) fn extract_page_text_items(
// End Marked Content — emit ActualText item with correct width // End Marked Content — emit ActualText item with correct width
if let Some(entry) = marked_content_stack.pop() { if let Some(entry) = marked_content_stack.pop() {
if let Some(at) = entry.actual_text { if let Some(at) = entry.actual_text {
// Compute width from text matrix advancement during BDC..EMC // Use the first-glyph position (if available) instead of the
if let Some(start_tm) = actual_text_start_tm.take() { // BDC-entry position. Td operators between BDC and the first
// Tj may have moved the text position to the correct line —
// the BDC-entry position can be on the previous line.
let glyph_tm = actual_text_glyph_tm.take();
let entry_tm = actual_text_start_tm.take();
if let Some(start_tm) = glyph_tm.or(entry_tm) {
let combined = multiply_matrices(&start_tm, &ctm); let combined = multiply_matrices(&start_tm, &ctm);
if combined[0].abs() >= combined[1].abs() { if combined[0].abs() >= combined[1].abs() {
rotation_votes.horizontal += 1; rotation_votes.horizontal += 1;
+6
View File
@@ -1,3 +1,9 @@
// Rust 1.95 introduced collapsible_match for `if` inside match arms.
// The content-stream parsers use this pattern extensively (match on operator
// name, then check `in_text_block && !op.operands.is_empty()`). Collapsing
// these into match guards would hurt readability. Allow crate-wide.
#![allow(clippy::collapsible_match)]
//! Smart PDF detection and text extraction using lopdf //! Smart PDF detection and text extraction using lopdf
//! //!
//! # Quick start //! # Quick start