fix: apply EXIF orientation to uploaded photos

iPhone photos were displayed rotated because EXIF orientation metadata
was not being applied. Fix both the client-side canvas conversion
(use createImageBitmap which respects EXIF) and the server-side image
processing (read and apply EXIF orientation before resizing).
This commit is contained in:
Jon Seager 2026-02-12 18:07:52 +00:00
parent 98053a8679
commit 2e41c28429
No known key found for this signature in database
4 changed files with 105 additions and 19 deletions

16
Cargo.lock generated
View file

@ -417,6 +417,7 @@ dependencies = [
"dotenvy", "dotenvy",
"image", "image",
"isocountry", "isocountry",
"kamadak-exif",
"once_cell", "once_cell",
"open", "open",
"paste", "paste",
@ -1498,6 +1499,15 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@ -1654,6 +1664,12 @@ dependencies = [
"pxfm", "pxfm",
] ]
[[package]]
name = "mutate_once"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af"
[[package]] [[package]]
name = "nom" name = "nom"
version = "7.1.3" version = "7.1.3"

View file

@ -40,6 +40,7 @@ url = "2"
uuid = { version = "1", features = ["v4"] } uuid = { version = "1", features = ["v4"] }
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation"] } webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation"] }
webauthn-rs-proto = "0.5" webauthn-rs-proto = "0.5"
kamadak-exif = "0.6.1"
[features] [features]
e2e = [] e2e = []

View file

@ -1,6 +1,6 @@
use anyhow::{Context, bail}; use anyhow::{Context, bail};
use base64::Engine; use base64::Engine;
use image::ImageReader; use image::{DynamicImage, ImageReader};
use std::io::Cursor; use std::io::Cursor;
/// Maximum dimension (width or height) for the full-size image. /// Maximum dimension (width or height) for the full-size image.
@ -42,6 +42,8 @@ pub fn process_data_url(data_url: &str) -> anyhow::Result<ProcessedImage> {
/// Process raw image bytes (JPEG/PNG/WebP) into resized full + thumbnail JPEGs. /// Process raw image bytes (JPEG/PNG/WebP) into resized full + thumbnail JPEGs.
pub fn process_image_bytes(raw_bytes: &[u8]) -> anyhow::Result<ProcessedImage> { pub fn process_image_bytes(raw_bytes: &[u8]) -> anyhow::Result<ProcessedImage> {
let orientation = read_exif_orientation(raw_bytes);
let mut reader = ImageReader::new(Cursor::new(raw_bytes)) let mut reader = ImageReader::new(Cursor::new(raw_bytes))
.with_guessed_format() .with_guessed_format()
.context("failed to guess image format")?; .context("failed to guess image format")?;
@ -53,6 +55,7 @@ pub fn process_image_bytes(raw_bytes: &[u8]) -> anyhow::Result<ProcessedImage> {
reader.limits(limits); reader.limits(limits);
let img = reader.decode().context("failed to decode image")?; let img = reader.decode().context("failed to decode image")?;
let img = apply_exif_orientation(img, orientation);
let full = img.resize( let full = img.resize(
MAX_FULL_SIZE, MAX_FULL_SIZE,
@ -76,6 +79,38 @@ pub fn process_image_bytes(raw_bytes: &[u8]) -> anyhow::Result<ProcessedImage> {
}) })
} }
/// 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. /// Decode a `data:image/...;base64,...` URL into raw bytes.
fn decode_data_url(data_url: &str) -> anyhow::Result<Vec<u8>> { fn decode_data_url(data_url: &str) -> anyhow::Result<Vec<u8>> {
let Some(rest) = data_url.strip_prefix("data:") else { let Some(rest) = data_url.strip_prefix("data:") else {
@ -143,4 +178,45 @@ mod tests {
"should reject non-image MIME types" "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);
}
} }

View file

@ -1,18 +1,11 @@
/** Convert any image file (HEIC, AVIF, WebP, PNG, etc.) to a JPEG data URL via Canvas. */ /** Convert any image file (HEIC, AVIF, WebP, PNG, etc.) to a JPEG data URL via Canvas.
const imageToJpegDataUrl = (file) => * Uses createImageBitmap which correctly applies EXIF orientation (e.g. iPhone photos). */
new Promise((resolve, reject) => { const imageToJpegDataUrl = async (file) => {
const img = new Image(); const bitmap = await createImageBitmap(file);
img.onload = () => {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
canvas.width = img.naturalWidth; canvas.width = bitmap.width;
canvas.height = img.naturalHeight; canvas.height = bitmap.height;
canvas.getContext("2d").drawImage(img, 0, 0); canvas.getContext("2d").drawImage(bitmap, 0, 0);
URL.revokeObjectURL(img.src); bitmap.close();
resolve(canvas.toDataURL("image/jpeg", 0.92)); return canvas.toDataURL("image/jpeg", 0.92);
}; };
img.onerror = () => {
URL.revokeObjectURL(img.src);
reject(new Error("Failed to load image"));
};
img.src = URL.createObjectURL(file);
});