diff --git a/crates/engine/src/cmd/fileio/load.rs b/crates/engine/src/cmd/fileio/load.rs index c1ebdf723..2c0c7eb73 100644 --- a/crates/engine/src/cmd/fileio/load.rs +++ b/crates/engine/src/cmd/fileio/load.rs @@ -602,7 +602,11 @@ pub fn raster_image(bytes: &[u8]) -> Result { reader.limits(limits); let kind = reader.format().ok_or_else(|| err("not an image VectorCraft reads (see document.formats)"))?; 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 kept = match f.id { + _ if turned => None, "png" | "jpg" | "gif" | "webp" => Some(bytes.to_vec()), "tiff" => vectorcraft_doc::cmyk::stored_cmyk_tiff(bytes, ppi), _ => None, @@ -621,13 +625,16 @@ pub fn raster_image(bytes: &[u8]) -> Result { Some(read) => 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"))); } - image::DynamicImage::from_decoder(decoder).map_err(err)?.to_rgba8() + 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() } }, }; @@ -637,6 +644,14 @@ pub fn raster_image(bytes: &[u8]) -> Result { as_png(img, 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. +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) +} + /// `img` stored as a PNG that keeps the file's resolution. fn as_png(img: image::RgbaImage, ppi: Option<(f64, f64)>) -> Result { let size = img.dimensions(); diff --git a/crates/engine/src/cmd/fileio/tests.rs b/crates/engine/src/cmd/fileio/tests.rs index f2b770682..f781a87b5 100644 --- a/crates/engine/src/cmd/fileio/tests.rs +++ b/crates/engine/src/cmd/fileio/tests.rs @@ -837,3 +837,33 @@ fn a_failed_save_does_not_stamp_the_documents_dates() { s.execute("edit.undo", &json!({})).unwrap(); assert!(!s.doc().unwrap().is_dirty()); } + +/// #4284: a photo whose EXIF orientation says the camera was turned places upright: its pixels +/// turned as the orientation says, its width and height swapped. One without keeps its own bytes. +#[test] +fn a_photo_places_the_way_its_exif_orientation_says() { + // 16 × 8, red on the left half. + let mut rgb = image::RgbImage::from_pixel(16, 8, image::Rgb([255, 255, 255])); + for y in 0..8 { + for x in 0..8 { + rgb.put_pixel(x, y, image::Rgb([220, 0, 0])); + } + } + let mut jpg = Vec::new(); + image::DynamicImage::ImageRgb8(rgb).write_to(&mut Cursor::new(&mut jpg), image::ImageFormat::Jpeg).unwrap(); + // An EXIF segment with Orientation 6: turn a quarter clockwise to show. + let exif: Vec = + [b"Exif\0\0".as_slice(), b"II", &[0x2a, 0, 8, 0, 0, 0], &[1, 0], &[0x12, 0x01, 3, 0, 1, 0, 0, 0, 6, 0, 0, 0], &[0, 0, 0, 0]].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 plain = raster_image(&jpg).unwrap(); + assert_eq!((plain.width, plain.height, plain.blob.bytes.as_slice()), (16, 8, jpg.as_slice()), "a photo as taken keeps its bytes"); + let img = raster_image(&turned).unwrap(); + assert_eq!((img.width, img.height), (8, 16), "turned a quarter"); + let px = image::load_from_memory(&img.blob.bytes).unwrap().to_rgb8(); + let red = |p: &image::Rgb| p[0] > 150 && p[1] < 80; + assert!(red(px.get_pixel(4, 3)) && !red(px.get_pixel(4, 12)), "the left half is on top"); +} diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 42aafc21d..c0aacef93 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -374,7 +374,11 @@ pub(crate) fn full_linked_images<'a>(d: &'a Document, doc_path: Option<&str>) -> continue; }; let preview = d.images.get(&key).map(|b| b.bytes.clone()); - let mime = fileio::format_for_name(g.link.name()).map(|f| f.mime.to_string()).unwrap_or_else(|| img.blob.mime.clone()); + // A file read as it is keeps its own type; one turned upright (EXIF orientation) is the PNG 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(), + }; out.images.insert(key, ImageBlob { mime, bytes: img.blob.bytes, proxy: preview }); } let mut warnings = vec![]; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 733a8b7cc..7d72a952c 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Parity with Adobe Illustrator -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Contextual task bar: stays up through a click on another object, hides only for a drag (#4681); scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-30 · **Change:** minor (Place: a photo's EXIF orientation turns it upright (#4284); scores unchanged) · **Target:** Adobe Illustrator 2026 (30.x; public release notes through 30.7, August 2026) ## Feature submission evidence @@ -1047,6 +1047,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - A Word paragraph justified with a low, medium or high kashida length places justified with Insert Kashidas set to Short, Medium or Long (`file.place`, `crates/engine/src/tests/textimport.rs`). - 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. - 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). - 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. @@ -1261,6 +1262,7 @@ undo step, with any adjustment and its params; bad requests refused) and `addons | Date | Change | Summary | |---|---|---| +| 2026-10-30 | minor | Place: a photo's EXIF orientation turns it upright (#4284); scores and estimates unchanged | | 2026-10-30 | minor | Contextual task bar: stays up through a click on another object, hides only for a drag (#4681); scores and estimates unchanged | | 2026-10-30 | minor | Draw Inside marks the corners of the object it draws into (#4319); scores and estimates unchanged | | 2026-10-30 | minor | Smart Pen add-on (a VectorCraft addition): evidence added; scores unchanged. |