From 727a635ade4b293e6437e25da6750f2e037b7030 Mon Sep 17 00:00:00 2001 From: McanKul Date: Sun, 4 Oct 2026 23:16:17 +0300 Subject: [PATCH] fix(pdf): classify unsafe text states --- src-tauri/src/pdf_engine/source_content.rs | 52 +++++++++++++- .../src/pdf_engine/source_content_integ.rs | 72 +++++++++++++++++++ 2 files changed, 121 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/pdf_engine/source_content.rs b/src-tauri/src/pdf_engine/source_content.rs index 67f3ed3..662a96b 100644 --- a/src-tauri/src/pdf_engine/source_content.rs +++ b/src-tauri/src/pdf_engine/source_content.rs @@ -173,6 +173,8 @@ struct Flags { ambiguous: bool, subset_font: bool, custom_encoding: bool, + text_rise: bool, + text_render_mode: bool, masked: bool, shared: bool, geometry: bool, @@ -201,6 +203,8 @@ struct GState { hscale: f64, tc: f64, tw: f64, + text_rise: f64, + text_render_mode: u8, } impl Default for GState { @@ -218,6 +222,8 @@ impl Default for GState { hscale: 100.0, tc: 0.0, tw: 0.0, + text_rise: 0.0, + text_render_mode: 0, } } } @@ -235,6 +241,7 @@ impl GState { struct TextState { tm: [f64; 6], tlm: [f64; 6], + clip_pending: bool, } impl Default for TextState { @@ -242,6 +249,7 @@ impl Default for TextState { Self { tm: IDENTITY, tlm: IDENTITY, + clip_pending: false, } } } @@ -396,8 +404,14 @@ impl Walker<'_> { "BT" => { ts.tm = IDENTITY; ts.tlm = IDENTITY; + ts.clip_pending = false; + } + "ET" => { + if ts.clip_pending { + gs.clip_active = true; + ts.clip_pending = false; + } } - "ET" => {} "Tf" => { if let Some(name) = op.operands.first().and_then(|o| o.as_name().ok()) { gs.font_name = Some(name.to_vec()); @@ -421,6 +435,28 @@ impl Walker<'_> { gs.hscale = z; } } + "Ts" => { + gs.text_rise = match op.operands.as_slice() { + [value] => obj_f64(value).ok_or_else(|| { + malformed_content("A text-rise operator has an invalid operand.") + })?, + _ => { + return Err(malformed_content( + "A text-rise operator must have one operand.", + )) + } + }; + } + "Tr" => { + gs.text_render_mode = match op.operands.as_slice() { + [Object::Integer(mode)] if (0..=7).contains(mode) => *mode as u8, + _ => { + return Err(malformed_content( + "A text-rendering-mode operator must contain an integer from 0 to 7.", + )) + } + }; + } "TL" => { if let Some(l) = op.operands.first().and_then(obj_f64) { gs.leading = l; @@ -480,6 +516,9 @@ impl Walker<'_> { nested, geom_unsafe, )?; + if gs.text_render_mode >= 4 && pieces.iter().any(|piece| !piece.is_empty()) { + ts.clip_pending = true; + } } "Do" => { let Some(name) = op.operands.first().and_then(|o| o.as_name().ok()) else { @@ -611,6 +650,7 @@ impl Walker<'_> { let shown = inspect.width * th; let width = (shown * sx).abs().max(0.01); let tx = (inspect.width + spacing_advance(pieces, gs.tc, gs.tw)) * th; + let origin = apply_point(effective, 0.0, gs.text_rise); let flags = Flags { nested_form: nested, type3: inspect.type3, @@ -624,6 +664,8 @@ impl Walker<'_> { ambiguous: inspect.ambiguous, subset_font: inspect.subset_font, custom_encoding: inspect.custom_encoding, + text_rise: gs.text_rise != 0.0, + text_render_mode: gs.text_render_mode != 0, masked: gs.masked, geometry: geom_unsafe, ..Flags::default() @@ -632,8 +674,8 @@ impl Walker<'_> { page_index, kind: SourceKind::Text, rect: SourceRect { - x: effective[4], - y: effective[5], + x: origin.0, + y: origin.1, w: width, h: height, }, @@ -808,6 +850,10 @@ fn pick_reason(flags: &Flags) -> (SourceCapability, Option) { Some("SUBSET_FONT") } else if flags.custom_encoding { Some("CUSTOM_ENCODING") + } else if flags.text_rise { + Some("TEXT_RISE") + } else if flags.text_render_mode { + Some("TEXT_RENDER_MODE") } else if flags.masked { Some("MASKED_IMAGE") } else if flags.shared { diff --git a/src-tauri/src/pdf_engine/source_content_integ.rs b/src-tauri/src/pdf_engine/source_content_integ.rs index b7748cb..19ce888 100644 --- a/src-tauri/src/pdf_engine/source_content_integ.rs +++ b/src-tauri/src/pdf_engine/source_content_integ.rs @@ -37,6 +37,8 @@ const FROZEN_REASONS: &[&str] = &[ "MISSING_FONT", "SUBSET_FONT", "CUSTOM_ENCODING", + "TEXT_RISE", + "TEXT_RENDER_MODE", "NO_TOUNICODE", "AMBIGUOUS_UNICODE", "TYPE3", @@ -853,6 +855,76 @@ fn classify_named_standard_encoding_remains_supported() { ); } +#[test] +fn classify_text_rise_moves_bounds_and_resets_to_supported() { + let scratch = Scratch::new("review-text-rise"); + let path = scratch.file("text-rise.pdf"); + write_helvetica_page( + &path, + b"BT /F1 12 Tf 72 700 Td 20 Ts (Hi) Tj 0 Ts (Lo) Tj ET", + ); + let hits = classify(&path, "REVIEW-TEXT-RISE"); + let texts: Vec<&SourceOccurrence> = hits.iter().filter(|hit| kind_token(hit) == "text").collect(); + assert_eq!(texts.len(), 2); + assert_unsupported(texts[0], "text", "TEXT_RISE", "REVIEW-TEXT-RISE"); + assert!((texts[0].rect.y - 720.0).abs() <= 1.0); + assert_supported_text_or_image(texts[1], "text", "REVIEW-TEXT-RISE-RESET"); + assert!((texts[1].rect.y - 700.0).abs() <= 1.0); +} + +#[test] +fn classify_text_render_mode_applies_clip_only_after_text_object() { + let scratch = Scratch::new("review-text-render-mode"); + let path = scratch.file("text-render-mode.pdf"); + write_helvetica_page( + &path, + concat!( + "BT /F1 12 Tf 72 700 Td 7 Tr (A) Tj 0 Tr (B) Tj ET ", + "BT /F1 12 Tf 72 650 Td (C) Tj ET" + ) + .as_bytes(), + ); + let hits = classify(&path, "REVIEW-TEXT-RENDER-MODE"); + let texts: Vec<&SourceOccurrence> = hits.iter().filter(|hit| kind_token(hit) == "text").collect(); + assert_eq!(texts.len(), 3); + assert_unsupported( + texts[0], + "text", + "TEXT_RENDER_MODE", + "REVIEW-TEXT-RENDER-MODE", + ); + assert_supported_text_or_image(texts[1], "text", "REVIEW-TEXT-RENDER-MODE-RESET"); + assert_unsupported( + texts[2], + "text", + "CLIPPED", + "REVIEW-TEXT-RENDER-MODE-CLIP", + ); +} + +#[test] +fn classify_malformed_text_state_operators_fail_closed() { + let scratch = Scratch::new("review-malformed-text-state"); + for (name, content) in [ + ("missing-rise.pdf", &b"BT /F1 12 Tf Ts (Hi) Tj ET"[..]), + ( + "invalid-render-mode.pdf", + &b"BT /F1 12 Tf 1.5 Tr (Hi) Tj ET"[..], + ), + ( + "out-of-range-render-mode.pdf", + &b"BT /F1 12 Tf 8 Tr (Hi) Tj ET"[..], + ), + ] { + let path = scratch.file(name); + write_helvetica_page(&path, content); + assert_eq!( + classify_source_content(&path).unwrap_err().code, + "MALFORMED_CONTENT" + ); + } +} + #[test] fn classify_repeated_form_occurrences_have_unique_locators() { let scratch = Scratch::new("review-repeated-form");