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.
This commit is contained in:
Dylan M. Taylor
2026-11-03 16:39:01 +00:00
parent 14ae839a28
commit 48e9e6b7a2
3 changed files with 52 additions and 5 deletions
+32 -2
View File
@@ -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<f64> {
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<u8> {
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<O
let spacing = gs.char_spacing + if f.is_space(code, len) { gs.word_spacing } else { 0.0 };
let trm = Matrix([size * gs.scale, 0.0, 0.0, size, 0.0, gs.rise]).then(tm).then(&gs.ctm);
let b = trm.bbox([0.0, f.descent, w0, f.ascent]);
// A Type 3 glyph paints where its whole font matrix puts it: the box covers both.
let b = gs.glyph_box.map_or(b, |g| {
let p = trm.bbox(g);
[b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[2]), b[3].max(p[3])]
});
let origin = trm.apply(0.0, 0.0);
let advance = w0 * size + spacing;
let hit = match scope.hidden_text {
+7 -3
View File
@@ -1,6 +1,6 @@
use super::{
CosError, Dict, Document, HashMap, HashSet, MAX_DEPTH, Matrix, Metrics, ObjRef, Object, Op, Pieces, Rc, RedactError, Scope, is_vertical, num,
overlaps, page_streams, res_dict,
overlaps, page_streams, res_dict, type3_glyph_box,
};
/// Drop every `replaced` resource name whose `Do` no longer appears in the rewritten `content`,
@@ -328,6 +328,9 @@ pub(super) fn prune_form_resources(doc: &Document, res: &mut Dict, content: &[Ve
Ok(())
}
/// What an ExtGState's `/Font` sets: metrics, vertical writing, size and a Type 3 glyph box.
pub(super) type ExtGStateFont = (Rc<Metrics>, 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<Metrics>,
) -> Option<(Rc<Metrics>, bool, f64)> {
) -> Option<ExtGStateFont> {
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))
}
+13
View File
@@ -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);
}