From 48e9e6b7a25ba92549e986ae38684da366302bd2 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Tue, 3 Nov 2026 16:39:01 +0000 Subject: [PATCH] fix(redact): place Type 3 glyphs with their whole font matrix Type 3 glyph boxes used the font matrix's scale only (its first element, with the FontBBox's height), leaving out translation and skew. The renderer applies the whole matrix, so a glyph the font matrix moves lay outside the box redaction and the final verifier tested: a mark over the drawn glyph neither removed it nor saw it left behind. The glyph box now also covers the FontBBox through the whole FontMatrix, for Tf and for an ExtGState font; the union with the old box keeps every position the old test covered. --- crates/redact/src/interp.rs | 34 +++++++++++++++++++++++++-- crates/redact/src/interp/prune.rs | 10 +++++--- crates/redact/src/tests/text_state.rs | 13 ++++++++++ 3 files changed, 52 insertions(+), 5 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 460085a320..9b0dc842cb 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -68,6 +68,9 @@ struct Gs { scale: f64, leading: f64, rise: f64, + /// A Type 3 font's glyph box in text space per unit of font size: its `/FontBBox` through its + /// whole `/FontMatrix`, translation included, which the metrics' box leaves out. + glyph_box: Option<[f64; 4]>, } /// What processing a scope produced. @@ -205,6 +208,24 @@ fn is_vertical(doc: &Document, font: &Dict) -> bool { } } +/// Where a Type 3 font's glyphs can paint, in text space per unit of font size: its `/FontBBox` +/// through its whole `/FontMatrix`. The renderer applies the whole matrix, translation and skew +/// included, so a box from its scale alone can lie beside the glyph a mark covers. +fn type3_glyph_box(doc: &Document, font: &Dict) -> Option<[f64; 4]> { + if font.name(b"Subtype") != Some(b"Type3".as_slice()) { + return None; + } + let numbers = |key: &[u8]| -> Vec { + font.get(key) + .map(|v| doc.resolve(v)) + .and_then(|v| v.as_array().map(|a| a.iter().filter_map(|x| doc.resolve(x).as_f64()).collect())) + .unwrap_or_default() + }; + let (matrix, bbox) = (numbers(b"FontMatrix"), numbers(b"FontBBox")); + let (&[a, b, c, d, e, f], &[x0, y0, x1, y1]) = (matrix.as_slice(), bbox.as_slice()) else { return None }; + Some(Matrix([a, b, c, d, e, f]).bbox([x0.min(x1), y0.min(y1), x0.max(x1), y0.max(y1)])) +} + fn fresh_name(scope: &mut Scope<'_>, xobjects: &Dict) -> Vec { let mut i = scope.used_names.len() + 1; loop { @@ -238,6 +259,7 @@ pub(crate) fn process(doc: &mut Document, scope: &mut Scope<'_>, streams: &[Vec< scale: 1.0, leading: 0.0, rise: 0.0, + glyph_box: None, }; process_from(doc, scope, streams, resources, gs) } @@ -456,8 +478,10 @@ impl Walk { } b"Tf" => self.select_font(doc, scope, op), _ => { - if let Some((font, vertical, size)) = op.name(0).and_then(|n| extgstate_font(doc, scope, &self.ext_gstates, n, &self.fallback)) { - (self.gs.font, self.gs.vertical, self.gs.size) = (font, vertical, size); + if let Some((font, vertical, size, glyph_box)) = + op.name(0).and_then(|n| extgstate_font(doc, scope, &self.ext_gstates, n, &self.fallback)) + { + (self.gs.font, self.gs.vertical, self.gs.size, self.gs.glyph_box) = (font, vertical, size, glyph_box); } } } @@ -468,6 +492,7 @@ impl Walk { let Some(name) = op.name(0) 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.glyph_box = font_dict.as_ref().and_then(|d| type3_glyph_box(doc, d)); self.gs.font = match scope.fonts.get(name) { Some(f) => f.clone(), None => { @@ -672,6 +697,11 @@ fn show(scope: &Scope<'_>, gs: &Gs, tm: &mut Matrix, items: &[Object]) -> (Vec, bool, f64, Option<[f64; 4]>); + /// The font, writing direction and size an ExtGState's `/Font [font size]` entry sets, as `Tf` /// would (ISO 32000-2 §8.4.5); `None` when `name` sets no font. The metrics are cached with the /// fonts `Tf` names, under a key no resource name can take (a name cannot hold a null byte). @@ -337,13 +340,14 @@ pub(super) fn extgstate_font( ext_gstates: &Dict, name: &[u8], fallback: &Rc, -) -> Option<(Rc, bool, f64)> { +) -> Option { let state = doc.resolve(ext_gstates.get(name)?).as_dict().cloned()?; let entry = doc.resolve(state.get(b"Font")?).as_array().cloned()?; let [font, size] = entry.as_slice() else { return None }; let size = doc.resolve(size).as_f64()?; let font_dict = doc.resolve(font).as_dict().cloned(); let vertical = font_dict.as_ref().is_some_and(|d| is_vertical(doc, d)); + let glyph_box = font_dict.as_ref().and_then(|d| type3_glyph_box(doc, d)); let key = [b"\0".as_slice(), name].concat(); let metrics = match scope.fonts.get(&key) { Some(m) => m.clone(), @@ -353,5 +357,5 @@ pub(super) fn extgstate_font( m } }; - Some((metrics, vertical, size)) + Some((metrics, vertical, size, glyph_box)) } diff --git a/crates/redact/src/tests/text_state.rs b/crates/redact/src/tests/text_state.rs index 4e888f262c..5d968fd402 100644 --- a/crates/redact/src/tests/text_state.rs +++ b/crates/redact/src/tests/text_state.rs @@ -25,3 +25,16 @@ 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:?}"); } + +/// A Type 3 font whose matrix moves its glyphs up 5 pt (at 10 pt): the renderer draws the `A` at +/// y 105–115, but the box from the matrix's scale alone put it at y 99–110, so a mark over the +/// top of the drawn glyph neither removed it nor saw it left behind. +#[test] +fn a_type3_glyph_is_placed_with_its_whole_font_matrix() { + let font = b"<< /Type /Font /Subtype /Type3 /FontBBox [0 0 1000 1000] /FontMatrix [0.001 0 0 0.001 0 0.5] /FirstChar 65 /LastChar 65 /Widths [500] /Encoding << /Differences [65 /A] >> /CharProcs << /A 8 0 R >> >>"; + let mut doc = one_page(b"BT /T3 10 Tf 10 100 Td (A) Tj ET", "/Font << /T3 7 0 R >>", vec![font.to_vec(), stream("", b"0 0 1000 1000 re f")]); + let top = [5.0, 111.0, 20.0, 116.0]; + assert_eq!(under(&mut doc, 0, &[top]), 1, "the verifier sees the drawn glyph"); + mark(&mut doc, 0, &[top], ""); + assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1); +}