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.
This commit is contained in:
dotCooCoo
2026-11-12 05:45:26 -08:00
parent 28dfd66b65
commit 8187f7ae00
5 changed files with 70 additions and 27 deletions
+20 -5
View File
@@ -306,7 +306,7 @@ fn jpeg_inks(bytes: &[u8]) -> Option<Inks> {
/// 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<Inks> {
fn tiff_inks(bytes: &[u8]) -> Option<Inks> {
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> {
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<Vec<u8>> {
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<Vec<u8>
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<u8>, (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),
+7 -20
View File
@@ -815,8 +815,8 @@ pub(crate) fn size_note(bytes: &[u8]) -> Option<String> {
/// ([`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<RasterImage> {
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<RasterImage> {
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<image::metadata::Orientat
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<Result<RasterImage>> {
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<Vec<u8>>, ppi: Option<(f64, f64)>) -> Result<RasterImage> {
+41
View File
@@ -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<u8> =
(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.
+1 -1
View File
@@ -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(),
+1 -1
View File
@@ -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 <name>: 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.