diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index ac69c73acb..8ab7602503 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -884,7 +884,7 @@ fn xobject( out.error = Some(error); return None; } - if let Err(error) = crate::type3::prune_type3_from_resources(doc, &mut cleaned_res, content) { + if let Err(error) = crate::type3::prune_type3_from_resources(doc, &mut cleaned_res, content, None) { out.error = Some(error); return None; } diff --git a/crates/redact/src/lib.rs b/crates/redact/src/lib.rs index de9dafe7db..2c9f9fcf15 100644 --- a/crates/redact/src/lib.rs +++ b/crates/redact/src/lib.rs @@ -626,7 +626,7 @@ fn redact_page_content( pi, )?; retired_properties.extend(interp::prune_properties(doc, &mut res, &cleaned_data)?); - type3::prune_type3_from_resources(doc, &mut res, &cleaned_data)?; + type3::prune_type3_from_resources(doc, &mut res, &cleaned_data, Some(&page.dict))?; doc.update_dict(page.obj, |d| { d.set(b"Contents".to_vec(), Object::Array(new_list)); d.set(b"Resources".to_vec(), Object::Dict(res)); diff --git a/crates/redact/src/sanitize.rs b/crates/redact/src/sanitize.rs index 98703dfbf2..d3e59bd684 100644 --- a/crates/redact/src/sanitize.rs +++ b/crates/redact/src/sanitize.rs @@ -424,7 +424,7 @@ fn content_pass(doc: &mut Document, text: bool, layers: bool, write: bool) -> Re res.set(b"XObject".to_vec(), Object::Dict(xo)); } retired_properties.extend(crate::interp::prune_properties(doc, &mut res, &page_content)?); - crate::type3::prune_type3_from_resources(doc, &mut res, &page_content)?; + crate::type3::prune_type3_from_resources(doc, &mut res, &page_content, Some(&page.dict))?; doc.update_dict(page.obj, |d| { d.set(b"Contents".to_vec(), Object::Array(new_list)); d.set(b"Resources".to_vec(), Object::Dict(res)); diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs index 1a7077974e..5548736688 100644 --- a/crates/redact/src/tests/surviving_references.rs +++ b/crates/redact/src/tests/surviving_references.rs @@ -332,6 +332,66 @@ fn a_type3_glyph_an_annotation_appearance_shows_is_kept() { assert!(full_save_holds(&doc, b"PAGE_GLYPH_C"), "the glyph the page still shows was unlinked"); } +/// The same annotation also has an `/AP` entry no reader draws (`/X`), which shows the redacted +/// glyph A. It keeps nothing: A's program goes, and B, which the normal appearance shows, stays. +#[test] +fn a_type3_glyph_only_an_undrawn_appearance_entry_shows_is_unlinked() { + 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(10), + 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 /X 9 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("/Type /XObject /Subtype /Form /BBox [0 0 100 20] /Resources << /Font << /T3 6 0 R >> >>", b"BT /T3 10 Tf 2 5 Td (A) 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"), "an undrawn appearance entry kept the redacted glyph program"); + assert!(full_save_holds(&doc, b"APPEARANCE_GLYPH_B"), "the glyph the normal appearance shows was unlinked"); + assert!(full_save_holds(&doc, b"PAGE_GLYPH_C"), "the glyph the page still shows was unlinked"); +} + +/// An appearance without /Resources shows glyph B of a second Type 3 font, which the page lists +/// but its content never selects. The appearance draws through the page's resources, so the +/// rewritten page still lists that font and B's program stays. +#[test] +fn a_type3_font_only_an_appearance_without_resources_selects_stays_on_the_page() { + 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 [8 0 R] >>".to_vec(), + stream("", b"BT /T3 10 Tf 10 100 Td (AC) Tj ET"), + b"<< /Font << /T3 6 0 R /T4 7 0 R >> >>".to_vec(), + type3_abc(10), + type3_abc(13), + b"<< /Type /Annot /Subtype /FreeText /Rect [150 200 250 220] /P 3 0 R /DA (/T4 10 Tf 0 g) /AP << /N 9 0 R >> >>".to_vec(), + stream("/Type /XObject /Subtype /Form /BBox [0 0 100 20]", b"BT /T4 10 Tf 2 5 Td (B) Tj ET"), + stream("", b"% REDACTED_GLYPH_A"), + stream("", b"% UNUSED_GLYPH_B"), + stream("", b"% PAGE_GLYPH_C"), + stream("", b"% UNUSED_T4_GLYPH_A"), + stream("", b"% APPEARANCE_GLYPH_B"), + stream("", b"% UNUSED_T4_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"); + + let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); + let saved = Document::open(Arc::new(bytes)).unwrap(); + let page = pdfcraft_model::pages(&saved).swap_remove(0); + let res = saved.resolve(page.dict.get(b"Resources").unwrap()).as_dict().unwrap().clone(); + let fonts = saved.resolve(res.get(b"Font").unwrap()).as_dict().unwrap().clone(); + assert!(fonts.contains(b"T4"), "the page no longer lists the font the appearance selects"); +} + /// 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() { diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs index e796d0d5c0..a39f1af144 100644 --- a/crates/redact/src/type3.rs +++ b/crates/redact/src/type3.rs @@ -357,22 +357,50 @@ fn scan_soft_mask(ctx: &mut ScanContext<'_>, res: &Dict, holder_ref: ObjRef, nam /// 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)?; - } + for r in appearance_streams(ctx.doc, &annot) { + scan_stream_object(ctx, page_res, page_ref, r, None, 0)?; } } Ok(()) } +/// The streams an annotation can show: its normal, rollover and down appearances (ISO 32000-2 +/// ยง12.5.5), each one stream or a dictionary of streams by appearance state. Readers draw no +/// other `/AP` entry, so one can't keep a glyph the page no longer shows. +fn appearance_streams(doc: &Document, annot: &Object) -> Vec { + let Some(ap) = doc.resolve(annot).as_dict().and_then(|a| a.get(b"AP").cloned()) else { return Vec::new() }; + let Some(ap) = doc.resolve(&ap).as_dict().cloned() else { return Vec::new() }; + let mut streams = Vec::new(); + for key in [b"N", b"R", b"D"] { + let Some(entry) = ap.get(key) else { continue }; + match &*doc.resolve(entry) { + Object::Dict(states) => streams.extend(states.iter().filter_map(|(_, s)| s.as_ref())), + _ => streams.extend(entry.as_ref()), + } + } + streams +} + +/// The decoded appearance streams of a page's annotations that have no resources of their own: +/// they draw through the page's, as [`scan_stream_object`] reads them. +fn resourceless_appearances(doc: &Document, page: &Dict) -> Result>, CosError> { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for annot in crate::annots_of(doc, page) { + for r in appearance_streams(doc, &annot) { + if !seen.insert(r) { + continue; + } + if let Object::Stream(s) = &*doc.get(r) + && s.dict.get(b"Resources").is_none_or(|ro| doc.resolve(ro).as_dict().is_none()) + { + out.push(s.decoded()?); + } + } + } + Ok(out) +} + /// 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. @@ -406,8 +434,9 @@ fn gs_font_object(doc: &Document, state_obj: &Object) -> Option { } /// Prune Type 3 fonts from a rewritten page or form's `/Resources` dictionary if the rewritten -/// content no longer draws any text using that font name. -pub(crate) fn prune_type3_from_resources(doc: &Document, res: &mut Dict, content: &[Vec]) -> Result<(), CosError> { +/// content no longer draws any text using that font name. `page` is the page's dictionary when +/// `res` are a page's resources. +pub(crate) fn prune_type3_from_resources(doc: &Document, res: &mut Dict, content: &[Vec], page: Option<&Dict>) -> Result<(), CosError> { let mut fonts = res_dict(doc, res, b"Font"); if fonts.is_empty() { return Ok(()); @@ -415,7 +444,16 @@ pub(crate) fn prune_type3_from_resources(doc: &Document, res: &mut Dict, content // The operators of forms that draw through these resources count too: dropping a font a child // form still shows would leave its text without one. - let ops = crate::interp::resource_ops(doc, res, content)?; + let mut ops = crate::interp::resource_ops(doc, res, content)?; + // So do those of a page's annotation appearances without resources of their own. They are + // read only when there is a Type 3 font to keep, so other files are not refused over them. + if let Some(page) = page + && fonts.iter().any(|(_, f)| doc.resolve(f).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Type3"))) + { + for data in resourceless_appearances(doc, page)? { + ops.extend(crate::interp::resource_ops(doc, res, std::slice::from_ref(&data))?); + } + } let mut drawn_font_names = HashSet::new(); for op in &ops {