From 7c513f3f3a85a0739c2addcb312caae94bc72e1d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Sun, 18 Oct 2026 16:39:02 -0700 Subject: [PATCH] File formats: a JPEG XL image whose decoder buffers run out is too large to draw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The decoder's pixel buffers are capped by the image's size and the raster allowance. When they need more, File › Open and Place now say the image is too large to draw, as they do when its size alone is over the allowance, instead of saying the file can't be read. Damaged and cut-short files keep their messages. --- Cargo.lock | 1 + Cargo.toml | 2 + crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/fileio/jxlread.rs | 39 ++++++++++++++----- crates/engine/src/cmd/fileio/tests_jxlread.rs | 17 +++++++- 5 files changed, 48 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8d886823e..431791145 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4839,6 +4839,7 @@ dependencies = [ "flate2", "image", "jxl-color", + "jxl-grid", "jxl-oxide", "kurbo 0.13.1", "log", diff --git a/Cargo.toml b/Cargo.toml index 6fcec350e..496c5de94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -94,6 +94,8 @@ zune-jpeg = "0.5" jxl-oxide = { version = "0.12.6", default-features = false } # jxl-oxide's color conversions, run on colors after their alpha is divided out. jxl-color = "0.11.0" +# jxl-oxide's buffer allocation error, told apart from other decoding errors. +jxl-grid = "0.6.2" # Tests: a lossless JPEG XL encoder (pure Rust, MIT OR Apache-2.0 OR Zlib) for the `.jxl` test files. zune-jpegxl = { version = "0.5.2", default-features = false, features = ["std"] } zune-core = "0.5" diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 36557e46f..e87992f44 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -38,6 +38,7 @@ vectorcraft-scriptcheck = { workspace = true } image = { workspace = true } jxl-oxide = { workspace = true } jxl-color = { workspace = true } +jxl-grid = { workspace = true } flate2 = { workspace = true } # The XML parts of Word documents placed as type (place/docx.rs). roxmltree = "0.21" diff --git a/crates/engine/src/cmd/fileio/jxlread.rs b/crates/engine/src/cmd/fileio/jxlread.rs index 1826c43df..5c6856ffa 100644 --- a/crates/engine/src/cmd/fileio/jxlread.rs +++ b/crates/engine/src/cmd/fileio/jxlread.rs @@ -39,16 +39,17 @@ pub fn is_jxl(bytes: &[u8]) -> bool { } /// The first frame of a JPEG XL image as straight RGBA. Fails, without decoding the pixels, when -/// the image would take more than `max_alloc` bytes as RGBA. +/// the image would take more than `max_alloc` bytes as RGBA. The image is also too large to draw +/// when the decoder's buffers for its pixels need more than they are allowed. pub fn decode(bytes: &[u8], max_alloc: u64) -> Result { let pixel_boxes = pixel_boxes(bytes); let bytes = pixel_boxes.as_deref().unwrap_or(bytes); // The decoder reads the header first, with buffers for the header alone. - let (head, _) = header(bytes, HEADER_BUFFERS)?; + let (head, _) = header(bytes, HEADER_BUFFERS, None)?; let (w, h) = (head.width(), head.height()); let rgba = u64::from(w) * u64::from(h) * 4; if rgba > max_alloc { - return Err(format!("an image of {w} × {h} pixels is too large to draw")); + return Err(too_large(w, h)); } if head.pixel_format().has_black() { return Err("CMYK JPEG XL images can't be read".into()); @@ -56,14 +57,15 @@ pub fn decode(bytes: &[u8], max_alloc: u64) -> Result // Then it reads the file again until the first frame is loaded, with buffers for the pixels. let budget = rgba.saturating_mul(DECODER_BUFFERS).saturating_add(HEADER_BUFFERS as u64).min(max_alloc); let budget = usize::try_from(budget).unwrap_or(usize::MAX); - let (mut image, mut at) = header(bytes, budget)?; + let size = Some((w, h)); + let (mut image, mut at) = header(bytes, budget, size)?; let mut end = at; while image.num_loaded_keyframes() == 0 { if end == bytes.len() { return Err(TRUNCATED.into()); } end = end.saturating_mul(2).max(HEAD).min(bytes.len()); - at += image.feed_bytes(&bytes[at..end]).map_err(unreadable)?; + at += image.feed_bytes(&bytes[at..end]).map_err(|e| failed(e, size))?; } let metadata = &image.image_header().metadata; let associated = metadata.alpha().and_then(|i| metadata.ec_info.get(i)).and_then(|ec| ec.alpha_associated()) == Some(true); @@ -92,7 +94,7 @@ pub fn decode(bytes: &[u8], max_alloc: u64) -> Result if let Some(encoding) = request { image.request_color_encoding(encoding); } - let render = image.render_frame(0).map_err(unreadable)?; + let render = image.render_frame(0).map_err(|e| failed(e, size))?; let mut stream = render.stream(); let channels = stream.channels() as usize; if !(1..=4).contains(&channels) { @@ -191,14 +193,15 @@ fn pixel_boxes(bytes: &[u8]) -> Option> { } /// A decoder whose buffers may take `buffers` bytes, given as few of `bytes` as it needs to read -/// the header → (the decoder, how many bytes it took). -fn header(bytes: &[u8], buffers: usize) -> Result<(JxlImage, usize), String> { +/// the header → (the decoder, how many bytes it took). `size` is the image's size when it is +/// known, for the error when the buffers need more ([`failed`]). +fn header(bytes: &[u8], buffers: usize, size: Option<(u32, u32)>) -> Result<(JxlImage, usize), String> { let mut uninit = JxlImage::builder().alloc_tracker(AllocTracker::with_limit(buffers)).build_uninit(); let (mut at, mut end) = (0, 0usize); loop { end = end.saturating_mul(2).max(HEAD).min(bytes.len()); - at += uninit.feed_bytes(&bytes[at..end]).map_err(unreadable)?; - match uninit.try_init().map_err(unreadable)? { + at += uninit.feed_bytes(&bytes[at..end]).map_err(|e| failed(e, size))?; + match uninit.try_init().map_err(|e| failed(e, size))? { InitializeResult::Initialized(image) => return Ok((image, at)), InitializeResult::NeedMoreData(_) if end == bytes.len() => return Err(TRUNCATED.into()), InitializeResult::NeedMoreData(more) => uninit = more, @@ -213,6 +216,22 @@ fn to_srgb_without_cms(xyb: bool, icc: Option<&[u8]>) -> bool { xyb || icc.is_none_or(|icc| ColorEncodingWithProfile::with_icc(icc).is_ok_and(|p| p.icc_profile().is_empty())) } +/// The decoder's error `e` as a message. When the decoder's buffers needed more than they were +/// allowed and the image's `size` is known, the image is too large to draw. Otherwise the file +/// can't be read. +fn failed(e: Box, size: Option<(u32, u32)>) -> String { + let first: &(dyn std::error::Error + 'static) = &*e; + let out_of_memory = std::iter::successors(Some(first), |c| c.source()).any(|c| c.is::()); + match size { + Some((w, h)) if out_of_memory => too_large(w, h), + _ => unreadable(e), + } +} + +fn too_large(w: u32, h: u32) -> String { + format!("an image of {w} × {h} pixels is too large to draw") +} + fn unreadable(e: impl std::fmt::Display) -> String { format!("the JPEG XL file can't be read: {e}") } diff --git a/crates/engine/src/cmd/fileio/tests_jxlread.rs b/crates/engine/src/cmd/fileio/tests_jxlread.rs index e4d3a1aad..f3768c003 100644 --- a/crates/engine/src/cmd/fileio/tests_jxlread.rs +++ b/crates/engine/src/cmd/fileio/tests_jxlread.rs @@ -127,12 +127,25 @@ fn an_image_too_large_is_refused_from_its_header() { let mut s = Session::new(); let e = s.execute("document.open", &json!({"name": "huge.jxl", "dataBase64": b64(&huge)})).unwrap_err().to_string(); assert!(e.contains("too large to draw"), "{e}"); - // The allowance counts RGBA bytes: 3 × 2 pixels take 24 (the decoder's own buffers then need more). + // The allowance counts RGBA bytes: 3 × 2 pixels take 24. assert!(decode(&rgba8(), 23).unwrap_err().contains("too large")); - assert!(!decode(&rgba8(), 24).unwrap_err().contains("too large")); assert!(decode(&rgba8(), 1 << 20).is_ok()); } +/// The decoder's buffers take more than the image as RGBA. When they need more than the allowance, +/// the image is too large to draw, as when its size alone is. +#[test] +fn an_image_whose_decoder_buffers_pass_the_allowance_is_too_large() { + assert_eq!(decode(&rgba8(), 24).unwrap_err(), "an image of 3 × 2 pixels is too large to draw"); + let gray: Vec = (0..64 * 64).map(|i| (i % 251) as u8).collect(); + let f = container(&jxl(64, 64, ColorSpace::Luma, BitDepth::Eight, &gray)); + assert_eq!(decode(&f, 64 * 64 * 4).unwrap_err(), "an image of 64 × 64 pixels is too large to draw"); + assert!(decode(&f, 1 << 20).is_ok()); + // A file cut short is still reported as cut short or unreadable. + let e = decode(&f[..f.len() - 3], 1 << 20).unwrap_err(); + assert!(!e.contains("too large"), "{e}"); +} + /// Premultiplied colors are divided by their alpha before they are converted to sRGB: a linear /// 0.25 at alpha 0.5 is a straight linear 0.5, which sRGB stores as 0.735 (188 of 255). #[test]