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() });