diff --git a/crates/engine/src/cmd/fileio/jxlread.rs b/crates/engine/src/cmd/fileio/jxlread.rs index 5c6856ffa..021cd5909 100644 --- a/crates/engine/src/cmd/fileio/jxlread.rs +++ b/crates/engine/src/cmd/fileio/jxlread.rs @@ -5,7 +5,8 @@ //! Colors come back as sRGB whenever the decoder converts them without a color management //! 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. 16-bit and floating-point samples become 8 bits, alpha comes back straight, +//! 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, //! 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. @@ -38,10 +39,11 @@ pub fn is_jxl(bytes: &[u8]) -> bool { bytes.starts_with(&CODESTREAM) || bytes.starts_with(&CONTAINER) } -/// 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 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 { +/// 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 +/// 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. +pub fn decode(bytes: &[u8], max_alloc: u64) -> Result<(image::RgbaImage, Option>), String> { 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. @@ -71,6 +73,7 @@ pub fn decode(bytes: &[u8], max_alloc: u64) -> Result 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()); // 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. @@ -110,7 +113,7 @@ pub fn decode(bytes: &[u8], max_alloc: u64) -> Result } to_rgba(&row, channels, associated, convert.as_ref(), &mut colors, line)?; } - Ok(out) + Ok((out, profile)) } /// How colors whose alpha was divided out become sRGB. @@ -216,6 +219,13 @@ 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 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. diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 748843179..c32fec37f 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -509,7 +509,7 @@ pub(crate) fn too_large_note(bytes: &[u8]) -> Option { /// as RGBA, need more than [`MAX_RASTER_ALLOC`] is refused as too large to draw. /// A TIFF with more strips or tiles than the decoder can count /// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw. JPEG XL images are -/// read with [`jxlread`] and stored as PNG. +/// read with [`jxlread`] and stored as PNG, with the ICC profile their colors are left in. pub fn raster_image(bytes: &[u8]) -> Result { let ppi = super::ppi::resolution(bytes); if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) { @@ -517,10 +517,11 @@ pub fn raster_image(bytes: &[u8]) -> Result { } if psdread::is_psd(bytes) { let cms = vectorcraft_color::cms::active(); - return as_png(psdread::decode(bytes, MAX_RASTER_ALLOC, |c| cms.cmyk_to_srgb(c, false)).map_err(err)?, ppi); + 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) { - return as_png(jxlread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?, ppi); + let (img, icc) = jxlread::decode(bytes, MAX_RASTER_ALLOC).map_err(err)?; + return as_png(img, icc, ppi); } 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) { @@ -563,14 +564,20 @@ pub fn raster_image(bytes: &[u8]) -> Result { if f.id == "tga" { super::tgaread::fix_alpha(bytes, &mut img); } - as_png(img, ppi) + as_png(img, None, ppi) } -/// `img` stored as a PNG that keeps the file's resolution. -fn as_png(img: image::RgbaImage, ppi: Option<(f64, f64)>) -> Result { +/// `img` stored as a PNG that keeps the file's resolution and carries `icc`, the ICC profile its +/// colors are in. +pub(super) fn as_png(img: image::RgbaImage, icc: Option>, ppi: Option<(f64, f64)>) -> Result { + use image::ImageEncoder as _; let size = img.dimensions(); let mut png = Vec::new(); - img.write_to(&mut Cursor::new(&mut png), image::ImageFormat::Png).map_err(err)?; + let mut encoder = image::codecs::png::PngEncoder::new(&mut png); + if let Some(icc) = icc { + encoder.set_icc_profile(icc).map_err(err)?; + } + img.write_with_encoder(encoder).map_err(err)?; let png = match ppi { Some(r) => super::ppi::with_png_resolution(&png, r), None => png, diff --git a/crates/engine/src/cmd/fileio/tests_jxlread.rs b/crates/engine/src/cmd/fileio/tests_jxlread.rs index f3768c003..1c248f486 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, to_rgba}; +use super::jxlread::{ToSrgb, decode, is_jxl, kept_profile, to_rgba}; use super::*; /// A lossless JPEG XL codestream of `w` × `h` pixels: `samples` in `space`, 8-bit, or 16-bit in @@ -91,7 +91,7 @@ fn sixteen_bit_samples_become_eight_bits() { let f = jxl(2, 2, ColorSpace::RGB, BitDepth::Sixteen, &samples); let to8 = |v: u16| (f64::from(v) / 65535.0 * 255.0).round() as u8; let want: Vec<[u8; 4]> = rgb.iter().map(|p| [to8(p[0]), to8(p[1]), to8(p[2]), 255]).collect(); - assert_eq!(decode(&f, 1 << 30).unwrap().pixels().map(|p| p.0).collect::>(), want); + assert_eq!(decode(&f, 1 << 30).unwrap().0.pixels().map(|p| p.0).collect::>(), want); // Gray, 16 bits, in the container, opened as a document. let gray: Vec = [0xffffu16, 0x8080, 0x0000, 0x4040].iter().flat_map(|v| v.to_ne_bytes()).collect(); let f = container(&jxl(2, 2, ColorSpace::Luma, BitDepth::Sixteen, &gray)); @@ -202,7 +202,47 @@ fn metadata_boxes_are_not_read() { boxed(b"Exif", &[0, 0, 0, 0, b'M', b'M', 0, 42]); boxed(b"brob", &[b'x', b'm', b'l', b' ', 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]); boxed(b"jxlc", &rgba8()); - assert_eq!(decode(&f, 1 << 30).unwrap().pixels().map(|p| p.0).collect::>(), RGBA); + assert_eq!(decode(&f, 1 << 30).unwrap().0.pixels().map(|p| p.0).collect::>(), RGBA); +} + +/// Colors left in their embedded RGB profile keep it: the PNG they are stored in carries the +/// profile. Colors converted to sRGB, and a gray image's colors, which come back as RGB, are stored +/// without one. +#[test] +fn colors_left_in_their_icc_profile_keep_it() { + use image::ImageDecoder as _; + use jxl_oxide::{EnumColourEncoding, RenderingIntent}; + // A profile jxl-color can't turn into a JPEG XL color space, so the decoder leaves the colors + // in it: the sRGB profile with the tag of its red curve renamed. + let unnamed = |encoding: &EnumColourEncoding, curve: &[u8; 4]| { + let mut icc = jxl_color::icc::colour_encoding_to_icc(encoding); + let count = u32::from_be_bytes(icc[128..132].try_into().unwrap()) as usize; + for at in (0..count).map(|i| 132 + 12 * i) { + if icc[at..at + 4] == curve[..] { + icc[at..at + 4].copy_from_slice(b"xTRC"); + } + } + icc + }; + 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"); + 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); + // 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] { + let RasterImage { blob, width, height, .. } = super::load::as_png(img.clone(), icc.clone(), Some((144.0, 144.0))).unwrap(); + assert_eq!((width, height), (2, 1)); + let mut png = image::codecs::png::PngDecoder::new(std::io::Cursor::new(&blob.bytes[..])).unwrap(); + assert_eq!(png.icc_profile().unwrap(), icc); + assert_eq!(image::DynamicImage::from_decoder(png).unwrap().to_rgba8(), img); + assert_eq!(super::ppi::resolution(&blob.bytes).map(|(x, y)| (x.round(), y.round())), Some((144.0, 144.0))); + } } #[test]