Place: a photo's EXIF orientation turns it upright (#4284)

This commit is contained in:
bflatastic
2026-10-30 17:15:38 +07:00
parent 9646feb672
commit f36dad59b3
3 changed files with 52 additions and 3 deletions
+17 -2
View File
@@ -602,7 +602,11 @@ pub fn raster_image(bytes: &[u8]) -> Result<RasterImage> {
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<RasterImage> {
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<RasterImage> {
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<image::metadata::Orientation> {
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<RasterImage> {
let size = img.dimensions();
+30
View File
@@ -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<u8> =
[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<u8>| 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");
}
+5 -1
View File
@@ -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![];