From c80b5909f5cd8ee189b03feed8e8903523bea1e4 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Sun, 8 Nov 2026 12:13:14 -0500 Subject: [PATCH] review: refuse an image whose soft mask could not be decoded --- crates/cos/src/budget.rs | 6 ++++++ crates/create/src/extract.rs | 21 +++++++++++++++++---- crates/create/src/tests.rs | 7 +++++-- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/crates/cos/src/budget.rs b/crates/cos/src/budget.rs index 376b108be3..93f83e4a5e 100644 --- a/crates/cos/src/budget.rs +++ b/crates/cos/src/budget.rs @@ -172,6 +172,12 @@ impl ImageBudget { self.pay_pixels(samples.saturating_mul(JPEG_WORK).saturating_add(reading)) } + /// Records an image left out for a reason other than its allowance, so that the caller + /// refuses it as it refuses one whose decode spent everything. + pub fn refuse(&mut self) { + self.refused += 1; + } + /// Pays for the pixels of image `s` that its `decoded` data holds, those of its `/Width` and /// `/Height` but no more than a bit of the data each, unless it is JBIG2, whose decoder pays for /// its page, or isn't decoded here. What can't be paid for refuses the image. diff --git a/crates/create/src/extract.rs b/crates/create/src/extract.rs index 375d14fab1..dea09b9549 100644 --- a/crates/create/src/extract.rs +++ b/crates/create/src/extract.rs @@ -547,7 +547,9 @@ fn soft_mask(doc: &Document, s: &Stream, w: usize, h: usize, budget: &mut ImageB let r = s.dict.get(b"SMask")?.as_ref()?; let obj = doc.get(r); let Object::Stream(m) = &*obj else { return None }; - gray_plane(doc, m, w, h, budget) + // A soft mask that cannot be decoded leaves the image showing what the mask hides, so the + // image is refused rather than written out opaque. + gray_plane(doc, m, w, h, budget, true) } /// A stencil `/Mask` (an image mask stream, 8.9.6.3) as 8-bit alpha: its 1 samples (0 with @@ -556,12 +558,12 @@ fn stencil_mask(doc: &Document, s: &Stream, w: usize, h: usize, budget: &mut Ima let r = s.dict.get(b"Mask")?.as_ref()?; let obj = doc.get(r); let Object::Stream(m) = &*obj else { return None }; - Some(gray_plane(doc, m, w, h, budget)?.into_iter().map(|v| 255 - v).collect()) + Some(gray_plane(doc, m, w, h, budget, false)?.into_iter().map(|v| 255 - v).collect()) } /// A one-component mask image as 8-bit values (its `/Decode` interval applied), scaled to /// `w` × `h`. `None` when it can't be decoded (JPEG 2000 masks). -fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize, budget: &mut ImageBudget) -> Option> { +fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize, budget: &mut ImageBudget, soft: bool) -> Option> { let (mw, mh) = size(m.dict.int(b"Width")?, m.dict.int(b"Height")?)?; let stencil = m.dict.get(b"ImageMask").is_some_and(|v| matches!(&*doc.resolve(v), Object::Bool(true))); let f = filters(m); @@ -585,7 +587,18 @@ fn gray_plane(doc: &Document, m: &Stream, w: usize, h: usize, budget: &mut Image _ => return None, }; let max = (1u32 << bpc.min(8)) - 1; - unpack(&decoded(doc, m, (mw, mh, 1, bpc), budget).ok()?, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect() + let data = match decoded(doc, m, (mw, mh, 1, bpc), budget) { + Ok(data) => data, + Err(_) => { + // A stage limit stops the mask being decoded: a soft mask leaves the image + // refusing it rather than showing what the mask hides. + if soft { + budget.refuse(); + } + return None; + } + }; + unpack(&data, mw, mh, 1, bpc)?.into_iter().map(|v| (v as u32 * 255 / max) as u8).collect() } }; if let Some([lo, hi]) = decode(doc, m, 1).and_then(|pairs| pairs.first().copied()) { diff --git a/crates/create/src/tests.rs b/crates/create/src/tests.rs index dd292c1a40..39cfd7dcbf 100644 --- a/crates/create/src/tests.rs +++ b/crates/create/src/tests.rs @@ -859,7 +859,8 @@ fn image_data_far_past_its_samples_is_not_decoded() { let (ext, rgba) = exported_rgba(&bomb(4, 2, 4 * 2 + 64)); assert_eq!((ext, rgba), ("png", [7, 7, 7, 255].repeat(8))); - // The same for a soft mask: it is not decoded, and the image exports without it. + // The same for a soft mask: it is not decoded, and the image that needs it is refused rather + // than written out without the transparency it hides. let mut doc = from_images(&[("flat.png".into(), png_bytes(false))]).unwrap(); with_image(&mut doc, |doc| { let m = doc.add(Object::Stream(Stream::flate(image_dict(1, 1, "DeviceGray", 8), &vec![0u8; 64 << 20]))); @@ -867,7 +868,9 @@ fn image_data_far_past_its_samples_is_not_decoded() { d.set(b"SMask".to_vec(), Object::Ref(m)); Stream::from_raw(d, vec![10; 24]) }); - assert_eq!(alphas(&exported_rgba(&doc).1), [255; 8]); + let out = extract_images(&doc, &[0], 0); + assert!(out.images.is_empty(), "an image is not written without the mask that hides its content"); + assert_eq!(out.skipped.len(), 1); } #[test]