mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-11-11 23:32:14 +00:00
File formats: a JPEG XL image keeps the RGB ICC profile its colors stay in
When a JPEG XL image embeds an ICC profile that the decoder can't convert to sRGB without a color management system, its colors come back as stored. The PNG the image was stored as left the profile out, so a saved document or an SVG export held those colors without their color space. That PNG now carries the profile when it is an RGB one. Colors converted to sRGB are stored without a profile, as before, and so are a gray image's colors, which come back as RGB.
This commit is contained in:
@@ -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<image::RgbaImage, String> {
|
||||
/// 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<Vec<u8>>), 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<image::RgbaImage, String>
|
||||
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<image::RgbaImage, String>
|
||||
}
|
||||
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<Vec<u8>> {
|
||||
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.
|
||||
|
||||
@@ -509,7 +509,7 @@ pub(crate) fn too_large_note(bytes: &[u8]) -> Option<String> {
|
||||
/// 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<RasterImage> {
|
||||
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<RasterImage> {
|
||||
}
|
||||
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<RasterImage> {
|
||||
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<RasterImage> {
|
||||
/// `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<Vec<u8>>, ppi: Option<(f64, f64)>) -> Result<RasterImage> {
|
||||
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,
|
||||
|
||||
@@ -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::<Vec<_>>(), want);
|
||||
assert_eq!(decode(&f, 1 << 30).unwrap().0.pixels().map(|p| p.0).collect::<Vec<_>>(), want);
|
||||
// Gray, 16 bits, in the container, opened as a document.
|
||||
let gray: Vec<u8> = [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::<Vec<_>>(), RGBA);
|
||||
assert_eq!(decode(&f, 1 << 30).unwrap().0.pixels().map(|p| p.0).collect::<Vec<_>>(), 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]
|
||||
|
||||
Reference in New Issue
Block a user