diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs index 477f03f9a5..1a7077974e 100644 --- a/crates/redact/src/tests/surviving_references.rs +++ b/crates/redact/src/tests/surviving_references.rs @@ -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 { + 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"); +} diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs index f431dab902..b7df125513 100644 --- a/crates/redact/src/type3.rs +++ b/crates/redact/src/type3.rs @@ -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 = 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, + 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.