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]