From 67516d6365e44ce6189a86fd963310bca3b054b3 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 30 Oct 2026 22:17:13 -0700 Subject: [PATCH] File formats: JPEG XL swaps a rotated image's resolution and refuses unconverted gray profiles An orientation that exchanges the axes (5 to 8) now swaps the Exif horizontal and vertical resolution, so Open and Place size the oriented pixels correctly. A gray image whose embedded ICC profile the decoder can't convert to sRGB is refused instead of being stored as RGB without a profile. --- crates/engine/src/cmd/fileio/jxlread.rs | 31 +++++++++++++------ crates/engine/src/cmd/fileio/load.rs | 4 +-- crates/engine/src/cmd/fileio/tests_jxlread.rs | 28 +++++++++++++---- 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/fileio/jxlread.rs b/crates/engine/src/cmd/fileio/jxlread.rs index 011af6a0f6..9e0a0b64c9 100644 --- a/crates/engine/src/cmd/fileio/jxlread.rs +++ b/crates/engine/src/cmd/fileio/jxlread.rs @@ -6,7 +6,8 @@ //! system: XYB-encoded images, every color space a JPEG XL header names, and embedded ICC //! profiles that describe one of them. Any other image with an embedded ICC profile comes back //! with its samples as stored, as PNG and JPEG files with an embedded profile are drawn, and with -//! the profile when it is an RGB one. 16-bit and floating-point samples become 8 bits, alpha comes back straight, +//! the profile when it is an RGB one. Gray images with an ICC profile the decoder cannot convert +//! are refused. 16-bit and floating-point samples become 8 bits, alpha comes back straight, //! and CMYK images are refused. The file is untrusted: the decoder is given only a container's //! image boxes, the image's size is checked from its header before any pixels are decoded, and the //! decoder's image buffers are capped. @@ -39,13 +40,19 @@ pub fn is_jxl(bytes: &[u8]) -> bool { bytes.starts_with(&CODESTREAM) || bytes.starts_with(&CONTAINER) } +/// Resolution after the decoder applies the image header's orientation. +pub(crate) fn oriented_resolution(ppi: Option<(f64, f64)>, orientation: u32) -> Option<(f64, f64)> { + ppi.map(|(x, y)| if (5..=8).contains(&orientation) { (y, x) } else { (x, y) }) +} + /// The first frame of a JPEG XL image as straight RGBA, with the embedded ICC profile its colors -/// are in when they are not converted to sRGB ([`kept_profile`]). Fails, without decoding the +/// are in when they are not converted to sRGB ([`output_profile`]), and the header orientation. +/// Fails, without decoding the /// pixels, when 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. A /// container whose selected boxes exceed `max_alloc` or [`HEADER_BUFFERS`], whichever is greater, /// is refused with "the JPEG XL container is too large to read". -pub fn decode(bytes: &[u8], max_alloc: u64) -> Result<(image::RgbaImage, Option>), String> { +pub fn decode(bytes: &[u8], max_alloc: u64) -> Result<(image::RgbaImage, Option>, u32), String> { let pixel_boxes = pixel_boxes(bytes, max_alloc.max(HEADER_BUFFERS as u64))?; let bytes = pixel_boxes.as_deref().unwrap_or(bytes); // The decoder reads the header first, with buffers for the header alone. @@ -75,7 +82,8 @@ pub fn decode(bytes: &[u8], max_alloc: u64) -> Result<(image::RgbaImage, Option< let associated = metadata.alpha().and_then(|i| metadata.ec_info.get(i)).and_then(|ec| ec.alpha_associated()) == Some(true); let intent = RenderingIntent::Relative; let srgb = if metadata.grayscale() { EnumColourEncoding::gray_srgb(intent) } else { EnumColourEncoding::srgb(intent) }; - let profile = kept_profile(metadata.xyb_encoded, image.original_icc()); + let profile = output_profile(metadata.xyb_encoded, metadata.grayscale(), image.original_icc())?; + let orientation = metadata.orientation; // Premultiplied colors are divided by alpha in the encoding they are stored in, and converted // to sRGB after that. XYB-encoded images come from the decoder in linear sRGB (tone mapped when // they are HDR), which the sRGB transfer curve alone turns into sRGB. @@ -115,7 +123,7 @@ pub fn decode(bytes: &[u8], max_alloc: u64) -> Result<(image::RgbaImage, Option< } to_rgba(&row, channels, associated, convert.as_ref(), &mut colors, line)?; } - Ok((out, profile)) + Ok((out, profile, orientation)) } /// How colors whose alpha was divided out become sRGB. @@ -226,11 +234,14 @@ 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 embedded ICC profile `icc` when the colors come back as stored in it -/// ([`to_srgb_without_cms`] is false), for the PNG they are kept in. Only an RGB profile is kept: -/// the colors of a gray image come back as RGB. -pub(crate) fn kept_profile(xyb: bool, icc: Option<&[u8]>) -> Option> { - icc.filter(|icc| icc.get(16..20) == Some(b"RGB ") && !to_srgb_without_cms(xyb, Some(icc))).map(<[u8]>::to_vec) +/// The embedded RGB profile when the decoder leaves colors in it. Gray colors without a +/// conversion to sRGB cannot be stored as RGB without changing their profile. +pub(crate) fn output_profile(xyb: bool, grayscale: bool, icc: Option<&[u8]>) -> Result>, String> { + let converts = to_srgb_without_cms(xyb, icc); + if grayscale && !converts { + return Err(unreadable("unsupported grayscale ICC profile")); + } + Ok(icc.filter(|icc| icc.get(16..20) == Some(b"RGB ") && !converts).map(<[u8]>::to_vec)) } /// The decoder's error `e` as a message. When the decoder's buffers needed more than they were diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 11a05cc78f..f111076d26 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -636,8 +636,8 @@ pub fn raster_image(bytes: &[u8]) -> Result { return as_png(psdread::decode(bytes, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)).map_err(err)?, None, ppi); } if jxlread::is_jxl(bytes) { - let (img, icc) = jxlread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?; - return as_png(img, icc, ppi); + let (img, icc, orientation) = jxlread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?; + return as_png(img, icc, jxlread::oriented_resolution(ppi, orientation)); } let mut reader = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().map_err(err)?; if reader.format().is_none() && super::tgaread::is_targa(bytes) { diff --git a/crates/engine/src/cmd/fileio/tests_jxlread.rs b/crates/engine/src/cmd/fileio/tests_jxlread.rs index 50255ae0a7..1774cb3a27 100644 --- a/crates/engine/src/cmd/fileio/tests_jxlread.rs +++ b/crates/engine/src/cmd/fileio/tests_jxlread.rs @@ -6,7 +6,7 @@ use zune_core::bit_depth::BitDepth; use zune_core::colorspace::ColorSpace; use zune_core::options::EncoderOptions; -use super::jxlread::{ToSrgb, decode, is_jxl, kept_profile, pixel_boxes, to_rgba}; +use super::jxlread::{ToSrgb, decode, is_jxl, oriented_resolution, output_profile, pixel_boxes, to_rgba}; use super::*; /// A lossless JPEG XL codestream of `w` × `h` pixels: `samples` in `space`, 8-bit, or 16-bit in @@ -115,6 +115,16 @@ fn container_exif_resolution_sizes_open_and_place() { assert_eq!(super::ppi::resolution(&s.doc().unwrap().doc.images.values().next().unwrap().bytes), Some((144.0, 72.0))); } +#[test] +fn jxl_axis_exchanging_orientations_swap_resolution() { + let ppi = Some((144.0, 72.0)); + for orientation in 1..=8 { + let expected = if orientation >= 5 { Some((72.0, 144.0)) } else { ppi }; + assert_eq!(oriented_resolution(ppi, orientation), expected, "orientation {orientation}"); + } + assert_eq!(oriented_resolution(None, 6), None); +} + /// A `.jxl` file copied in a file manager pastes as its image. #[test] fn a_copied_jxl_file_pastes_as_its_image() { @@ -273,12 +283,18 @@ fn colors_left_in_their_icc_profile_keep_it() { let srgb = EnumColourEncoding::srgb(RenderingIntent::Relative); let rgb = unnamed(&srgb, b"rTRC"); assert_ne!(rgb, jxl_color::icc::colour_encoding_to_icc(&srgb), "a tag renamed"); - assert_eq!(kept_profile(false, Some(&rgb)), Some(rgb.clone())); - assert_eq!(kept_profile(true, Some(&rgb)), None, "XYB-encoded colors become sRGB"); - assert_eq!(kept_profile(false, Some(&jxl_color::icc::colour_encoding_to_icc(&srgb))), None, "converted to sRGB"); + assert_eq!(output_profile(false, false, Some(&rgb)).unwrap(), Some(rgb.clone())); + assert_eq!(output_profile(true, false, Some(&rgb)).unwrap(), None, "XYB-encoded colors become sRGB"); + assert_eq!(output_profile(false, false, Some(&jxl_color::icc::colour_encoding_to_icc(&srgb))).unwrap(), None, "converted to sRGB"); let gray = unnamed(&EnumColourEncoding::gray_srgb(RenderingIntent::Relative), b"kTRC"); - assert_eq!(kept_profile(false, Some(&gray)), None, "gray"); - assert_eq!(kept_profile(false, None), None); + assert!(!jxl_oxide::ColorEncodingWithProfile::with_icc(&gray).unwrap().icc_profile().is_empty()); + assert_eq!(output_profile(false, true, Some(&gray)).unwrap_err(), "the JPEG XL file can't be read: unsupported grayscale ICC profile"); + assert_eq!( + output_profile(false, true, Some(&jxl_color::icc::colour_encoding_to_icc(&EnumColourEncoding::gray_srgb(RenderingIntent::Relative)))) + .unwrap(), + None + ); + assert_eq!(output_profile(false, false, None).unwrap(), None); // The PNG carries the profile, the pixels and the resolution. let img = image::RgbaImage::from_raw(2, 1, vec![200, 100, 50, 255, 9, 9, 9, 0]).unwrap(); for icc in [Some(rgb), None] {