From 0cb06e4568899dc61031e354cfa898f097087fd8 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Sat, 31 Oct 2026 00:46:22 +0200 Subject: [PATCH] Redaction keeps a page Type 3 font that a pattern or soft mask shows The page font prune counted the page content, forms without /Resources and annotation appearances, but not tiling patterns or soft mask groups without /Resources. Those draw through the page's resources too, so a Type 3 font only they selected left the page and their text lost its font. They are now read as well, only when the page lists a Type 3 font. Two new tests fail without the change. --- .../redact/src/tests/surviving_references.rs | 68 ++++++++++++++++++- crates/redact/src/type3.rs | 61 +++++++++++++++-- 2 files changed, 119 insertions(+), 10 deletions(-) diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs index 5548736688..0864c701bd 100644 --- a/crates/redact/src/tests/surviving_references.rs +++ b/crates/redact/src/tests/surviving_references.rs @@ -383,13 +383,75 @@ fn a_type3_font_only_an_appearance_without_resources_selects_stays_on_the_page() 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!(saved_first_page_lists_font(&doc, b"T4"), "the page no longer lists the font the appearance selects"); +} - let bytes = write_full(&doc, &SaveOptions::default()).unwrap(); +/// Whether the first page of a full save of `doc` lists the font `name` in its resources. +fn saved_first_page_lists_font(doc: &Document, name: &[u8]) -> bool { + 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"); + res.get(b"Font").and_then(|f| saved.resolve(f).as_dict().map(|fonts| fonts.contains(name))).unwrap_or(false) +} + +/// A tiling pattern without /Resources shows glyph B of a second Type 3 font, which the page lists +/// but its content never selects. The cell 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_a_pattern_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 >>".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 /T4 7 0 R >> /Pattern << /P1 8 0 R >> >>".to_vec(), + type3_abc(9), + type3_abc(12), + stream("/Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 20 20] /XStep 20 /YStep 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"% PATTERN_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"PATTERN_GLYPH_B"), "the glyph the pattern shows was unlinked"); + assert!(saved_first_page_lists_font(&doc, b"T4"), "the page no longer lists the font the pattern selects"); +} + +/// The same with the group of a soft mask without /Resources: the page still lists the second +/// font, and the glyph the mask shows stays. +#[test] +fn a_type3_font_only_a_soft_mask_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 >>".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 /T4 7 0 R >> /ExtGState << /GS1 << /Type /ExtGState /SMask << /Type /Mask /S /Luminosity /G 8 0 R >> >> >> >>" + .to_vec(), + type3_abc(9), + type3_abc(12), + stream( + "/Type /XObject /Subtype /Form /BBox [0 0 300 300] /Group << /S /Transparency /CS /DeviceGray >>", + b"1 g BT /T4 40 Tf 160 180 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"% MASK_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"MASK_GLYPH_B"), "the glyph the soft mask shows was unlinked"); + assert!(saved_first_page_lists_font(&doc, b"T4"), "the page no longer lists the font the soft mask selects"); } /// The cell of a tiling pattern the page fills with shows glyph B: its program is kept. diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs index a39f1af144..32145c426a 100644 --- a/crates/redact/src/type3.rs +++ b/crates/redact/src/type3.rs @@ -401,6 +401,50 @@ fn resourceless_appearances(doc: &Document, page: &Dict) -> Result>, Ok(out) } +/// Add to `ops` those of the tiling patterns and soft mask groups without resources of their own +/// that `ops` select (`scn`, `SCN`, `gs`), and of the forms and further ones those draw in turn: +/// they draw through `res`, as [`scan_stream_object`] reads them. The forms that the first `known` +/// operators draw are in `ops` already. +fn add_pattern_and_mask_ops(doc: &Document, res: &Dict, ops: &mut Vec, known: usize) -> Result<(), CosError> { + let xobjects = res_dict(doc, res, b"XObject"); + let patterns = res_dict(doc, res, b"Pattern"); + let states = res_dict(doc, res, b"ExtGState"); + let mut seen = HashSet::new(); + let mut cursor = 0; + while let Some(op) = ops.get(cursor) { + let selected = match op.op.as_slice() { + b"scn" | b"SCN" => op.last_name().and_then(|n| patterns.get(n)).and_then(Object::as_ref), + b"gs" => op + .name(0) + .and_then(|n| states.get(n)) + .and_then(|s| doc.resolve(s).as_dict().and_then(|s| s.get(b"SMask").cloned())) + .and_then(|m| doc.resolve(&m).as_dict().and_then(|m| m.get(b"G").and_then(Object::as_ref))), + b"Do" if cursor >= known => op + .last_name() + .and_then(|n| xobjects.get(n)) + .and_then(Object::as_ref) + .filter(|r| matches!(&*doc.get(*r), Object::Stream(s) if s.dict.name(b"Subtype") == Some(b"Form"))), + _ => None, + }; + cursor += 1; + let Some(r) = selected else { continue }; + if !seen.insert(r) { + continue; + } + let obj = doc.get(r); + let Object::Stream(s) = &*obj else { continue }; + if s.dict.get(b"Resources").is_some_and(|ro| doc.resolve(ro).as_dict().is_some()) { + continue; + } + let parsed = pdfcraft_content::parse(&s.decoded()?); + if parsed.skipped > 0 || ops.len().saturating_add(parsed.ops.len()) > 2_000_000 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect pattern or soft mask content".into() }); + } + ops.extend(parsed.ops); + } + 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. @@ -445,14 +489,17 @@ 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 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))?); + // So do those of a page's annotation appearances, and of tiling patterns and soft mask groups, + // 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 fonts.iter().any(|(_, f)| doc.resolve(f).as_dict().is_some_and(|d| d.name(b"Subtype") == Some(b"Type3"))) { + if let Some(page) = page { + for data in resourceless_appearances(doc, page)? { + ops.extend(crate::interp::resource_ops(doc, res, std::slice::from_ref(&data))?); + } } + let known = ops.len(); + add_pattern_and_mask_ops(doc, res, &mut ops, known)?; } let mut drawn_font_names = HashSet::new();