diff --git a/Cargo.lock b/Cargo.lock index 887ff37..75ea16f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -417,6 +417,7 @@ dependencies = [ "dotenvy", "image", "isocountry", + "kamadak-exif", "once_cell", "open", "paste", @@ -1498,6 +1499,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kamadak-exif" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837" +dependencies = [ + "mutate_once", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1654,6 +1664,12 @@ dependencies = [ "pxfm", ] +[[package]] +name = "mutate_once" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" + [[package]] name = "nom" version = "7.1.3" diff --git a/Cargo.toml b/Cargo.toml index 3a0c58a..9d3d51b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ url = "2" uuid = { version = "1", features = ["v4"] } webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation"] } webauthn-rs-proto = "0.5" +kamadak-exif = "0.6.1" [features] e2e = [] diff --git a/src/infrastructure/image_processing.rs b/src/infrastructure/image_processing.rs index d03be79..6ea3759 100644 --- a/src/infrastructure/image_processing.rs +++ b/src/infrastructure/image_processing.rs @@ -1,6 +1,6 @@ use anyhow::{Context, bail}; use base64::Engine; -use image::ImageReader; +use image::{DynamicImage, ImageReader}; use std::io::Cursor; /// Maximum dimension (width or height) for the full-size image. @@ -42,6 +42,8 @@ pub fn process_data_url(data_url: &str) -> anyhow::Result { /// Process raw image bytes (JPEG/PNG/WebP) into resized full + thumbnail JPEGs. pub fn process_image_bytes(raw_bytes: &[u8]) -> anyhow::Result { + let orientation = read_exif_orientation(raw_bytes); + let mut reader = ImageReader::new(Cursor::new(raw_bytes)) .with_guessed_format() .context("failed to guess image format")?; @@ -53,6 +55,7 @@ pub fn process_image_bytes(raw_bytes: &[u8]) -> anyhow::Result { reader.limits(limits); let img = reader.decode().context("failed to decode image")?; + let img = apply_exif_orientation(img, orientation); let full = img.resize( MAX_FULL_SIZE, @@ -76,6 +79,38 @@ pub fn process_image_bytes(raw_bytes: &[u8]) -> anyhow::Result { }) } +/// Read the EXIF orientation tag from raw image bytes. +/// +/// Returns the orientation value (1-8), or 1 (normal) if no EXIF data is found. +fn read_exif_orientation(raw_bytes: &[u8]) -> u32 { + let reader = exif::Reader::new(); + let Ok(exif_data) = reader.read_from_container(&mut Cursor::new(raw_bytes)) else { + return 1; + }; + exif_data + .get_field(exif::Tag::Orientation, exif::In::PRIMARY) + .and_then(|f| f.value.get_uint(0)) + .unwrap_or(1) +} + +/// Apply EXIF orientation transforms so the image displays correctly. +/// +/// iPhone cameras (and many others) store photos in a fixed sensor orientation +/// and embed an EXIF `Orientation` tag. Without applying this, photos appear +/// rotated or mirrored. +fn apply_exif_orientation(img: DynamicImage, orientation: u32) -> DynamicImage { + match orientation { + 2 => img.fliph(), + 3 => img.rotate180(), + 4 => img.flipv(), + 5 => img.rotate90().fliph(), + 6 => img.rotate90(), + 7 => img.rotate90().flipv(), + 8 => img.rotate270(), + _ => img, // 1 (normal) or unknown + } +} + /// Decode a `data:image/...;base64,...` URL into raw bytes. fn decode_data_url(data_url: &str) -> anyhow::Result> { let Some(rest) = data_url.strip_prefix("data:") else { @@ -143,4 +178,45 @@ mod tests { "should reject non-image MIME types" ); } + + #[test] + fn apply_exif_orientation_identity() { + let img = DynamicImage::new_rgb8(4, 2); + let result = apply_exif_orientation(img.clone(), 1); + assert_eq!((result.width(), result.height()), (4, 2)); + } + + #[test] + fn apply_exif_orientation_rotate90() { + // Orientation 6 = rotate 90° CW — swaps width and height + let img = DynamicImage::new_rgb8(4, 2); + let result = apply_exif_orientation(img, 6); + assert_eq!((result.width(), result.height()), (2, 4)); + } + + #[test] + fn apply_exif_orientation_rotate270() { + // Orientation 8 = rotate 270° CW — swaps width and height + let img = DynamicImage::new_rgb8(4, 2); + let result = apply_exif_orientation(img, 8); + assert_eq!((result.width(), result.height()), (2, 4)); + } + + #[test] + fn apply_exif_orientation_rotate180() { + // Orientation 3 = rotate 180° — preserves dimensions + let img = DynamicImage::new_rgb8(4, 2); + let result = apply_exif_orientation(img, 3); + assert_eq!((result.width(), result.height()), (4, 2)); + } + + #[test] + fn read_exif_orientation_returns_default_for_png() { + // PNG doesn't have EXIF, should return 1 + let img = DynamicImage::new_rgb8(2, 2); + let mut buf = Vec::new(); + img.write_to(&mut Cursor::new(&mut buf), image::ImageFormat::Png) + .expect("encode png"); + assert_eq!(read_exif_orientation(&buf), 1); + } } diff --git a/static/js/image-utils.js b/static/js/image-utils.js index f9cfed9..4e9919e 100644 --- a/static/js/image-utils.js +++ b/static/js/image-utils.js @@ -1,18 +1,11 @@ -/** Convert any image file (HEIC, AVIF, WebP, PNG, etc.) to a JPEG data URL via Canvas. */ -const imageToJpegDataUrl = (file) => - new Promise((resolve, reject) => { - const img = new Image(); - img.onload = () => { - const canvas = document.createElement("canvas"); - canvas.width = img.naturalWidth; - canvas.height = img.naturalHeight; - canvas.getContext("2d").drawImage(img, 0, 0); - URL.revokeObjectURL(img.src); - resolve(canvas.toDataURL("image/jpeg", 0.92)); - }; - img.onerror = () => { - URL.revokeObjectURL(img.src); - reject(new Error("Failed to load image")); - }; - img.src = URL.createObjectURL(file); - }); +/** Convert any image file (HEIC, AVIF, WebP, PNG, etc.) to a JPEG data URL via Canvas. + * Uses createImageBitmap which correctly applies EXIF orientation (e.g. iPhone photos). */ +const imageToJpegDataUrl = async (file) => { + const bitmap = await createImageBitmap(file); + const canvas = document.createElement("canvas"); + canvas.width = bitmap.width; + canvas.height = bitmap.height; + canvas.getContext("2d").drawImage(bitmap, 0, 0); + bitmap.close(); + return canvas.toDataURL("image/jpeg", 0.92); +};