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.
This commit is contained in:
dotCooCoo
2026-10-30 22:17:13 -07:00
parent 6118e7afba
commit 67516d6365
3 changed files with 45 additions and 18 deletions
+21 -10
View File
@@ -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<Vec<u8>>), String> {
pub fn decode(bytes: &[u8], max_alloc: u64) -> Result<(image::RgbaImage, Option<Vec<u8>>, 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<Vec<u8>> {
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<Option<Vec<u8>>, 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
+2 -2
View File
@@ -636,8 +636,8 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
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) {
+22 -6
View File
@@ -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] {