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], ),