Redaction keeps Type 3 glyphs that annotations, patterns and masks show

unlink_type3 (#4579) unlinks the Type 3 glyph programs that nothing
shows any more. It found what is still shown by scanning page content
and the forms reached through /XObject only. Annotation appearances,
tiling pattern cells and soft mask groups draw too. A glyph only they
showed counted as unused, so its program left /CharProcs, or the whole
font lost /CharProcs and left the page's /Font. This ran on every Apply
Redactions, even when the mark covered text in another font.

The scan now also walks the appearance streams of each page's
annotations, the tiling pattern an scn or SCN selects and the group of
the soft mask a gs sets. Each is scanned once per stream, font and
holder, through its own /Resources or its caller's, with no current
font. Forms take the same path through scan_stream_object.

Three tests, one per drawer. All three fail without the fix.
This commit is contained in:
oracola91 committed 2026-10-28 01:29:25 +02:00
1 parent 5af1027652
commit 4347024e42
2 files changed
+156 -8

No files matched your search

@@ -298,3 +298,86 @@ fn type3_font_used_in_a_form_xobject_has_its_redacted_glyph_removed() {
assert_eq!(report.glyphs, 1);
assert!(!full_save_holds(&doc, b"SECRET_FORM_GLYPH_PAYLOAD"), "redacted glyph program survived");
}
/// A Type 3 font with glyphs A, B and C, whose programs are objects `a`, `a + 1` and `a + 2`.
fn type3_abc(a: u32) -> Vec<u8> {
let (b, c) = (a + 1, a + 2);
format!(
"<< /Type /Font /Subtype /Type3 /FontBBox [0 -200 1000 800] /FontMatrix [0.001 0 0 0.001 0 0] /FirstChar 65 /LastChar 67 /Widths [500 500 500] /Encoding << /Differences [65 /A /B /C] >> /CharProcs << /A {a} 0 R /B {b} 0 R /C {c} 0 R >> >>"
)
.into_bytes()
}
/// An annotation's appearance shows glyph B of a Type 3 font the page lists too. The appearance
/// still draws it, so its program is kept; the redacted glyph A goes.
#[test]
fn a_type3_glyph_an_annotation_appearance_shows_is_kept() {
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R /Annots [7 0 R] >>".to_vec(),
stream("", b"BT /T3 10 Tf 10 100 Td (AC) Tj ET"),
b"<< /Font << /T3 6 0 R >> >>".to_vec(),
type3_abc(9),
b"<< /Type /Annot /Subtype /FreeText /Rect [150 200 250 220] /P 3 0 R /DA (/T3 10 Tf 0 g) /AP << /N 8 0 R >> >>".to_vec(),
stream("/Type /XObject /Subtype /Form /BBox [0 0 100 20] /Resources << /Font << /T3 6 0 R >> >>", b"BT /T3 10 Tf 2 5 Td (B) Tj ET"),
stream("", b"% REDACTED_GLYPH_A"),
stream("", b"% APPEARANCE_GLYPH_B"),
stream("", b"% PAGE_GLYPH_C"),
]);
mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1);
assert!(!full_save_holds(&doc, b"REDACTED_GLYPH_A"), "the redacted glyph program survived");
assert!(full_save_holds(&doc, b"APPEARANCE_GLYPH_B"), "the glyph the appearance shows was unlinked");
assert!(full_save_holds(&doc, b"PAGE_GLYPH_C"), "the glyph the page still shows was unlinked");
}
/// The cell of a tiling pattern the page fills with shows glyph B: its program is kept.
#[test]
fn a_type3_glyph_a_tiling_pattern_shows_is_kept() {
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(),
stream("", b"BT /T3 10 Tf 10 100 Td (AC) Tj ET /Pattern cs /P1 scn 200 200 50 50 re f"),
b"<< /Font << /T3 6 0 R >> /Pattern << /P1 7 0 R >> >>".to_vec(),
type3_abc(8),
stream(
"/Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 20 20] /XStep 20 /YStep 20 /Resources << /Font << /T3 6 0 R >> >>",
b"BT /T3 10 Tf 2 5 Td (B) Tj ET",
),
stream("", b"% REDACTED_GLYPH_A"),
stream("", b"% PATTERN_GLYPH_B"),
stream("", b"% PAGE_GLYPH_C"),
]);
mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1);
assert!(!full_save_holds(&doc, b"REDACTED_GLYPH_A"), "the redacted glyph program survived");
assert!(full_save_holds(&doc, b"PATTERN_GLYPH_B"), "the glyph the pattern shows was unlinked");
assert!(full_save_holds(&doc, b"PAGE_GLYPH_C"), "the glyph the page still shows was unlinked");
}
/// The group of a soft mask the page sets shows glyph B: its program is kept.
#[test]
fn a_type3_glyph_a_soft_mask_shows_is_kept() {
let mut doc = pdf(vec![
b"<< /Type /Catalog /Pages 2 0 R >>".to_vec(),
b"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".to_vec(),
b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 4 0 R /Resources 5 0 R >>".to_vec(),
stream("", b"BT /T3 10 Tf 10 100 Td (AC) Tj ET /GS1 gs 150 150 100 100 re f"),
b"<< /Font << /T3 6 0 R >> /ExtGState << /GS1 << /Type /ExtGState /SMask << /Type /Mask /S /Luminosity /G 7 0 R >> >> >> >>".to_vec(),
type3_abc(8),
stream(
"/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Group << /S /Transparency /CS /DeviceGray >> /Resources << /Font << /T3 6 0 R >> >>",
b"1 g BT /T3 40 Tf 160 180 Td (B) Tj ET",
),
stream("", b"% REDACTED_GLYPH_A"),
stream("", b"% MASK_GLYPH_B"),
stream("", b"% PAGE_GLYPH_C"),
]);
mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], "");
assert_eq!(apply(&mut doc, None).unwrap().glyphs, 1);
assert!(!full_save_holds(&doc, b"REDACTED_GLYPH_A"), "the redacted glyph program survived");
assert!(full_save_holds(&doc, b"MASK_GLYPH_B"), "the glyph the soft mask shows was unlinked");
assert!(full_save_holds(&doc, b"PAGE_GLYPH_C"), "the glyph the page still shows was unlinked");
}
+73 -8
View File
@@ -258,6 +258,17 @@ fn scan_content_stream(
scan_xobject(ctx, res, holder_ref, name, current_font, depth)?;
}
}
b"gs" => {
apply_text_op(ctx, res, &op, &mut current_font);
if let Some(name) = op.name(0) {
scan_soft_mask(ctx, res, holder_ref, name, depth)?;
}
}
b"scn" | b"SCN" => {
if let Some(name) = op.last_name() {
scan_pattern(ctx, res, holder_ref, name, depth)?;
}
}
_ => apply_text_op(ctx, res, &op, &mut current_font),
}
}
@@ -320,20 +331,73 @@ fn scan_xobject(
) -> Result<(), CosError> {
let xo = res_dict(ctx.doc, res, b"XObject");
let Some(r) = xo.get(name).and_then(Object::as_ref) else { return Ok(()) };
let obj = ctx.doc.get(r);
let Object::Stream(s) = &*obj else { return Ok(()) };
if s.dict.name(b"Subtype") != Some(b"Form") {
if !matches!(&*ctx.doc.get(r), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form")) {
return Ok(());
}
let form_own_res = s.dict.get(b"Resources").and_then(|ro| ctx.doc.resolve(ro).as_dict().cloned());
// A form with resources of its own draws through them; one without draws through its caller's.
let through = if form_own_res.is_some() { r } else { holder_ref };
scan_stream_object(ctx, res, holder_ref, r, current_font, depth)
}
/// Scan the tiling pattern `name` that an `scn` or `SCN` operator selects. Its cell is content of
/// its own and can show glyphs of a font the page lists too. A shading pattern is not a stream and
/// draws no text.
fn scan_pattern(ctx: &mut ScanContext<'_>, res: &Dict, holder_ref: ObjRef, name: &[u8], depth: usize) -> Result<(), CosError> {
let patterns = res_dict(ctx.doc, res, b"Pattern");
let Some(r) = patterns.get(name).and_then(Object::as_ref) else { return Ok(()) };
scan_stream_object(ctx, res, holder_ref, r, None, depth)
}
/// Scan the transparency group of the soft mask that the ExtGState `name` sets: what it shows
/// shapes the page, so its glyphs are in use.
fn scan_soft_mask(ctx: &mut ScanContext<'_>, res: &Dict, holder_ref: ObjRef, name: &[u8], depth: usize) -> Result<(), CosError> {
let states = res_dict(ctx.doc, res, b"ExtGState");
let Some(state) = states.get(name).and_then(|s| ctx.doc.resolve(s).as_dict().cloned()) else { return Ok(()) };
let Some(mask) = state.get(b"SMask").and_then(|m| ctx.doc.resolve(m).as_dict().cloned()) else { return Ok(()) };
let Some(group) = mask.get(b"G").and_then(Object::as_ref) else { return Ok(()) };
scan_stream_object(ctx, res, holder_ref, group, None, depth)
}
/// Scan the appearance streams of a page's annotations. A comment, stamp or field can show glyphs
/// of a Type 3 font that the page lists too. An appearance without resources of its own draws
/// through the page's.
fn scan_appearances(ctx: &mut ScanContext<'_>, page: &Dict, page_ref: ObjRef, page_res: &Dict) -> Result<(), CosError> {
for annot in crate::annots_of(ctx.doc, page) {
let Some(ap) = ctx.doc.resolve(&annot).as_dict().and_then(|a| a.get(b"AP").cloned()) else { continue };
let Some(ap) = ctx.doc.resolve(&ap).as_dict().cloned() else { continue };
for (_, entry) in ap.iter() {
// Each entry is one stream, or a dictionary of streams by appearance state.
let streams: Vec<ObjRef> = match &*ctx.doc.resolve(entry) {
Object::Dict(states) => states.iter().filter_map(|(_, s)| s.as_ref()).collect(),
_ => entry.as_ref().into_iter().collect(),
};
for r in streams {
scan_stream_object(ctx, page_res, page_ref, r, None, 0)?;
}
}
}
Ok(())
}
/// Scan a content stream object (a form, a pattern cell, a soft mask group or an appearance), once
/// per (stream, font, drawing holder). One with resources of its own draws through them; one
/// without draws through its caller's.
fn scan_stream_object(
ctx: &mut ScanContext<'_>,
res: &Dict,
holder_ref: ObjRef,
r: ObjRef,
current_font: Option<FontKey>,
depth: usize,
) -> Result<(), CosError> {
let obj = ctx.doc.get(r);
let Object::Stream(s) = &*obj else { return Ok(()) };
let own_res = s.dict.get(b"Resources").and_then(|ro| ctx.doc.resolve(ro).as_dict().cloned());
let through = if own_res.is_some() { r } else { holder_ref };
if !ctx.seen_forms.insert((r, current_font, through)) {
return Ok(());
}
let data = s.decoded()?;
match form_own_res {
Some(form_res) => scan_content_stream(ctx, std::slice::from_ref(&data), r, &form_res, current_font, depth + 1),
match own_res {
Some(own) => scan_content_stream(ctx, std::slice::from_ref(&data), r, &own, current_font, depth + 1),
None => scan_content_stream(ctx, std::slice::from_ref(&data), holder_ref, res, current_font, depth + 1),
}
}
@@ -480,6 +544,7 @@ pub(crate) fn unlink_type3(doc: &mut Document) -> Result<(), RedactError> {
.unwrap_or_default();
scan_content_stream(&mut ctx, &content, p.obj, &page_res, None, 0)?;
scan_appearances(&mut ctx, &p.dict, p.obj, &page_res)?;
}
// Also scan any surviving form that wasn't reached by page streams directly.