Merge #6474: Type 3 pruning counts a page's appearances toward one operator total

# Conflicts:
#	crates/redact/src/tests/surviving_references.rs
This commit is contained in:
bflatastic committed 2026-11-09 08:05:56 +07:00
commit b6ec3fe38d
5 files changed
+411 -14

No files matched your search

+3 -1
View File
@@ -1097,7 +1097,9 @@ fn xobject(
out.error = Some(error);
return None;
}
if let Err(error) = crate::type3::prune_type3_from_resources(doc, &mut cleaned_res, content) {
// No page: annotation appearances draw through a page's resources, never through a
// 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;
}
+1 -1
View File
@@ -855,7 +855,7 @@ fn redact_page_content(
let painted = if out.keeps_all_patterns { &out.patterns_seen } else { &out.patterns_painted };
report.patterns_removed += interp::prune_patterns(doc, &mut res, &out.patterns_seen, painted, pruned_patterns);
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));
+1 -1
View File
@@ -642,7 +642,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));
@@ -342,3 +342,238 @@ fn a_type3_glyphs_form_with_non_dictionary_resources_fails_closed() {
mark(&mut doc, 0, &[[9.0, 95.0, 14.9, 110.0]], "");
assert!(apply(&mut doc, None).is_err());
}
/// 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 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");
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:?}");
}
/// 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();
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();
res.get(b"Font").and_then(|f| saved.resolve(f).as_dict().map(|fonts| fonts.contains(name))).unwrap_or(false)
}
/// 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![
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 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_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 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"),
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 9 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");
}
+171 -11
View File
@@ -308,6 +308,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),
}
}
@@ -370,20 +381,145 @@ 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) {
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<ObjRef> {
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 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<ObjRef> {
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(r);
}
}
}
out
}
/// 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<pdfcraft_content::Op>, known: usize) -> Result<(), CosError> {
let xobjects = res_dict(doc, res, b"XObject");
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"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 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.
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),
}
}
@@ -396,8 +532,9 @@ fn gs_font_object(doc: &Document, state_obj: &Object) -> Option<Object> {
}
/// 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<u8>]) -> 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<u8>], page: Option<&Dict>) -> Result<(), CosError> {
let mut fonts = res_dict(doc, res, b"Font");
if fonts.is_empty() {
return Ok(());
@@ -405,7 +542,29 @@ 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, 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 {
// One total for all of them, as for the content: each appearance under the cap could
// 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() });
}
ops.extend(more);
}
}
let known = ops.len();
add_soft_mask_ops(doc, res, &mut ops, known)?;
}
let mut drawn_font_names = HashSet::new();
for op in &ops {
@@ -530,6 +689,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.