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",
|
"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"
|
||||||
|
|
|
||||||
|
|
@ -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 = []
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue