diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index ea2c44215e..272fffeec9 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -151,24 +151,180 @@ fn planar(d: &mut tiff::decoder::Decoder>) -> bool { /// that many samples, which includes images whose planes together need more than the decoder's /// limit (256 MB of samples). pub(crate) fn read_samples(d: &mut tiff::decoder::Decoder>, n: usize, per: usize) -> Option> { + Some(match read_samples_at_depth(d, n, per)? { + Samples::Eight(v) => v, + Samples::Sixteen(v) => v.iter().map(|s| (s >> 8) as u8).collect(), + }) +} + +/// Samples of an image, pixel by pixel, at the depth its file stores them. +#[derive(Clone, Debug, PartialEq)] +enum Samples { + Eight(Vec), + Sixteen(Vec), +} + +impl Samples { + fn as_slice(&self) -> SampleSlice<'_> { + match self { + Self::Eight(v) => SampleSlice::Eight(v), + Self::Sixteen(v) => SampleSlice::Sixteen(v), + } + } +} + +/// [`Samples`], borrowed. +#[derive(Clone, Copy, Debug)] +enum SampleSlice<'a> { + Eight(&'a [u8]), + Sixteen(&'a [u16]), +} + +/// [`read_samples`], keeping 16-bit samples at 16 bits. +fn read_samples_at_depth(d: &mut tiff::decoder::Decoder>, n: usize, per: usize) -> Option { let mut buf = tiff::decoder::DecodingResult::U8(Vec::new()); let layout = d.read_image_to_buffer(&mut buf).ok()?; - let data = match buf { - tiff::decoder::DecodingResult::U8(v) => v, - tiff::decoder::DecodingResult::U16(v) => v.iter().map(|s| (s >> 8) as u8).collect(), - _ => return None, - }; + match buf { + tiff::decoder::DecodingResult::U8(v) => chunky(v, layout.planes, n, per).map(Samples::Eight), + tiff::decoder::DecodingResult::U16(v) => chunky(v, layout.planes, n, per).map(Samples::Sixteen), + _ => None, + } +} + +/// `data`, the samples of `n` pixels, `per` to a pixel, stored in `planes` planes, as the samples +/// pixel by pixel; `None` when it doesn't hold that many samples or a plane per sample. +fn chunky(data: Vec, planes: usize, n: usize, per: usize) -> Option> { if Some(data.len()) != n.checked_mul(per) { return None; } - if layout.planes <= 1 { + if planes <= 1 { return Some(data); } - if layout.planes != per { + if planes != per { return None; } - let planes: Vec<&[u8]> = data.chunks_exact(n).collect(); - Some((0..n).flat_map(|i| planes.iter().map(move |p| p.get(i).copied().unwrap_or(0))).collect()) + let planes: Vec<&[T]> = data.chunks_exact(n).collect(); + Some((0..n).flat_map(|i| planes.iter().filter_map(move |p| p.get(i).copied())).collect()) +} + +/// The color space an ICC profile's header names (bytes 16 to 19, such as `RGB `, `GRAY` or +/// `CMYK`); `None` when `icc` is shorter than a header. +pub fn icc_color_space(icc: &[u8]) -> Option<[u8; 4]> { + if icc.len() < 128 { + return None; + } + icc.get(16..20)?.try_into().ok() +} + +/// The ICC profile of TIFF tag 34675 (ICCProfile) of the image `d` reads; `None` without one. +fn tiff_icc(d: &mut tiff::decoder::Decoder>) -> Option> { + d.get_tag_u8_vec(tiff::tags::Tag::IccProfile).ok().filter(|icc| !icc.is_empty()) +} + +/// The ICC profile of TIFF `bytes`' first image (tag 34675), read with the `tiff` decoder's own +/// limits, whatever the size of the image; `None` for other images and TIFFs without one. +pub fn tiff_icc_profile(bytes: &[u8]) -> Option> { + let (_, mut d) = tiff(bytes)?; + tiff_icc(&mut d) +} + +/// The ICC profile of a JPEG, put together from all its numbered `ICC_PROFILE` APP2 segments; +/// `None` without one. +fn jpeg_icc(bytes: &[u8]) -> Option> { + let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(bytes), DecoderOptions::default()); + d.decode_headers().ok()?; + d.icc_profile().filter(|icc| !icc.is_empty()) +} + +/// `icc` when it is a profile of CMYK colors, which the samples of a CMYK image are in. +fn cmyk_icc(icc: Option>) -> Option> { + icc.filter(|icc| icc_color_space(icc) == Some(*b"CMYK")) +} + +/// The CMYK samples of a CMYK image at the depth its file stores them, and its ICC profile. +struct CmykSource { + width: u32, + height: u32, + samples: Samples, + icc: Option>, +} + +/// The samples of CMYK TIFF `bytes` (8 or 16 bits), their inks together or one plane per ink, with +/// the TIFF's CMYK ICC profile; `None` for other images and ones larger than [`MAX_CMYK_PIXELS`]. +fn tiff_source(bytes: &[u8]) -> Option { + let (kind, mut d) = tiff(bytes)?; + if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) { + return None; + } + let (width, height) = d.dimensions().ok()?; + let n = usize::try_from(pixels(width, height)?).ok()?; + let icc = cmyk_icc(tiff_icc(&mut d)); + let samples = read_samples_at_depth(&mut d, n, 4)?; + Some(CmykSource { width, height, samples, icc }) +} + +/// The ink amounts of a four-component JPEG, 8 bits each, with its CMYK ICC profile. +fn jpeg_source(bytes: &[u8]) -> Option { + let Inks { width, height, data } = jpeg_inks(bytes)?; + Some(CmykSource { width, height, samples: Samples::Eight(data), icc: cmyk_icc(jpeg_icc(bytes)) }) +} + +/// A Deflate-compressed CMYK TIFF of `width` × `height` pixels of `samples` (inks together, 8 or 16 +/// bits as they are given), declaring the resolution `ppi` when both of its values are finite and +/// above 0, and carrying `icc` as tag 34675 (ICCProfile, of type UNDEFINED); `None` when it can't be +/// written. +fn write_cmyk_tiff(width: u32, height: u32, samples: SampleSlice<'_>, ppi: Option<(f64, f64)>, icc: Option<&[u8]>) -> Option> { + use tiff::encoder::colortype::{CMYK8, CMYK16, ColorType}; + use tiff::encoder::{Compression, ImageEncoder, Rational, TiffEncoder, TiffKind, compression::DeflateLevel}; + use tiff::tags::{ResolutionUnit, Tag}; + fn tags( + img: &mut ImageEncoder<'_, W, C, K>, + ppi: Option<(f64, f64)>, + icc: Option<&[u8]>, + ) -> Option<()> { + if let Some((x, y)) = ppi.filter(|(x, y)| [x, y].iter().all(|v| v.is_finite() && **v > 0.0)) { + // Ten-thousandths of a pixel per inch. + let r = |v: f64| Rational { n: (v * 10_000.0).round() as u32, d: 10_000 }; + img.encoder().write_tag(Tag::XResolution, r(x)).ok()?; + img.encoder().write_tag(Tag::YResolution, r(y)).ok()?; + img.encoder().write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).ok()?; + } + if let Some(icc) = icc { + img.encoder().write_tag(Tag::IccProfile, Undefined(icc)).ok()?; + } + Some(()) + } + let mut out = Cursor::new(Vec::new()); + let mut enc = TiffEncoder::new(&mut out).ok()?.with_compression(Compression::Deflate(DeflateLevel::Balanced)); + match samples { + SampleSlice::Eight(data) => { + let mut img = enc.new_image::(width, height).ok()?; + tags(&mut img, ppi, icc)?; + img.write_data(data).ok()?; + } + SampleSlice::Sixteen(data) => { + let mut img = enc.new_image::(width, height).ok()?; + tags(&mut img, ppi, icc)?; + img.write_data(data).ok()?; + } + } + Some(out.into_inner()) +} + +/// Bytes written as a TIFF field of type UNDEFINED, the type of an embedded ICC profile. +struct Undefined<'a>(&'a [u8]); + +impl tiff::encoder::TiffValue for Undefined<'_> { + const BYTE_LEN: u8 = 1; + const FIELD_TYPE: tiff::tags::Type = tiff::tags::Type::UNDEFINED; + + fn count(&self) -> usize { + self.0.len() + } + + fn data(&self) -> std::borrow::Cow<'_, [u8]> { + std::borrow::Cow::Borrowed(self.0) + } } impl ImageBlob { @@ -180,20 +336,8 @@ impl ImageBlob { /// [`ImageBlob::cmyk_tiff`], declaring the resolution `ppi` (horizontal, vertical pixels per /// inch) when it is given and both are finite and above 0. pub fn cmyk_tiff_at(inks: &Inks, ppi: Option<(f64, f64)>) -> Option { - use tiff::encoder::{Compression, Rational, TiffEncoder, colortype::CMYK8, compression::DeflateLevel}; - use tiff::tags::{ResolutionUnit, Tag}; - let mut out = Cursor::new(Vec::new()); - let mut enc = TiffEncoder::new(&mut out).ok()?.with_compression(Compression::Deflate(DeflateLevel::Balanced)); - let mut img = enc.new_image::(inks.width, inks.height).ok()?; - if let Some((x, y)) = ppi.filter(|(x, y)| [x, y].iter().all(|v| v.is_finite() && **v > 0.0)) { - // Ten-thousandths of a pixel per inch. - let r = |v: f64| Rational { n: (v * 10_000.0).round() as u32, d: 10_000 }; - img.encoder().write_tag(Tag::XResolution, r(x)).ok()?; - img.encoder().write_tag(Tag::YResolution, r(y)).ok()?; - img.encoder().write_tag(Tag::ResolutionUnit, ResolutionUnit::Inch).ok()?; - } - img.write_data(&inks.data).ok()?; - Some(Self::new("image/tiff", out.into_inner())) + let tiff = write_cmyk_tiff(inks.width, inks.height, SampleSlice::Eight(&inks.data), ppi, None)?; + Some(Self::new("image/tiff", tiff)) } /// Does the image hold CMYK samples (a four-component JPEG or a CMYK TIFF)? Reads only its @@ -318,30 +462,38 @@ fn tiff_inks(bytes: &[u8]) -> Option { /// CMYK TIFF `bytes` that no orientation turns ([`turned_cmyk`] stores the others) as Open and /// Place store them: as they are, or, when they store one plane per ink, which the image decoder -/// doesn't read, their ink amounts written again by [`ImageBlob::cmyk_tiff_at`] with the resolution -/// `ppi`. `None` for other images and for CMYK TIFFs whose inks [`ImageBlob::cmyk`] doesn't read. +/// doesn't read, their inks together in a CMYK TIFF with the resolution `ppi`, at the 8 or 16 bits +/// the file has, carrying the file's CMYK ICC profile. `None` for other images and for CMYK TIFFs +/// whose inks [`ImageBlob::cmyk`] doesn't read. pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option> { - let inks = tiff_inks(bytes)?; + let source = tiff_source(bytes)?; let (_, mut d) = tiff(bytes)?; if !planar(&mut d) { return Some(bytes.to_vec()); } - ImageBlob::cmyk_tiff_at(&inks, ppi).map(|b| Arc::unwrap_or_clone(b.bytes)) + write_cmyk_tiff(source.width, source.height, source.samples.as_slice(), ppi, source.icc.as_deref()) } /// CMYK JPEG or TIFF `bytes` that `orientation` turns or flips, as Open and Place store them: their -/// ink amounts ([`ImageBlob::cmyk`], 8 bits each) turned and written again by -/// [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi` of the turned pixels, with the turned -/// width and height. `None` for other images and for CMYK images whose inks [`ImageBlob::cmyk`] -/// doesn't read. +/// inks turned and written again as a CMYK TIFF with the resolution `ppi` of the turned pixels, +/// with the turned width and height. A TIFF keeps its 8 or 16 bits, a JPEG has 8, and both keep +/// their CMYK ICC profile (a JPEG's put together from all its APP2 segments). `None` for other +/// images and for CMYK images whose inks [`ImageBlob::cmyk`] doesn't read. pub fn turned_cmyk(bytes: &[u8], orientation: image::metadata::Orientation, ppi: Option<(f64, f64)>) -> Option<(Vec, (u32, u32))> { - let inks = if bytes.starts_with(&[0xFF, 0xD8]) { jpeg_inks(bytes) } else { tiff_inks(bytes) }?; - // Four 8-bit inks to a pixel, turned as four 8-bit channels are. - let mut px = image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(inks.width, inks.height, inks.data)?); + let CmykSource { width, height, samples, icc } = if bytes.starts_with(&[0xFF, 0xD8]) { jpeg_source(bytes) } else { tiff_source(bytes) }?; + // Four inks to a pixel, turned as four channels of the same depth are. + let mut px = match samples { + Samples::Eight(v) => image::DynamicImage::ImageRgba8(image::RgbaImage::from_raw(width, height, v)?), + Samples::Sixteen(v) => image::DynamicImage::ImageRgba16(image::ImageBuffer::from_raw(width, height, v)?), + }; px.apply_orientation(orientation); - let inks = Inks::new(px.width(), px.height(), px.into_bytes())?; - let tiff = ImageBlob::cmyk_tiff_at(&inks, ppi)?; - Some((Arc::unwrap_or_clone(tiff.bytes), (inks.width, inks.height))) + let size = (px.width(), px.height()); + let samples = match px { + image::DynamicImage::ImageRgba16(v) => Samples::Sixteen(v.into_raw()), + other => Samples::Eight(other.into_rgba8().into_raw()), + }; + let tiff = write_cmyk_tiff(size.0, size.1, samples.as_slice(), ppi, icc.as_deref())?; + Some((tiff, size)) } /// A TIFF whose pixels have samples after their colour, which the image decoder doesn't read at diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index e9f6ecf3d4..ba27e70d14 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -870,7 +870,10 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option { /// read with [`jxlread`] and stored as PNG, with the ICC profile their colors are left in. /// A PNG, JPEG, GIF, WebP or TIFF whose orientation ([`exif_orientation`]) turns or flips it is /// stored as it is shown, with its resolution's axes swapped when it turns a quarter; a CMYK JPEG -/// or TIFF keeps its inks, 8 bits each, as a CMYK TIFF ([`vectorcraft_doc::cmyk::turned_cmyk`]). +/// or TIFF keeps its inks, at the 8 or 16 bits the file has, and its CMYK ICC profile as a CMYK +/// TIFF ([`vectorcraft_doc::cmyk::turned_cmyk`]). An image the decoder reads and that is stored as +/// PNG keeps its gray or RGB samples at 8 or 16 bits, and the gray or RGB ICC profile they are in; +/// other samples become 8-bit RGBA. pub fn raster_image(bytes: &[u8]) -> Result { let ppi = super::ppi::resolution(bytes); if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) { @@ -933,28 +936,52 @@ pub fn raster_image(bytes: &[u8]) -> Result { let cms = vectorcraft_color::cms::active(); vectorcraft_doc::cmyk::extra_samples_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false)) }; + // The ICC profile of the samples the image decoder reads; the other readers give sRGB pixels. + let mut icc = None; let mut img = match (f.id == "tiff").then(extra_samples).flatten() { Some(img) => image::DynamicImage::ImageRgba8(img), None => match (f.id == "tiff").then(|| vectorcraft_doc::cmyk::group3_tiff_rgba(bytes)).flatten() { Some(read) => image::DynamicImage::ImageRgba8(read.map_err(err)?), None => { use image::ImageDecoder as _; - let decoder = reader.into_decoder().map_err(err)?; + let mut decoder = reader.into_decoder().map_err(err)?; // The allowance counts the decoded samples and the RGBA pixels they become (#1717). let (w, h) = decoder.dimensions(); if decoder.total_bytes() > MAX_RASTER_ALLOC || u64::from(w) * u64::from(h) * 4 > MAX_RASTER_ALLOC { return Err(err(format!("an image of {w} × {h} pixels is too large to draw"))); } + // A profile the decoder can't read leaves the image without one. The image decoder + // reads a TIFF's tags within the size of its pixels, so a small TIFF's profile is + // read by the TIFF decoder itself. + icc = match f.id { + "tiff" => vectorcraft_doc::cmyk::tiff_icc_profile(bytes), + _ => decoder.icc_profile().ok().flatten(), + }; image::DynamicImage::from_decoder(decoder).map_err(err)? } }, }; img.apply_orientation(orientation.unwrap_or(image::metadata::Orientation::NoTransforms)); - let mut img = img.into_rgba8(); if f.id == "tga" { + let mut img = img.into_rgba8(); super::tgaread::fix_alpha(bytes, &mut img); + return as_png(img, None, shown_ppi); } - as_png(img, None, shown_ppi) + use image::DynamicImage as D; + let img = match img { + D::ImageLuma8(_) | D::ImageLumaA8(_) | D::ImageRgb8(_) | D::ImageRgba8(_) => img, + D::ImageLuma16(_) | D::ImageLumaA16(_) | D::ImageRgb16(_) | D::ImageRgba16(_) => img, + other => D::ImageRgba8(other.into_rgba8()), + }; + let icc = icc.filter(|icc| png_takes_icc(&img, icc)); + as_png(img, icc, shown_ppi) +} + +/// Can a PNG of `img`'s samples carry ICC profile `icc`? Its color space must be the samples': +/// `GRAY` for gray samples, `RGB ` for RGB ones. +fn png_takes_icc(img: &image::DynamicImage, icc: &[u8]) -> bool { + let want = if img.color().has_color() { *b"RGB " } else { *b"GRAY" }; + vectorcraft_doc::cmyk::icc_color_space(icc) == Some(want) } /// The EXIF orientation of encoded image `bytes` when it turns or flips the pixels; `None` when @@ -970,10 +997,11 @@ pub(crate) fn exif_orientation(bytes: &[u8]) -> Option>, ppi: Option<(f64, f64)>) -> Result { +/// colors are in. `img` has samples a PNG holds: 8 or 16 bits of gray or RGB, with or without alpha. +pub(super) fn as_png(img: impl Into, icc: Option>, ppi: Option<(f64, f64)>) -> Result { use image::ImageEncoder as _; - let size = img.dimensions(); + let img: image::DynamicImage = img.into(); + let size = (img.width(), img.height()); let mut png = Vec::new(); let mut encoder = image::codecs::png::PngEncoder::new(&mut png); if let Some(icc) = icc { diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 18bb4853b1..71c1bd06e5 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -1171,6 +1171,216 @@ fn png_webp_and_rgb_tiff_turned_a_quarter_swap_their_resolution() { } } +/// A made-up ICC profile of `len` bytes (at least a header's 128) whose header names the color +/// space `space`; the bytes after the header differ from place to place. +fn icc_profile(space: &[u8; 4], len: usize) -> Vec { + let mut icc: Vec = (0..len).map(|i| (i % 251) as u8).collect(); + icc[..4].copy_from_slice(&u32::try_from(len).unwrap().to_be_bytes()); + icc[12..16].copy_from_slice(b"mntr"); + icc[16..20].copy_from_slice(space); + icc[36..40].copy_from_slice(b"acsp"); + icc +} + +/// `tiff` (little-endian) with an ICCProfile tag (34675, of type UNDEFINED) holding `icc`: its first +/// directory copied to the end of the file with the tag added in order, as [`tiff_oriented`] adds +/// its tag. +fn tiff_with_icc(tiff: &[u8], icc: &[u8]) -> Vec { + let at = usize::try_from(u32::from_le_bytes(tiff[4..8].try_into().unwrap())).unwrap(); + let n = usize::from(u16::from_le_bytes([tiff[at], tiff[at + 1]])); + let mut entries: Vec> = tiff[at + 2..at + 2 + 12 * n].chunks(12).map(<[u8]>::to_vec).collect(); + let mut out = tiff.to_vec(); + if out.len() % 2 == 1 { + out.push(0); + } + let data = u32::try_from(out.len()).unwrap(); + out.extend(icc); + if out.len() % 2 == 1 { + out.push(0); + } + let i = entries.iter().position(|e| u16::from_le_bytes([e[0], e[1]]) > 34675).unwrap_or(n); + let count = u32::try_from(icc.len()).unwrap(); + entries.insert(i, [&34675u16.to_le_bytes()[..], &7u16.to_le_bytes(), &count.to_le_bytes(), &data.to_le_bytes()].concat()); + let ifd = u32::try_from(out.len()).unwrap(); + out[4..8].copy_from_slice(&ifd.to_le_bytes()); + out.extend(u16::try_from(n + 1).unwrap().to_le_bytes()); + out.extend(entries.concat()); + out.extend([0; 4]); + out +} + +/// The ICC profile and pixels of a stored image, read by the image decoder for its format. +fn stored_pixels(what: &str, img: &RasterImage) -> (Option>, image::ExtendedColorType, image::DynamicImage) { + use image::ImageDecoder as _; + let bytes = Cursor::new(img.blob.bytes.as_slice()); + let mut d: Box = match img.blob.mime.as_str() { + "image/png" => Box::new(image::codecs::png::PngDecoder::new(bytes).unwrap()), + "image/tiff" => Box::new(image::codecs::tiff::TiffDecoder::new(bytes).unwrap()), + other => panic!("{what}: stored as {other}"), + }; + let icc = d.icc_profile().unwrap(); + let color = d.original_color_type(); + (icc, color, image::DynamicImage::from_decoder(d).unwrap()) +} + +/// An image turned by its orientation and stored as PNG keeps the gray or RGB ICC profile its file +/// carries, and its 16-bit samples at 16 bits, from a PNG, a JPEG, a WebP and a TIFF; a TIFF the +/// image decoder reads, stored as PNG without a turn, keeps them too. A profile of another color +/// space than the samples' is left out. +#[test] +fn images_stored_as_png_keep_their_icc_profile_and_16_bit_samples() { + use image::{DynamicImage, ImageEncoder as _}; + let (rgb_icc, gray_icc, cmyk_icc) = (icc_profile(b"RGB ", 600), icc_profile(b"GRAY", 300), icc_profile(b"CMYK", 400)); + // 3 × 2, every sample's low byte different. + let rgb16 = image::ImageBuffer::from_fn(3, 2, |x, y| { + let v = u16::try_from(x * 0x1111 + y * 0x2345).unwrap(); + image::Rgb([0x1000 + v, 0x8001 + v, 0xF0F3 - v]) + }); + let rgb16 = DynamicImage::ImageRgb16(rgb16); + let gray16 = DynamicImage::ImageLuma16(image::ImageBuffer::from_fn(3, 2, |x, y| image::Luma([u16::try_from(x * 0x3001 + y * 0x0107).unwrap()]))); + let rgb8 = DynamicImage::ImageRgb8(image::RgbImage::from_fn(3, 2, |x, y| image::Rgb([u8::try_from(x * 70 + y * 9).unwrap(), 200, 13]))); + let png = |img: &DynamicImage, icc: &[u8], orientation: u16| { + let mut out = Vec::new(); + let mut enc = image::codecs::png::PngEncoder::new(&mut out); + enc.set_icc_profile(icc.to_vec()).unwrap(); + enc.set_exif_metadata(exif_block(orientation, (150, 150))).unwrap(); + img.write_with_encoder(enc).unwrap(); + out + }; + let webp = { + let mut out = Vec::new(); + let mut enc = image::codecs::webp::WebPEncoder::new_lossless(&mut out); + enc.set_icc_profile(rgb_icc.clone()).unwrap(); + enc.set_exif_metadata(exif_block(6, (150, 150))).unwrap(); + rgb8.write_with_encoder(enc).unwrap(); + out + }; + let tiff = |orientation: Option| { + let mut out = Vec::new(); + let mut enc = image::codecs::tiff::TiffEncoder::new(Cursor::new(&mut out)); + enc.set_icc_profile(rgb_icc.clone()).unwrap(); + rgb16.write_with_encoder(enc).unwrap(); + match orientation { + Some(o) => tiff_oriented(&out, o, false, None), + None => out, + } + }; + // (what, file, the ICC profile to keep, the pixels as they show, 16 bits) + type Case<'a> = (&'a str, Vec, Option<&'a [u8]>, DynamicImage, bool); + let cases: Vec = vec![ + ("16-bit RGB PNG, orientation 6", png(&rgb16, &rgb_icc, 6), Some(&rgb_icc), rgb16.rotate90(), true), + ("16-bit gray PNG, orientation 8", png(&gray16, &gray_icc, 8), Some(&gray_icc), gray16.rotate270(), true), + ("8-bit RGB PNG, orientation 3", png(&rgb8, &rgb_icc, 3), Some(&rgb_icc), rgb8.rotate180(), false), + ("RGB PNG with a CMYK profile, orientation 6", png(&rgb8, &cmyk_icc, 6), None, rgb8.rotate90(), false), + ("WebP, orientation 6", webp, Some(&rgb_icc), rgb8.rotate90(), false), + ("16-bit RGB TIFF, not turned", tiff(None), Some(&rgb_icc), rgb16.clone(), true), + ("16-bit RGB TIFF, orientation 6", tiff(Some(6)), Some(&rgb_icc), rgb16.rotate90(), true), + ]; + for (what, file, icc, want, wide) in cases { + let img = raster_image(&file).unwrap_or_else(|e| panic!("{what}: {e}")); + assert_eq!((img.width, img.height, img.blob.mime.as_str()), (want.width(), want.height(), "image/png"), "{what}"); + let (kept, color, px) = stored_pixels(what, &img); + assert_eq!(kept.as_deref(), icc, "{what}: the ICC profile"); + assert_eq!(color.bits_per_pixel() / u16::from(color.channel_count()), if wide { 16 } else { 8 }, "{what}: the sample depth"); + if wide { + assert_eq!(px.to_rgb16(), want.to_rgb16(), "{what}: the samples"); + } else { + assert_eq!(px.to_rgb8(), want.to_rgb8(), "{what}: the samples"); + } + } + // A JPEG's samples change in encoding; its profile is kept. + let mut jpg = Vec::new(); + let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpg, 95); + enc.set_icc_profile(rgb_icc.clone()).unwrap(); + rgb8.write_with_encoder(enc).unwrap(); + let exif = [b"Exif\0\0".as_slice(), &exif_block(6, (150, 150))].concat(); + let mut turned = jpg[..2].to_vec(); + turned.extend([0xff, 0xe1]); + turned.extend(u16::try_from(exif.len() + 2).unwrap().to_be_bytes()); + turned.extend(&exif); + turned.extend(&jpg[2..]); + let img = raster_image(&turned).unwrap(); + assert_eq!((img.width, img.height, img.blob.mime.as_str()), (2, 3, "image/png"), "JPEG"); + assert_eq!(stored_pixels("JPEG", &img).0, Some(rgb_icc.clone()), "JPEG: the ICC profile"); + // Not turned, a PNG keeps its own bytes. + let plain = png(&rgb16, &rgb_icc, 1); + assert_eq!(raster_image(&plain).unwrap().blob.bytes.as_slice(), plain.as_slice(), "a PNG as taken keeps its bytes"); +} + +/// A CMYK TIFF stored again, because it stores one plane per ink or because its orientation turns +/// it, keeps its ICC profile as tag 34675 and its 8 or 16 bits a sample, exactly; one that stores +/// its inks together and isn't turned keeps its own bytes. +#[test] +fn cmyk_tiffs_written_again_keep_their_icc_profile_and_depth() { + use image::DynamicImage; + let icc = icc_profile(b"CMYK", 900); + // 3 × 2, no black, every sample's low byte different. + let ink = |c: usize, p: usize| -> u16 { u16::try_from(0x0101 * (c * 5 + p + 1) + 0x10 * p + c).unwrap() }; + for bits in [8u16, 16] { + let sample = |c: usize, p: usize| match (c, bits) { + (3, _) => 0, + (_, 16) => ink(c, p), + _ => ink(c, p) & 0xFF, + }; + let channels: Vec> = (0..4).map(|c| (0..6).map(|p| sample(c, p)).collect()).collect(); + // The image decoder shows a CMYK pixel without black as 1 - C, 1 - M, 1 - Y, exactly. + let full = if bits == 16 { 0xFFFF } else { 0xFF }; + let shown: Vec = (0..6).flat_map(|p| (0..3).map(|c| full - channels[c][p]).collect::>()).collect(); + let shown = if bits == 16 { + DynamicImage::ImageRgb16(image::ImageBuffer::from_raw(3, 2, shown).unwrap()) + } else { + DynamicImage::ImageRgb8(image::RgbImage::from_raw(3, 2, shown.iter().map(|v| u8::try_from(*v).unwrap()).collect()).unwrap()) + }; + for planar in [false, true] { + let file = tiff_with_icc(&hand_tiff(5, bits, &[], planar, &channels), &icc); + for (orientation, want) in [(None, shown.clone()), (Some(6), shown.rotate90()), (Some(3), shown.rotate180())] { + let what = format!("{bits} bits, planar {planar}, orientation {orientation:?}"); + let file = orientation.map_or_else(|| file.clone(), |o| tiff_oriented(&file, o, false, Some(150))); + let img = raster_image(&file).unwrap_or_else(|e| panic!("{what}: {e}")); + if orientation.is_none() && !planar { + assert_eq!(img.blob.bytes.as_slice(), file.as_slice(), "{what}: kept as it is"); + } + let (kept, color, px) = stored_pixels(&what, &img); + assert_eq!(kept.as_deref(), Some(icc.as_slice()), "{what}: the ICC profile"); + let depth = if bits == 16 { image::ExtendedColorType::Cmyk16 } else { image::ExtendedColorType::Cmyk8 }; + assert_eq!(color, depth, "{what}: the sample depth"); + assert_eq!(px, want, "{what}: the samples"); + } + } + } +} + +/// A CMYK JPEG turned by its orientation and stored as a CMYK TIFF keeps its whole ICC profile, +/// put together from the two APP2 segments a profile larger than one segment is split into. +#[test] +fn a_turned_cmyk_jpeg_keeps_its_icc_profile_from_every_segment() { + use vectorcraft_render::encode::jpeg::{self, ColorModel, JpegOptions}; + let icc = icc_profile(b"CMYK", 70_000); + let inks: Vec = (0..16u32 * 8).flat_map(|i| [u8::try_from(i).unwrap(), 0, 0, 40]).collect(); + let o = JpegOptions { color_model: ColorModel::Cmyk, embed_icc: false, ..Default::default() }; + let jpg = jpeg::encode(&inks, 16, 8, 100, None, &o).unwrap(); + let exif = [b"Exif\0\0".as_slice(), &exif_block(6, (300, 300))].concat(); + let mut file = jpg[..2].to_vec(); + file.extend([0xff, 0xe1]); + file.extend(u16::try_from(exif.len() + 2).unwrap().to_be_bytes()); + file.extend(&exif); + let chunks: Vec<&[u8]> = icc.chunks(65_519).collect(); + assert_eq!(chunks.len(), 2, "the profile takes two segments"); + for (i, chunk) in chunks.iter().enumerate() { + file.extend([0xff, 0xe2]); + file.extend(u16::try_from(chunk.len() + 16).unwrap().to_be_bytes()); + file.extend(b"ICC_PROFILE\0"); + file.extend([u8::try_from(i + 1).unwrap(), 2]); + file.extend(*chunk); + } + file.extend(&jpg[2..]); + let img = raster_image(&file).unwrap(); + assert_eq!((img.width, img.height, img.blob.mime.as_str()), (8, 16, "image/tiff")); + let (kept, color, _) = stored_pixels("CMYK JPEG", &img); + assert_eq!(color, image::ExtendedColorType::Cmyk8); + assert!(kept.as_deref() == Some(icc.as_slice()), "the whole ICC profile: {:?} bytes", kept.map(|k| k.len())); +} + /// With `importer` named, a file up to the importer's limit is read whole and a larger one is an /// error, whatever its content. #[test] diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f04ee3c910..a5036cd320 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1639,7 +1639,8 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - On the desktop, File › Place reads and decodes files off the UI thread, one at a time, and places each once it is read, with "Placing" in the status bar and the busy spinner until then: the Place dialog's OK, a file dropped on the canvas, a click or drag of the place cursor, and the OK of the PDF, DXF and text import options. The files and thumbnails the place cursor is loaded with are read the same way, and the Place dialog's lines about its files are read on a thread of their own. Files are placed in the order they were started; a file is not placed while a dialog is open or a drag is under way, and a file whose document was closed meanwhile is not placed. `file.place` and `file.place.queue` over MCP and scripts reply as before. - On the desktop, Paste from a menu or shortcut captures another app's bitmap or reads a copied image file on the UI thread, then decodes the image on the file-reading worker. The status bar shows "Pasting" until the image is pasted. Windows device-independent bitmaps and Linux clipboard images are encoded as PNG on that worker. A Copy or Cut made while the image is read keeps its internal clipboard. `edit.paste*` over MCP and scripts reads the bitmap at once. - The pixels of a PNG, JPEG, GIF or WebP file that Place decodes to check them are kept in the image store the renderers share, so the canvas's first frame with the placed image draws them without decoding the file again on the UI thread: File › Place, a file dropped on the canvas, a click or drag of the place cursor, `file.place`, and Paste of such an image. A CMYK JPEG is still decoded when it is first drawn (`crates/ui-egui/src/placing.rs`, `crates/render/src/tests.rs`). - - A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284). A quarter turn swaps the resolution's axes, so the image keeps its physical size. TIFFs read without the image decoder (grayscale with extra channels, CCITT Group 3, CMYK with one plane per ink) follow their Orientation tag too, and a turned CMYK JPEG or TIFF keeps its inks, 8 bits each, as a CMYK TIFF. + - A photo whose EXIF orientation turns or flips it (a camera held upright or upside down) is placed and opened upright, as it was taken; the image is stored as it is seen (#4284). A quarter turn swaps the resolution's axes, so the image keeps its physical size. TIFFs read without the image decoder (grayscale with extra channels, CCITT Group 3, CMYK with one plane per ink) follow their Orientation tag too, and a turned CMYK JPEG or TIFF keeps its inks, at the 8 or 16 bits the file has, as a CMYK TIFF. + - An image that Open and Place store again keeps the ICC profile its file embeds and its 16-bit samples. A turned PNG, JPEG, WebP or TIFF stored as PNG, and a TIFF the image decoder reads, keep their gray or RGB samples at 8 or 16 bits with their gray or RGB profile in the PNG's `iCCP` chunk ([PNG specification, iCCP](https://www.w3.org/TR/png-3/#11iCCP)). A CMYK TIFF stored with one plane per ink and a turned CMYK JPEG or TIFF keep their CMYK profile as TIFF tag 34675, a JPEG's put together from all its APP2 segments ([ICC image-format guide](https://github.com/InternationalColorConsortium/iccDEV/blob/master/iccdev-mcp/docs/icc-format-guide.md)). A profile whose color space differs from the stored samples' is left out. The profile is kept in the stored image; how CMYK inks are drawn and exported to PDF does not change (`crates/engine/src/cmd/fileio/tests.rs`). - File › Place and a file dropped on the canvas refuse a PNG, JPEG, GIF or WebP file whose pixels can't be read, such as one cut short by a download or a sync still running, and the status bar shows "Couldn't place : the image's pixels can't be read". Paste and Relink refuse such an image too, and Update Links, or Open when it reads the file again, counts it as missing. A JPEG cut short still places when its pixels can be decoded (`file.place`, `crates/engine/src/tests_place.rs`). - On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them. - On the desktop with Enable Background Export on (Preferences › File Handling), File › Package… collects the linked files and fonts and writes the package from the document as it was at OK on the thread that writes background saves and exports, with "Packaging" in the status bar and the busy spinner until it is written; a save of unsaved changes that Package starts is written first. `file.package` over MCP and scripts replies as before.