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