From 3f03cefcca8fc15af33c1700a30c5343b6c406db Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 13:47:32 +0000 Subject: [PATCH] fix(redact): read text-state operands from the end, as the renderer does Ts, Tc, Tw, Tz, TL and Tr took their first operand, Tf its first two, and " its first two numbers. The renderer takes the operands immediately before the operator and ignores any surplus before them, so surplus operands (`1 50 Ts`) moved the interpreter's glyph boxes away from the text the page shows: removal and verification missed it. Each of them now reads its trailing operands. --- crates/redact/src/interp.rs | 27 +++++++++++++++++---------- crates/redact/src/tests/text_state.rs | 14 ++++++++++++++ 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 460085a320..6f48e8852e 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -464,8 +464,10 @@ impl Walk { } fn select_font(&mut self, doc: &Document, scope: &mut Scope<'_>, op: &Op) { - self.gs.size = op.num(1).unwrap_or(self.gs.size); - let Some(name) = op.name(0) else { return }; + // `/F1 12 Tf` takes its last two operands, as the renderer does: a surplus before them + // is ignored, not read in their place. + self.gs.size = op.nums::<1>().map_or(self.gs.size, |[size]| size); + let Some(name) = op.operands.len().checked_sub(2).and_then(|i| op.name(i)) else { return }; let font_dict = self.fonts_res.get(name).and_then(|f| doc.resolve(f).as_dict().cloned()); self.gs.vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); self.gs.font = match scope.fonts.get(name) { @@ -481,17 +483,20 @@ impl Walk { /// Text state and text-positioning operators. fn text_state(&mut self, op: &Op) { let gs = &mut self.gs; + // The operand immediately before the operator, as the renderer reads it: surplus operands + // before it must not move where glyphs are placed (`1 50 Ts` raises text by 50, not 1). + let last = op.nums::<1>().map(|[v]| v); match op.op.as_slice() { b"BT" => { self.tm = Matrix::IDENTITY; self.tlm = Matrix::IDENTITY; } - b"Tc" => gs.char_spacing = op.num(0).unwrap_or(0.0), - b"Tw" => gs.word_spacing = op.num(0).unwrap_or(0.0), - b"Tz" => gs.scale = op.num(0).unwrap_or(100.0) / 100.0, - b"TL" => gs.leading = op.num(0).unwrap_or(0.0), - b"Ts" => gs.rise = op.num(0).unwrap_or(0.0), - b"Tr" => gs.render_mode = op.num(0).unwrap_or(0.0) as i64, + b"Tc" => gs.char_spacing = last.unwrap_or(0.0), + b"Tw" => gs.word_spacing = last.unwrap_or(0.0), + b"Tz" => gs.scale = last.unwrap_or(100.0) / 100.0, + b"TL" => gs.leading = last.unwrap_or(0.0), + b"Ts" => gs.rise = last.unwrap_or(0.0), + b"Tr" => gs.render_mode = last.unwrap_or(0.0) as i64, b"Td" | b"TD" => { if let Some([x, y]) = op.nums::<2>() { if op.is("TD") { @@ -518,8 +523,10 @@ impl Walk { fn show_text(&mut self, scope: &mut Scope<'_>, op: Op, piece: &mut Piece) -> Option { let o = op.op.as_slice(); if o == b"\"" { - self.gs.word_spacing = op.num(0).unwrap_or(self.gs.word_spacing); - self.gs.char_spacing = op.num(1).unwrap_or(self.gs.char_spacing); + // `aw ac string "`: the two numbers before the string, whatever surplus precedes them. + let at = |back: usize| op.operands.len().checked_sub(back).and_then(|i| op.num(i)); + self.gs.word_spacing = at(3).unwrap_or(self.gs.word_spacing); + self.gs.char_spacing = at(2).unwrap_or(self.gs.char_spacing); } if matches!(o, b"'" | b"\"") { self.tlm = Matrix::translate(0.0, -self.gs.leading).then(&self.tlm); diff --git a/crates/redact/src/tests/text_state.rs b/crates/redact/src/tests/text_state.rs index 4e888f262c..2df6b53fad 100644 --- a/crates/redact/src/tests/text_state.rs +++ b/crates/redact/src/tests/text_state.rs @@ -25,3 +25,17 @@ fn a_form_inherits_the_text_state_of_the_page_that_draws_it() { let r = apply(&mut doc, None).unwrap(); assert_eq!((r.glyphs, r.forms_rewritten), (6, 1), "{r:?}"); } + +/// `1 50 Ts` raises text by 50, the operand before the operator, as the renderer reads it; the +/// surplus `1` before it is ignored. Reading the first operand put the glyphs 49 pt too low, so a +/// mark over the text the page shows missed it. +#[test] +fn surplus_text_state_operands_do_not_move_the_glyphs() { + let mut doc = one_page(b"BT /F1 10 Tf 1 50 Ts 10 100 Td (SECRET) Tj ET", "", vec![]); + assert_eq!(under(&mut doc, 0, &[SECRET_AT_10_100]), 0, "nothing is drawn at y 100"); + let raised = [5.0, 145.0, 45.0, 160.0]; + assert_eq!(under(&mut doc, 0, &[raised]), 6); + mark(&mut doc, 0, &[raised], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 6); + assert!(!content(&doc, 0).contains("SECRET")); +}