From 199354246be8f935b604713ae2a0868edb00c4d3 Mon Sep 17 00:00:00 2001 From: Grzegorz Martens Date: Sat, 31 Oct 2026 17:36:44 +0100 Subject: [PATCH] M2.6: redaction reads past /Crypt, and what can't be averaged spends nothing Redaction checked the raw /Filter array, so /Crypt in a chain removed an image it could have cleared, though decoding leaves /Crypt out. It leaves it out too. Decoded only to be resampled, an image that can't be averaged came to None after the decode, having spent the run's allowance and perhaps been counted as refused. Each such case now stops before it. Found by CodeRabbit. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AmsdeUCdTaD72jkDw2pJKY --- crates/optimize/src/images.rs | 35 ++++++++++++++++++++++++++++++++++- crates/redact/src/image.rs | 16 +++++++++++----- crates/redact/src/tests.rs | 7 +++++++ 3 files changed, 52 insertions(+), 6 deletions(-) diff --git a/crates/optimize/src/images.rs b/crates/optimize/src/images.rs index 51c130abd8..7666c036fc 100644 --- a/crates/optimize/src/images.rs +++ b/crates/optimize/src/images.rs @@ -1102,7 +1102,10 @@ fn mono(doc: &Document, s: &Stream, ppi: f64, defaults: bool, settings: &MonoSet // JBIG2 (whose symbols may be smaller than its own coding) can be smaller stored again alone. let alone_to_g4 = s.filters().len() == 1 && settings.compression == MonoCompression::CcittG4; let only_to_resample = retain || (alone_to_g4 && (jbig2 || fax.is_some_and(|p| p.k < 0))); - if only_to_resample && !downsample { + // What can't be averaged, whatever it holds, comes to the same `None` below, after a decode that + // would spend the run's allowance on it. + let too_wide = d.int(b"Width").is_some_and(|w| w > i64::from(pdfcraft_filters::MAX_CCITT_COLUMNS)); + if only_to_resample && (!downsample || defaults || too_wide || (jbig2 && dark_is_one(doc, d).is_none())) { return None; } let (data, w, h, packed) = one_bit(doc, s, !retain, defaults, budget)?; @@ -1398,6 +1401,36 @@ mod dimension_tests { assert_eq!(changed, 2, "the third is as it was"); } + /// Decoded only to be resampled, what can't be averaged whatever it holds isn't decoded, so it + /// takes nothing from the images that can be, and isn't counted as refused past the allowance. + #[test] + fn a_jbig2_image_that_cant_be_averaged_spends_nothing() { + let image = |edit: &dyn Fn(&mut Dict)| { + let mut dict = Dict::new(); + dict.set(b"Subtype".to_vec(), Object::name("Image")); + dict.set(b"Width".to_vec(), Object::Int(16)); + dict.set(b"Height".to_vec(), Object::Int(2)); + dict.set(b"BitsPerComponent".to_vec(), Object::Int(1)); + dict.set(b"ColorSpace".to_vec(), Object::name("DeviceGray")); + dict.set(b"Filter".to_vec(), Object::name("JBIG2Decode")); + edit(&mut dict); + Stream::from_raw(dict, pdfcraft_filters::encode_jbig2_generic(&[0xF0; 4], 16, 2).unwrap()) + }; + let settings = MonoSettings { compression: MonoCompression::Retain, ..MonoSettings::REDUCE }; + let spent = |s: &Stream, defaults: bool| { + let mut budget = ImageBudget::with_work(u64::MAX); + let _ = mono(&Document::new_empty(), s, 720.0, defaults, &settings, &mut budget); + u64::MAX - budget.work_left() + }; + let plain = image(&|_| {}); + assert!(spent(&plain, false) > 0, "one that can be averaged is decoded"); + assert_eq!(spent(&plain, true), 0, "drawn where a device colour space is remapped"); + let wide = image(&|d| d.set(b"Width".to_vec(), Object::Int(i64::from(pdfcraft_filters::MAX_CCITT_COLUMNS) + 1))); + assert_eq!(spent(&wide, false), 0, "too wide to average"); + let tinted = image(&|d| d.set(b"ColorSpace".to_vec(), Object::Array(vec![Object::name("CalGray"), Object::Dict(Dict::new())]))); + assert_eq!(spent(&tinted, false), 0, "no way to tell which sample is dark"); + } + /// A JPEG larger than its dictionary says is refused from its header, before its pixels are decoded. #[test] fn a_jpeg_of_another_size_than_its_dictionary_is_not_decoded() { diff --git a/crates/redact/src/image.rs b/crates/redact/src/image.rs index 2880bd2304..52377078be 100644 --- a/crates/redact/src/image.rs +++ b/crates/redact/src/image.rs @@ -59,13 +59,19 @@ fn decode_and_clear(doc: &Document, s: &Stream, ctm: &Matrix, rects: &[[f64; 4]] let fax = |f: &Object| f.as_name().is_some_and(|n| matches!(n, b"CCITTFaxDecode" | b"CCF")); let jbig2 = |f: &Object| f.as_name() == Some(b"JBIG2Decode".as_slice()); let readable = |f: &Object| f.as_name().is_some_and(supported); + // `/Crypt` was undone when the object was loaded (ISO 32000-2 ยง7.4.10), and decoding leaves it + // out wherever it stands. + let crypt = |f: &Object| f.as_name() == Some(b"Crypt".as_slice()); let (ok, is_fax, is_jbig2) = match s.dict.get(b"Filter") { None => (true, false, false), - Some(f @ Object::Name(_)) => (readable(f) || fax(f) || jbig2(f), fax(f), jbig2(f)), - Some(Object::Array(a)) => match a.split_last() { - Some((last, before)) if fax(last) || jbig2(last) => (before.iter().all(readable), fax(last), jbig2(last)), - _ => (a.iter().all(readable), false, false), - }, + Some(f @ Object::Name(_)) => (readable(f) || crypt(f) || fax(f) || jbig2(f), fax(f), jbig2(f)), + Some(Object::Array(a)) => { + let chain: Vec<&Object> = a.iter().filter(|f| !crypt(f)).collect(); + match chain.split_last() { + Some((last, before)) if fax(last) || jbig2(last) => (before.iter().all(|f| readable(f)), fax(last), jbig2(last)), + _ => (chain.iter().all(|f| readable(f)), false, false), + } + } _ => (false, false, false), }; if !ok { diff --git a/crates/redact/src/tests.rs b/crates/redact/src/tests.rs index a14dffdb98..a547193dce 100644 --- a/crates/redact/src/tests.rs +++ b/crates/redact/src/tests.rs @@ -804,6 +804,13 @@ fn a_jbig2_image_has_its_covered_pixels_cleared_instead_of_being_removed() { ( format!("{image} /ColorSpace /DeviceGray /Filter [/ASCIIHexDecode /JBIG2Decode] /DecodeParms [null << /JBIG2Globals 8 0 R >>]"), hex, + vec![globals.clone()], + [0x00, 0xFF, 0xFF, 0xFF], + ), + // `/Crypt` was undone on load, and takes a place in the parameters. + ( + format!("{image} /ColorSpace /DeviceGray /Filter [/Crypt /JBIG2Decode] /DecodeParms [null << /JBIG2Globals 8 0 R >>]"), + white.clone(), vec![globals], [0x00, 0xFF, 0xFF, 0xFF], ),