From f36dad59b3a0d4dab17352456acb6b714b14f401 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 30 Oct 2026 11:26:22 +0700 Subject: [PATCH] Place: a photo's EXIF orientation turns it upright (#4284) --- crates/engine/src/cmd/fileio/load.rs | 19 +++++++++++++++-- crates/engine/src/cmd/fileio/tests.rs | 30 +++++++++++++++++++++++++++ crates/engine/src/cmd/links.rs | 6 +++++- 3 files changed, 52 insertions(+), 3 deletions(-) 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![];