From 4347024e42ebac277987eaf8b0e5ccbab13554ed Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Wed, 28 Oct 2026 01:29:25 +0200 Subject: [PATCH 1/7] 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. --- .../redact/src/tests/surviving_references.rs | 83 +++++++++++++++++++ crates/redact/src/type3.rs | 81 ++++++++++++++++-- 2 files changed, 156 insertions(+), 8 deletions(-) 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. From 08237a5e3df8a00e8d01fb1953cfc2fb654893cc Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Thu, 29 Oct 2026 23:32:10 +0200 Subject: [PATCH 2/7] Type 3 cleanup reads only drawn appearances, and keeps the fonts they select An annotation's /AP can hold entries that no reader draws. Only the normal, rollover and down appearances (N, R, D) now keep a Type 3 glyph, so an extra entry such as /X can no longer keep a redacted glyph program in the saved file. Rewriting a page also dropped a Type 3 font that only an annotation appearance without /Resources selects, although that appearance draws through the page's resources. The page prune now reads those appearances too. It reads them only when the page lists a Type 3 font, so other files are not refused over an appearance it cannot parse. Two tests, both failing without this change. --- crates/redact/src/interp.rs | 2 +- crates/redact/src/lib.rs | 2 +- crates/redact/src/sanitize.rs | 2 +- .../redact/src/tests/surviving_references.rs | 60 +++++++++++++++++ crates/redact/src/type3.rs | 66 +++++++++++++++---- 5 files changed, 115 insertions(+), 17 deletions(-) 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 { 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 3/7] 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(); From 2468d8e470e87a271dbfb20e1e397b614474faf1 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Mon, 2 Nov 2026 01:35:12 +0200 Subject: [PATCH 4/7] Say why the form path prunes Type 3 fonts without a page Annotation appearances draw through a page's resources, never through a form's own, so the form path passes no page. The form's own patterns and soft masks are still counted. Asked in review. --- crates/redact/src/interp.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index 8ab7602503..edebb99dcb 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -884,6 +884,8 @@ fn xobject( out.error = Some(error); return None; } + // No page: annotation appearances draw through a page's resources, never through a + // form's. The form's own patterns and soft masks are still counted. if let Err(error) = crate::type3::prune_type3_from_resources(doc, &mut cleaned_res, content, None) { out.error = Some(error); return None; From d2974e1fa5d1672aab4a144d047e02234f07058b Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Fri, 6 Nov 2026 21:17:52 +0200 Subject: [PATCH 5/7] Type 3 pruning counts a page's appearances toward one operator total When a page lists a Type 3 font, prune_type3_from_resources (#5197) adds the operators of the page's annotation appearances without resources of their own. Each appearance was checked against the 2,000,000 cap on its own, so several under it could add up to far more, and the prune went on with a total no other path allows. The appearances now share the running total, and passing it refuses the redaction with the same kind of error as the other caps. --- .../redact/src/tests/surviving_references.rs | 26 +++++++++++++++++++ crates/redact/src/type3.rs | 8 +++++- 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs index 0864c701bd..b2e59a6465 100644 --- a/crates/redact/src/tests/surviving_references.rs +++ b/crates/redact/src/tests/surviving_references.rs @@ -386,6 +386,32 @@ fn a_type3_font_only_an_appearance_without_resources_selects_stays_on_the_page() assert!(saved_first_page_lists_font(&doc, b"T4"), "the page no longer lists the font the appearance selects"); } +/// The operators of a page's appearances count toward one total, as the content's do. Two +/// appearances under the cap alone pass it together, and the redaction is refused rather than +/// judged on part of what draws through the page's fonts. +#[test] +fn a_page_s_appearances_share_one_operator_total() { + let big = || stream("/Type /XObject /Subtype /Form /BBox [0 0 100 20]", b"n\n".repeat(1_100_000)); + 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 8 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(11), + b"<< /Type /Annot /Subtype /FreeText /Rect [150 200 250 220] /P 3 0 R /AP << /N 9 0 R >> >>".to_vec(), + b"<< /Type /Annot /Subtype /FreeText /Rect [150 240 250 260] /P 3 0 R /AP << /N 10 0 R >> >>".to_vec(), + big(), + big(), + stream("", b"% GLYPH_A"), + stream("", b"% GLYPH_B"), + stream("", b"% GLYPH_C"), + ]); + mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], ""); + let refused = apply(&mut doc, None); + assert!(refused.is_err(), "two appearances over the operator cap together were judged in part: {refused:?}"); +} + /// 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(); diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs index 32145c426a..78916c012f 100644 --- a/crates/redact/src/type3.rs +++ b/crates/redact/src/type3.rs @@ -494,8 +494,14 @@ pub(crate) fn prune_type3_from_resources(doc: &Document, res: &mut Dict, content // 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 { + // One total for all of them, as for the content: each appearance under the cap could + // still add up to far more than it. for data in resourceless_appearances(doc, page)? { - ops.extend(crate::interp::resource_ops(doc, res, std::slice::from_ref(&data))?); + let more = crate::interp::resource_ops(doc, res, std::slice::from_ref(&data))?; + if ops.len().saturating_add(more.len()) > 2_000_000 { + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect annotation appearances".into() }); + } + ops.extend(more); } } let known = ops.len(); From f0e400c1efe0f35cb08aa8164ce5c9a5f7c56e0b Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:21:55 +0200 Subject: [PATCH 6/7] Type 3 pruning leaves tiling patterns to the pattern guard A tiling pattern must have /Resources of its own, so its cell never draws through the page's. Since #3680, Apply Redactions refuses a page whose tiling pattern shows text or has no /Resources (pattern_guard), so prune_type3_from_resources reads only soft mask groups now. Remove Hidden Information runs no pattern guard: there, a page font that only such a malformed pattern uses goes, as it did before this PR. unlink_type3 still follows a pattern's cell, for patterns on pages the redaction does not touch. Its test now puts the pattern on a second page. --- crates/redact/src/interp.rs | 2 +- .../redact/src/tests/surviving_references.rs | 51 +++++-------------- crates/redact/src/type3.rs | 24 ++++----- 3 files changed, 27 insertions(+), 50 deletions(-) diff --git a/crates/redact/src/interp.rs b/crates/redact/src/interp.rs index e0f8963d90..57829889de 100644 --- a/crates/redact/src/interp.rs +++ b/crates/redact/src/interp.rs @@ -1077,7 +1077,7 @@ fn xobject( return None; } // No page: annotation appearances draw through a page's resources, never through a - // form's. The form's own patterns and soft masks are still counted. + // form's. The form's own soft masks are still counted. 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/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs index 0864c701bd..7723cc1baa 100644 --- a/crates/redact/src/tests/surviving_references.rs +++ b/crates/redact/src/tests/surviving_references.rs @@ -395,36 +395,9 @@ fn saved_first_page_lists_font(doc: &Document, name: &[u8]) -> bool { 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. +/// The group of a soft mask without /Resources shows glyph B of a second Type 3 font, which the +/// page lists but its content never selects. The group 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_soft_mask_without_resources_selects_stays_on_the_page() { let mut doc = pdf(vec![ @@ -454,18 +427,22 @@ fn a_type3_font_only_a_soft_mask_without_resources_selects_stays_on_the_page() { 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. +/// The cell of a tiling pattern another page fills with shows glyph B: its program is kept. (On +/// the redacted page itself, a pattern that shows text refuses the redaction: `pattern_guard`.) #[test] -fn a_type3_glyph_a_tiling_pattern_shows_is_kept() { +fn a_type3_glyph_a_tiling_pattern_on_another_page_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 /Pages /Kids [3 0 R 6 0 R] /Count 2 >>".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("", b"BT /T3 10 Tf 10 100 Td (AC) Tj ET"), + b"<< /Font << /T3 9 0 R >> >>".to_vec(), + b"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 300] /Contents 7 0 R /Resources 8 0 R >>".to_vec(), + stream("", b"/Pattern cs /P1 scn 200 200 50 50 re f"), + b"<< /Pattern << /P1 10 0 R >> >>".to_vec(), + type3_abc(11), stream( - "/Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 20 20] /XStep 20 /YStep 20 /Resources << /Font << /T3 6 0 R >> >>", + "/Type /Pattern /PatternType 1 /PaintType 1 /TilingType 1 /BBox [0 0 20 20] /XStep 20 /YStep 20 /Resources << /Font << /T3 9 0 R >> >>", b"BT /T3 10 Tf 2 5 Td (B) Tj ET", ), stream("", b"% REDACTED_GLYPH_A"), diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs index 32145c426a..1d8d315d76 100644 --- a/crates/redact/src/type3.rs +++ b/crates/redact/src/type3.rs @@ -401,19 +401,19 @@ 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> { +/// Add to `ops` those of the soft mask groups without resources of their own that `ops` set +/// (`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. Tiling patterns are not read here: a tiling pattern must have resources of its own, +/// so its cell draws through them, and Apply Redactions refuses one without them +/// (`pattern_guard`). +fn add_soft_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)) @@ -438,7 +438,7 @@ fn add_pattern_and_mask_ops(doc: &Document, res: &Dict, ops: &mut Vec 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() }); + return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect soft mask content".into() }); } ops.extend(parsed.ops); } @@ -489,9 +489,9 @@ 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, 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. + // So do those of a page's annotation appearances, and of 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)? { @@ -499,7 +499,7 @@ pub(crate) fn prune_type3_from_resources(doc: &Document, res: &mut Dict, content } } let known = ops.len(); - add_pattern_and_mask_ops(doc, res, &mut ops, known)?; + add_soft_mask_ops(doc, res, &mut ops, known)?; } let mut drawn_font_names = HashSet::new(); From f7d8fbf21d3f551ea025fd0c623126a12dc15614 Mon Sep 17 00:00:00 2001 From: oracola91 <118899664+oracola91@users.noreply.github.com> Date: Sat, 7 Nov 2026 16:50:04 +0200 Subject: [PATCH 7/7] Type 3 pruning decodes a page's appearances within one byte allowance prune_type3_from_resources decoded every appearance without resources of its own into one list before counting any operators, each up to the single-stream limit, so their sum had no bound. They are now decoded one at a time against one page's DecodeBudget, as the page content is, and passing it refuses the redaction. --- .../redact/src/tests/surviving_references.rs | 27 +++++++++++++++++++ crates/redact/src/type3.rs | 18 ++++++++----- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/crates/redact/src/tests/surviving_references.rs b/crates/redact/src/tests/surviving_references.rs index 8f5cee93a3..9042866e8b 100644 --- a/crates/redact/src/tests/surviving_references.rs +++ b/crates/redact/src/tests/surviving_references.rs @@ -412,6 +412,33 @@ fn a_page_s_appearances_share_one_operator_total() { assert!(refused.is_err(), "two appearances over the operator cap together were judged in part: {refused:?}"); } +/// Their decoded bytes share one page's allowance too. Two compressed appearances, each under it +/// but past it together (and blank, so far from the operator cap), refuse the redaction rather +/// than being decoded and held in full. +#[test] +fn a_page_s_appearances_share_one_decoded_byte_allowance() { + let packed = pdfcraft_filters::encode_flate(&vec![b' '; pdfcraft_cos::MAX_PAGE_DECODED / 2 + 1]); + let big = || stream("/Type /XObject /Subtype /Form /BBox [0 0 100 20] /Filter /FlateDecode", packed.as_slice()); + 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 8 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(11), + b"<< /Type /Annot /Subtype /FreeText /Rect [150 200 250 220] /P 3 0 R /AP << /N 9 0 R >> >>".to_vec(), + b"<< /Type /Annot /Subtype /FreeText /Rect [150 240 250 260] /P 3 0 R /AP << /N 10 0 R >> >>".to_vec(), + big(), + big(), + stream("", b"% GLYPH_A"), + stream("", b"% GLYPH_B"), + stream("", b"% GLYPH_C"), + ]); + mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], ""); + let refused = apply(&mut doc, None); + assert!(refused.is_err(), "two appearances past the page's decoded-byte allowance together were read in full: {refused:?}"); +} + /// 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(); diff --git a/crates/redact/src/type3.rs b/crates/redact/src/type3.rs index 2c13547215..496c211489 100644 --- a/crates/redact/src/type3.rs +++ b/crates/redact/src/type3.rs @@ -381,9 +381,9 @@ fn appearance_streams(doc: &Document, annot: &Object) -> Vec { 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> { +/// The 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) -> Vec { let mut seen = HashSet::new(); let mut out = Vec::new(); for annot in crate::annots_of(doc, page) { @@ -394,11 +394,11 @@ fn resourceless_appearances(doc: &Document, page: &Dict) -> Result>, 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()?); + out.push(r); } } } - Ok(out) + out } /// Add to `ops` those of the soft mask groups without resources of their own that `ops` set @@ -495,8 +495,12 @@ pub(crate) fn prune_type3_from_resources(doc: &Document, res: &mut Dict, content 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 { // One total for all of them, as for the content: each appearance under the cap could - // still add up to far more than it. - for data in resourceless_appearances(doc, page)? { + // still add up to far more than it. Their decoded bytes share one page's allowance + // too, and each is decoded only in its turn, so they are never all held at once. + let mut budget = pdfcraft_cos::DecodeBudget::page(); + for r in resourceless_appearances(doc, page) { + let Object::Stream(s) = &*doc.get(r) else { continue }; + let data = budget.decode(s)?; let more = crate::interp::resource_ops(doc, res, std::slice::from_ref(&data))?; if ops.len().saturating_add(more.len()) > 2_000_000 { return Err(CosError::Syntax { offset: 0, detail: "redaction could not fully inspect annotation appearances".into() });