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:
parent
98053a8679
commit
2e41c28429
4 changed files with 105 additions and 19 deletions
16
Cargo.lock
generated
16
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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<ProcessedImage> {
|
|||
|
||||
/// Process raw image bytes (JPEG/PNG/WebP) into resized full + thumbnail JPEGs.
|
||||
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))
|
||||
.with_guessed_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);
|
||||
|
||||
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<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.
|
||||
fn decode_data_url(data_url: &str) -> anyhow::Result<Vec<u8>> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
/** 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 = 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);
|
||||
});
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
canvas.getContext("2d").drawImage(bitmap, 0, 0);
|
||||
bitmap.close();
|
||||
return canvas.toDataURL("image/jpeg", 0.92);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue