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] {