From 28dfd66b65b30240b14e8f6257c3094d9d9a6eda Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Wed, 11 Nov 2026 22:51:42 -0800 Subject: [PATCH 1/2] Images: a quarter turn by the orientation swaps the resolution, and every TIFF follows the tag Opening or placing an image whose orientation turns it a quarter (EXIF or TIFF Orientation 5 to 8) stored the pixels turned but kept the resolution as stored, so a photo at 300 x 150 ppi came in at the wrong width and height. XResolution counts pixels in the ImageWidth direction of the stored image (TIFF 6.0, https://www.itu.int/itudoc/itu-t/com16/tiff-fx/docs/tiff6.pdf; EXIF uses the same tag, CIPA DC-008-2012, https://www.cipa.jp/std/documents/e/DC-008-2012_E.pdf), so a quarter turn now swaps the axes, in the size Open and Place use and in the stored image, with jxlread::oriented_resolution as JPEG XL does. Three kinds of TIFF did not follow their Orientation tag: - grayscale TIFFs with extra samples and 1-bit CCITT Group 3 TIFFs were not turned; - a CMYK TIFF with its inks together was turned but stored as RGB, losing its inks, and one with a plane per ink did not open ("Planar TIFF with CMYK color type is not supported"). They now turn as the other images do, and a CMYK TIFF keeps its inks, 8 bits each, written again with the turned resolution. --- crates/doc/src/cmyk.rs | 9 +- crates/engine/src/cmd/fileio/load.rs | 52 +++++-- crates/engine/src/cmd/fileio/tests.rs | 207 ++++++++++++++++++++++++++ 3 files changed, 255 insertions(+), 13 deletions(-) diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index e487226d4f..a3a6f7b016 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -133,6 +133,13 @@ pub(crate) fn tiff(bytes: &[u8]) -> Option<(tiff::ColorType, tiff::decoder::Deco Some((d.colortype().ok()?, d)) } +/// The Orientation tag of TIFF `bytes`' first image, of any unsigned type; `None` for other images +/// and TIFFs without one. Reads only its header. +pub fn tiff_orientation(bytes: &[u8]) -> Option { + let (_, mut d) = tiff(bytes)?; + d.find_tag_unsigned::(tiff::tags::Tag::Orientation).ok().flatten() +} + /// Does the TIFF `d` reads store one plane per channel (PlanarConfiguration 2)? fn planar(d: &mut tiff::decoder::Decoder>) -> bool { d.find_tag_unsigned::(tiff::tags::Tag::PlanarConfiguration).ok().flatten().is_some_and(|p| p != 1) @@ -299,7 +306,7 @@ fn jpeg_inks(bytes: &[u8]) -> Option { /// The ink amounts of a CMYK TIFF (16-bit samples to 8 bits), its inks stored together or one plane /// per ink. -fn tiff_inks(bytes: &[u8]) -> Option { +pub fn tiff_inks(bytes: &[u8]) -> Option { let (kind, mut d) = tiff(bytes)?; let (w, h) = d.dimensions().ok()?; if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) { diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index fde1ed273f..7df402724a 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -814,6 +814,9 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option { /// 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, 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 TIFF +/// keeps its inks, 8 bits each ([`turned_cmyk_tiff`]). pub fn raster_image(bytes: &[u8]) -> Result { let ppi = super::ppi::resolution(bytes); if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) { @@ -852,7 +855,15 @@ pub fn raster_image(bytes: &[u8]) -> Result { let f = image_format(kind).ok_or_else(|| err(format!("{kind:?} images can't be opened (see document.formats)")))?; // A photo whose EXIF orientation turns or flips it (a camera held upright) is stored as it is // seen, so it places the way it was taken (#4284); the others keep their own bytes. - let turned = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff") && exif_orientation(bytes).is_some(); + let orientation = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff").then(|| exif_orientation(bytes)).flatten(); + let turned = orientation.is_some(); + // The resolution describes the stored pixels; a quarter turn swaps its axes. + let shown_ppi = jxlread::oriented_resolution(ppi, orientation.map_or(1, |o| u32::from(o.to_exif()))); + if let Some(orientation) = orientation.filter(|_| f.id == "tiff") + && let Some(stored) = turned_cmyk_tiff(bytes, orientation, shown_ppi) + { + return stored; + } let kept = match f.id { _ if turned => None, "png" | "jpg" | "gif" | "webp" => Some(bytes.to_vec()), @@ -868,36 +879,53 @@ pub fn raster_image(bytes: &[u8]) -> Result { vectorcraft_doc::cmyk::extra_samples_tiff_rgba(bytes, |c| cms.cmyk_to_srgb(c, false)) }; let mut img = match (f.id == "tiff").then(extra_samples).flatten() { - Some(img) => img, + Some(img) => image::DynamicImage::ImageRgba8(img), None => match (f.id == "tiff").then(|| vectorcraft_doc::cmyk::group3_tiff_rgba(bytes)).flatten() { - Some(read) => read.map_err(err)?, + Some(read) => image::DynamicImage::ImageRgba8(read.map_err(err)?), None => { use image::ImageDecoder as _; - let mut decoder = reader.into_decoder().map_err(err)?; + let 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"))); } - let orientation = decoder.orientation().unwrap_or(image::metadata::Orientation::NoTransforms); - let mut img = image::DynamicImage::from_decoder(decoder).map_err(err)?; - img.apply_orientation(orientation); - img.to_rgba8() + 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" { super::tgaread::fix_alpha(bytes, &mut img); } - as_png(img, None, ppi) + as_png(img, None, shown_ppi) } /// The EXIF orientation of encoded image `bytes` when it turns or flips the pixels; `None` when -/// it leaves them as stored, or the image has none. +/// it leaves them as stored, or the image has none. A TIFF the image decoder can't open (one with +/// extra samples) has its Orientation tag read from its first image +/// ([`vectorcraft_doc::cmyk::tiff_orientation`]). pub(crate) fn exif_orientation(bytes: &[u8]) -> Option { use image::ImageDecoder as _; - let mut decoder = image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?; - decoder.orientation().ok().filter(|o| *o != image::metadata::Orientation::NoTransforms) + use image::metadata::Orientation; + let decoded = || image::ImageReader::new(Cursor::new(bytes)).with_guessed_format().ok()?.into_decoder().ok()?.orientation().ok(); + let tagged = || vectorcraft_doc::cmyk::tiff_orientation(bytes).and_then(|v| Orientation::from_exif(u8::try_from(v).ok()?)); + decoded().or_else(tagged).filter(|o| *o != Orientation::NoTransforms) +} + +/// CMYK TIFF `bytes` turned or flipped by their `orientation`, as [`raster_image`] stores them: +/// their ink amounts ([`vectorcraft_doc::cmyk::tiff_inks`], 8 bits each) turned and written again +/// by [`ImageBlob::cmyk_tiff_at`] with the resolution `ppi` of the turned pixels. `None` for other +/// images and for CMYK TIFFs whose inks it doesn't read. +fn turned_cmyk_tiff(bytes: &[u8], orientation: image::metadata::Orientation, ppi: Option<(f64, f64)>) -> Option> { + let inks = vectorcraft_doc::cmyk::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)?); + px.apply_orientation(orientation); + let inks = vectorcraft_doc::cmyk::Inks::new(px.width(), px.height(), px.into_bytes())?; + let tiff = ImageBlob::cmyk_tiff_at(&inks, ppi)?; + Some(stored(std::sync::Arc::unwrap_or_clone(tiff.bytes), "image/tiff", (inks.width, inks.height), ppi)) } /// `img` stored as a PNG that keeps the file's resolution and carries `icc`, the ICC profile its diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index c90e077eb4..5f508bd6ec 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -907,6 +907,213 @@ fn a_photo_places_the_way_its_exif_orientation_says() { assert!(red(px.get_pixel(4, 3)) && !red(px.get_pixel(4, 12)), "the left half is on top"); } +/// Where the first stored pixel shows once EXIF (or TIFF) `orientation` turns the image, as +/// (right, bottom): orientations 1 and 5 keep it at the top left, 2 and 6 put it at the top right, +/// 3 and 7 at the bottom right, 4 and 8 at the bottom left. Values outside 1 to 8 leave it. +fn first_pixel_at(orientation: u16) -> (bool, bool) { + match orientation { + 2 | 6 => (true, false), + 3 | 7 => (true, true), + 4 | 8 => (false, true), + _ => (false, false), + } +} + +/// A little-endian EXIF block (a TIFF header and directory, as a PNG `eXIf` chunk or a WebP +/// `EXIF` chunk holds it) with an Orientation of `orientation` and an XResolution and YResolution +/// of `x` and `y` pixels per inch. +fn exif_block(orientation: u16, (x, y): (u32, u32)) -> Vec { + let entry = |tag: u16, kind: u16, value: u32| [&tag.to_le_bytes()[..], &kind.to_le_bytes(), &1u32.to_le_bytes(), &value.to_le_bytes()].concat(); + // Orientation, XResolution, YResolution, ResolutionUnit inches; the two rationals follow. + [ + b"II".as_slice(), + &[0x2a, 0, 8, 0, 0, 0], + &4u16.to_le_bytes(), + &entry(0x0112, 3, u32::from(orientation)), + &entry(0x011a, 5, 62), + &entry(0x011b, 5, 70), + &entry(0x0128, 3, 2), + &[0, 0, 0, 0], + &[x.to_le_bytes(), 1u32.to_le_bytes(), y.to_le_bytes(), 1u32.to_le_bytes()].concat(), + ] + .concat() +} + +/// A photo turned a quarter by its EXIF orientation keeps its physical size: the resolution's +/// axes swap with the pixels', in what Place reports and in the stored image. Every orientation +/// puts the first stored pixel in its corner once, and the stored image, read again, is not turned +/// a second time. Values outside 1 to 8 leave the photo as stored. +#[test] +fn a_quarter_turn_swaps_the_resolution_axes() { + // 48 × 32: the top-left 16 × 16 (one whole JPEG MCU) red, the rest white. + let rgb = image::RgbImage::from_fn(48, 32, |x, y| if x < 16 && y < 16 { image::Rgb([220, 0, 0]) } else { image::Rgb([255, 255, 255]) }); + let mut jpg = Vec::new(); + image::DynamicImage::ImageRgb8(rgb).write_to(&mut Cursor::new(&mut jpg), image::ImageFormat::Jpeg).unwrap(); + for orientation in [0u16, 1, 2, 3, 4, 5, 6, 7, 8, 9] { + let exif = [b"Exif\0\0".as_slice(), &exif_block(orientation, (300, 150))].concat(); + // Without a JFIF density the resolution comes from the EXIF block. + 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 what = format!("orientation {orientation}"); + assert_eq!(super::ppi::resolution(&turned), Some((300.0, 150.0)), "{what}: as stored"); + let quarter = (5..=8).contains(&orientation); + let (size, ppi) = if quarter { ((32, 48), (150.0, 300.0)) } else { ((48, 32), (300.0, 150.0)) }; + let img = raster_image(&turned).unwrap(); + assert_eq!(((img.width, img.height), img.ppi), (size, Some(ppi)), "{what}"); + assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image"); + let px = image::load_from_memory(&img.blob.bytes).unwrap().to_rgb8(); + let red = |x: u32, y: u32| px.get_pixel(x, y)[1] < 80; + let (w, h) = size; + let corners = [(false, false), (true, false), (true, true), (false, true)]; + let reds: Vec<_> = + corners.into_iter().filter(|&(right, bottom)| red(if right { w - 4 } else { 4 }, if bottom { h - 4 } else { 4 })).collect(); + assert_eq!(reds, [first_pixel_at(orientation)], "{what}: the first stored pixel's corner"); + let again = raster_image(&img.blob.bytes).unwrap(); + assert_eq!(((again.width, again.height), again.ppi), (size, Some(ppi)), "{what}: read again, it isn't turned again"); + } +} + +/// `tiff` (little-endian) with an Orientation tag (274) of `value`, a SHORT or, when `long`, a +/// LONG, and, when `y_ppi` is given, a YResolution (283, which `tiff` must have) of that many +/// pixels per inch: its first directory copied to the end of the file with the tag added in order, +/// and the header pointing there, so that every other offset in the file still holds. +fn tiff_oriented(tiff: &[u8], value: u16, long: bool, y_ppi: Option) -> 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 i = entries.iter().position(|e| u16::from_le_bytes([e[0], e[1]]) > 274).unwrap_or(n); + let (kind, v) = if long { (4u16, u32::from(value).to_le_bytes()) } else { (3, [value.to_le_bytes(), [0, 0]].concat().try_into().unwrap()) }; + entries.insert(i, [&274u16.to_le_bytes()[..], &kind.to_le_bytes(), &1u32.to_le_bytes(), &v].concat()); + let mut out = tiff.to_vec(); + if out.len() % 2 == 1 { + out.push(0); + } + if let Some(y) = y_ppi { + let rational = u32::try_from(out.len()).unwrap(); + out.extend([y.to_le_bytes(), 1u32.to_le_bytes()].concat()); + let e = entries.iter_mut().find(|e| u16::from_le_bytes([e[0], e[1]]) == 283).unwrap(); + e[8..12].copy_from_slice(&rational.to_le_bytes()); + } + 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 +} + +/// TIFFs the image decoder can't read, grayscale with an alpha channel and 1-bit ones compressed +/// with CCITT Group 3, turn and flip as their Orientation tag says, as the others do (#4284), +/// whether the tag is a SHORT or a LONG. A quarter turn swaps the resolution's axes. +#[test] +fn tiffs_read_without_the_image_decoder_follow_their_orientation() { + // 3 × 2 at 150 × 300 ppi, the first pixel black and the rest white, opaque. + let gray = hand_tiff(1, 8, &[2], false, &[vec![0, 255, 255, 255, 255, 255], vec![255; 6]]); + let rows: Vec> = (0..2).map(|y| (0..3).map(|x| x == 0 && y == 0).collect()).collect(); + let g = vectorcraft_testkit::tiff::Group3 { + rows: &rows, + compression: 2, + t4_options: 0, + photometric: 0, + fill_order: 1, + rows_per_strip: 2, + tile: None, + rtc: false, + }; + let fax = vectorcraft_testkit::tiff::group3(&g); + // The Group 3 file declares no resolution. + for (kind, file, y_ppi) in [("Group 3", fax, None), ("gray with alpha", gray, Some(300))] { + assert!(image::load_from_memory(&file).is_err(), "{kind}: the image decoder reads it now"); + for (orientation, long) in (1..=8).flat_map(|o| [(o, false), (o, true)]) { + let what = format!("{kind}, orientation {orientation}, LONG {long}"); + let img = raster_image(&tiff_oriented(&file, orientation, long, y_ppi)).unwrap_or_else(|e| panic!("{what}: {e}")); + let quarter = orientation >= 5; + let size = if quarter { (2, 3) } else { (3, 2) }; + let ppi = y_ppi.map(|y| if quarter { (f64::from(y), 150.0) } else { (150.0, f64::from(y)) }); + assert_eq!(((img.width, img.height), img.ppi), (size, ppi), "{what}"); + assert_eq!(super::ppi::resolution(&img.blob.bytes), ppi, "{what}: the stored image"); + let px = image::load_from_memory(&img.blob.bytes).unwrap().to_luma8(); + let (right, bottom) = first_pixel_at(orientation); + let (x, y) = (if right { size.0 - 1 } else { 0 }, if bottom { size.1 - 1 } else { 0 }); + let black: Vec<_> = px.enumerate_pixels().filter(|p| p.2[0] < 128).map(|p| (p.0, p.1)).collect(); + assert_eq!(black, [(x, y)], "{what}: the first stored pixel's corner"); + } + } +} + +/// A CMYK TIFF that its Orientation tag turns or flips keeps its inks, turned, whether it stores +/// them together or one plane per ink, at 8 or 16 bits; a quarter turn swaps the resolution's axes. +/// Read again, the stored image is not turned a second time. +#[test] +fn cmyk_tiffs_turned_by_their_orientation_keep_their_inks() { + // 3 × 2 at 150 × 300 ppi: the first pixel full cyan, the others 30% black. + let first: [u8; 4] = [255, 0, 0, 0]; + let other: [u8; 4] = [0, 0, 0, 77]; + let channel = |i: usize| -> Vec { (0..6).map(|p| u16::from(if p == 0 { first[i] } else { other[i] })).collect() }; + for (bits, planar) in [(8, false), (8, true), (16, false), (16, true)] { + let channels: Vec> = (0..4).map(|i| channel(i).iter().map(|v| if bits == 16 { v * 257 } else { *v }).collect()).collect(); + let file = hand_tiff(5, bits, &[], planar, &channels); + for orientation in 1..=8 { + let what = format!("{bits} bits, planar {planar}, orientation {orientation}"); + let img = raster_image(&tiff_oriented(&file, orientation, false, Some(300))).unwrap_or_else(|e| panic!("{what}: {e}")); + let quarter = orientation >= 5; + let size = if quarter { (2, 3) } else { (3, 2) }; + let ppi = if quarter { (300.0, 150.0) } else { (150.0, 300.0) }; + assert_eq!(((img.width, img.height), img.ppi, img.blob.mime.as_str()), (size, Some(ppi), "image/tiff"), "{what}"); + assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image"); + let inks = img.blob.cmyk().unwrap_or_else(|| panic!("{what}: the inks are lost")); + assert_eq!((inks.width, inks.height), size, "{what}: the inks' size"); + let (right, bottom) = first_pixel_at(orientation); + let at = (if right { size.0 - 1 } else { 0 }, if bottom { size.1 - 1 } else { 0 }); + for (i, px) in inks.data.chunks(4).enumerate() { + let (x, y) = (u32::try_from(i).unwrap() % size.0, u32::try_from(i).unwrap() / size.0); + assert_eq!(px, if (x, y) == at { first } else { other }, "{what}: the inks of pixel ({x}, {y})"); + } + let again = raster_image(&img.blob.bytes).unwrap(); + assert_eq!(((again.width, again.height), again.blob.cmyk()), (size, Some(inks)), "{what}: read again, it isn't turned again"); + } + } +} + +/// A PNG (its `eXIf` chunk and `pHYs` resolution), a WebP (its `EXIF` chunk) and an RGB TIFF read +/// by the image decoder, turned a quarter by their orientation, swap their resolution's axes; a +/// half turn keeps them. +#[test] +fn png_webp_and_rgb_tiff_turned_a_quarter_swap_their_resolution() { + use image::ImageEncoder as _; + let rgb = image::RgbImage::from_fn(3, 2, |x, y| image::Rgb(if x == 0 && y == 0 { [0, 0, 0] } else { [255, 255, 255] })); + for orientation in [3u16, 6, 8] { + let mut png = Vec::new(); + let mut enc = image::codecs::png::PngEncoder::new(&mut png); + enc.set_exif_metadata(exif_block(orientation, (150, 300))).unwrap(); + enc.write_image(rgb.as_raw(), 3, 2, image::ExtendedColorType::Rgb8).unwrap(); + let png = super::ppi::with_png_resolution(&png, (150.0, 300.0)); + let mut webp = Vec::new(); + let mut enc = image::codecs::webp::WebPEncoder::new_lossless(&mut webp); + enc.set_exif_metadata(exif_block(orientation, (150, 300))).unwrap(); + enc.write_image(rgb.as_raw(), 3, 2, image::ExtendedColorType::Rgb8).unwrap(); + let channel = |c: usize| -> Vec { rgb.pixels().map(|p| u16::from(p[c])).collect() }; + let tiff = tiff_oriented(&hand_tiff(2, 8, &[], false, &[channel(0), channel(1), channel(2)]), orientation, false, Some(300)); + for (kind, file) in [("PNG", png), ("WebP", webp), ("TIFF", tiff)] { + let what = format!("{kind}, orientation {orientation}"); + assert_eq!(super::ppi::resolution(&file), Some((150.0, 300.0)), "{what}: as stored"); + let quarter = orientation >= 5; + let (size, ppi) = if quarter { ((2, 3), (300.0, 150.0)) } else { ((3, 2), (150.0, 300.0)) }; + let img = raster_image(&file).unwrap_or_else(|e| panic!("{what}: {e}")); + assert_eq!(((img.width, img.height), img.ppi), (size, Some(ppi)), "{what}"); + assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image"); + let px = image::load_from_memory(&img.blob.bytes).unwrap().to_luma8(); + let (right, bottom) = first_pixel_at(orientation); + let at = (if right { size.0 - 1 } else { 0 }, if bottom { size.1 - 1 } else { 0 }); + let black: Vec<_> = px.enumerate_pixels().filter(|p| p.2[0] < 128).map(|p| (p.0, p.1)).collect(); + assert_eq!(black, [at], "{what}: the first stored pixel's corner"); + } + } +} + /// 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] From 8187f7ae00cb2731e8d9ced6918b3a8c04f3b0bc Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 12 Nov 2026 05:45:26 -0800 Subject: [PATCH 2/2] Images: a CMYK JPEG turned by its EXIF orientation keeps its inks A four-component JPEG whose EXIF orientation turns or flips it went through the image decoder, which reads CMYK JPEGs as RGB, and was stored as an RGB PNG without its inks. It is now stored the way a turned CMYK TIFF is: its inks, 8 bits each, turned and written as a CMYK TIFF with the turned resolution. - vectorcraft_doc::cmyk::turned_cmyk turns the inks of a CMYK JPEG or TIFF, next to stored_cmyk_tiff; tiff_inks is private again. - raster_image swaps the resolution axes inline, as its HEIF branch does. - The links comment and the docs/target-app-parity.md line on EXIF orientation now describe the turned CMYK TIFF. --- crates/doc/src/cmyk.rs | 25 ++++++++++++---- crates/engine/src/cmd/fileio/load.rs | 27 +++++------------- crates/engine/src/cmd/fileio/tests.rs | 41 +++++++++++++++++++++++++++ crates/engine/src/cmd/links.rs | 2 +- docs/target-app-parity.md | 2 +- 5 files changed, 70 insertions(+), 27 deletions(-) diff --git a/crates/doc/src/cmyk.rs b/crates/doc/src/cmyk.rs index a3a6f7b016..f94f0ec4a8 100644 --- a/crates/doc/src/cmyk.rs +++ b/crates/doc/src/cmyk.rs @@ -306,7 +306,7 @@ fn jpeg_inks(bytes: &[u8]) -> Option { /// The ink amounts of a CMYK TIFF (16-bit samples to 8 bits), its inks stored together or one plane /// per ink. -pub fn tiff_inks(bytes: &[u8]) -> Option { +fn tiff_inks(bytes: &[u8]) -> Option { let (kind, mut d) = tiff(bytes)?; let (w, h) = d.dimensions().ok()?; if !matches!(kind, tiff::ColorType::CMYK(8 | 16)) { @@ -316,10 +316,10 @@ pub fn tiff_inks(bytes: &[u8]) -> Option { Inks::new(w, h, read_samples(&mut d, n, 4)?) } -/// CMYK TIFF `bytes` 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. +/// 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. pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option> { let inks = tiff_inks(bytes)?; let (_, mut d) = tiff(bytes)?; @@ -329,6 +329,21 @@ pub fn stored_cmyk_tiff(bytes: &[u8], ppi: Option<(f64, f64)>) -> Option ImageBlob::cmyk_tiff_at(&inks, ppi).map(|b| Arc::unwrap_or_clone(b.bytes)) } +/// 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. +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)?); + 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))) +} + /// A TIFF whose pixels have samples after their colour, which the image decoder doesn't read at /// all, as RGBA pixels: CMYK with an alpha channel (5 samples), each colour converted by `rgb` (ink /// amounts 0..1 → sRGB 0..1; the inks can't be kept with the transparency, so they show as RGB), diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index 7df402724a..7cb5d370bb 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -815,8 +815,8 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option { /// ([`vectorcraft_doc::cmyk::oversized_tiff`]) is refused as too large to draw. JPEG XL images are /// 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 TIFF -/// keeps its inks, 8 bits each ([`turned_cmyk_tiff`]). +/// 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`]). pub fn raster_image(bytes: &[u8]) -> Result { let ppi = super::ppi::resolution(bytes); if let Some((w, h)) = vectorcraft_doc::cmyk::oversized_tiff(bytes) { @@ -858,11 +858,12 @@ pub fn raster_image(bytes: &[u8]) -> Result { let orientation = matches!(f.id, "png" | "jpg" | "gif" | "webp" | "tiff").then(|| exif_orientation(bytes)).flatten(); let turned = orientation.is_some(); // The resolution describes the stored pixels; a quarter turn swaps its axes. - let shown_ppi = jxlread::oriented_resolution(ppi, orientation.map_or(1, |o| u32::from(o.to_exif()))); - if let Some(orientation) = orientation.filter(|_| f.id == "tiff") - && let Some(stored) = turned_cmyk_tiff(bytes, orientation, shown_ppi) + let quarter = orientation.is_some_and(|o| o.to_exif() >= 5); + let shown_ppi = ppi.map(|(x, y)| if quarter { (y, x) } else { (x, y) }); + if let Some(orientation) = orientation.filter(|_| matches!(f.id, "jpg" | "tiff")) + && let Some((tiff, size)) = vectorcraft_doc::cmyk::turned_cmyk(bytes, orientation, shown_ppi) { - return stored; + return stored(tiff, "image/tiff", size, shown_ppi); } let kept = match f.id { _ if turned => None, @@ -914,20 +915,6 @@ pub(crate) fn exif_orientation(bytes: &[u8]) -> Option) -> Option> { - let inks = vectorcraft_doc::cmyk::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)?); - px.apply_orientation(orientation); - let inks = vectorcraft_doc::cmyk::Inks::new(px.width(), px.height(), px.into_bytes())?; - let tiff = ImageBlob::cmyk_tiff_at(&inks, ppi)?; - Some(stored(std::sync::Arc::unwrap_or_clone(tiff.bytes), "image/tiff", (inks.width, inks.height), ppi)) -} - /// `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 { diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index 5f508bd6ec..5ea57858cc 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -1078,6 +1078,47 @@ fn cmyk_tiffs_turned_by_their_orientation_keep_their_inks() { } } +/// A CMYK JPEG that its EXIF orientation turns or flips keeps its inks, turned, stored as a CMYK +/// TIFF; a quarter turn swaps the resolution's axes. Orientation 1 keeps the JPEG as it is, and +/// the stored image, read again, is not turned a second time. +#[test] +fn cmyk_jpegs_turned_by_their_orientation_keep_their_inks() { + use vectorcraft_render::encode::jpeg::{self, ColorModel, JpegOptions}; + // 48 × 32: the top-left 16 × 16 (one whole JPEG MCU) full cyan, the rest 30% black. + let inks: Vec = + (0..32u32).flat_map(|y| (0..48u32).flat_map(move |x| if x < 16 && y < 16 { [255, 0, 0, 0] } else { [0, 0, 0, 77] })).collect(); + let o = JpegOptions { color_model: ColorModel::Cmyk, embed_icc: false, ..Default::default() }; + let jpg = jpeg::encode(&inks, 48, 32, 100, None, &o).unwrap(); + for orientation in 1..=8u16 { + let exif = [b"Exif\0\0".as_slice(), &exif_block(orientation, (300, 150))].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); + file.extend(&jpg[2..]); + let what = format!("orientation {orientation}"); + let quarter = orientation >= 5; + let (size, ppi) = if quarter { ((32, 48), (150.0, 300.0)) } else { ((48, 32), (300.0, 150.0)) }; + let mime = if orientation == 1 { "image/jpeg" } else { "image/tiff" }; + let img = raster_image(&file).unwrap_or_else(|e| panic!("{what}: {e}")); + assert_eq!(((img.width, img.height), img.ppi, img.blob.mime.as_str()), (size, Some(ppi), mime), "{what}"); + assert_eq!(super::ppi::resolution(&img.blob.bytes), Some(ppi), "{what}: the stored image"); + let inks = img.blob.cmyk().unwrap_or_else(|| panic!("{what}: the inks are lost")); + assert_eq!((inks.width, inks.height), size, "{what}: the inks' size"); + let (w, h) = size; + let at = |x: u32, y: u32| inks.data[usize::try_from((y * w + x) * 4).unwrap()..][..4].to_vec(); + let cyan = |p: &[u8]| p[0] > 200 && p[3] < 40; + let corners = [(false, false), (true, false), (true, true), (false, true)]; + let cyans: Vec<_> = + corners.into_iter().filter(|&(right, bottom)| cyan(&at(if right { w - 4 } else { 4 }, if bottom { h - 4 } else { 4 }))).collect(); + assert_eq!(cyans, [first_pixel_at(orientation)], "{what}: the first stored pixel's corner"); + let black = at(w / 2, h / 2); + assert!(black[0] < 40 && (60..=95).contains(&black[3]), "{what}: the other inks, {black:?}"); + let again = raster_image(&img.blob.bytes).unwrap(); + assert_eq!(((again.width, again.height), again.blob.cmyk()), (size, Some(inks)), "{what}: read again, it isn't turned again"); + } +} + /// A PNG (its `eXIf` chunk and `pHYs` resolution), a WebP (its `EXIF` chunk) and an RGB TIFF read /// by the image decoder, turned a quarter by their orientation, swap their resolution's axes; a /// half turn keeps them. diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index a134eb1c55..41417a4433 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -436,7 +436,7 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, base: LinkBase) -> (Cow<'a continue; }; let preview = d.images.get(&key).map(|b| b.bytes.clone()); - // A file read as it is keeps its own type; one turned upright (EXIF orientation) is the PNG it became. + // A file read as it is keeps its own type; one turned upright by its orientation is the PNG, or CMYK TIFF, it became. let mime = match fileio::format_for_name(g.link.name()) { Some(f) if img.blob.bytes.as_slice() == bytes.as_slice() => f.mime.to_string(), _ => img.blob.mime.clone(), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 3cc8df2a9e..49037bd59f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1553,7 +1553,7 @@ 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 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. - 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.