use std::fs::File; use std::io::BufReader; use std::path::Path; use anyhow::{Result, anyhow}; use exif::{In, Reader, Tag, Value}; use log::debug; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct ExifData { pub camera_make: Option, pub camera_model: Option, pub lens_model: Option, pub width: Option, pub height: Option, pub orientation: Option, pub gps_latitude: Option, pub gps_longitude: Option, pub gps_altitude: Option, pub focal_length: Option, pub aperture: Option, pub shutter_speed: Option, pub iso: Option, pub date_taken: Option, } pub fn supports_exif(path: &Path) -> bool { if let Some(ext) = path.extension() { let ext_lower = ext.to_string_lossy().to_lowercase(); matches!( ext_lower.as_str(), // JPEG formats "jpg" | "jpeg" | // TIFF and RAW formats based on TIFF "tiff" | "tif" | "nef" | "cr2" | "cr3" | "arw" | "dng" | "raf" | "orf" | "rw2" | "pef" | "srw" | // HEIF and variants "heif" | "heic" | "avif" | // PNG "png" | // WebP "webp" ) } else { false } } pub fn extract_exif_from_path(path: &Path) -> Result { debug!("Extracting EXIF from: {:?}", path); if !supports_exif(path) { return Err(anyhow!("File type does not support EXIF")); } let file = File::open(path)?; let mut bufreader = BufReader::new(file); let exifreader = Reader::new(); let exif = exifreader.read_from_container(&mut bufreader)?; let mut data = ExifData::default(); for field in exif.fields() { match field.tag { Tag::Make => { data.camera_make = get_string_value(field); } Tag::Model => { data.camera_model = get_string_value(field); } Tag::LensModel => { data.lens_model = get_string_value(field); } Tag::PixelXDimension | Tag::ImageWidth => { if data.width.is_none() { data.width = get_u32_value(field).map(|v| v as i32); } } Tag::PixelYDimension | Tag::ImageLength => { if data.height.is_none() { data.height = get_u32_value(field).map(|v| v as i32); } } Tag::Orientation => { data.orientation = get_u32_value(field).map(|v| v as i32); } Tag::FocalLength => { data.focal_length = get_rational_value(field); } Tag::FNumber => { data.aperture = get_rational_value(field); } Tag::ExposureTime => { data.shutter_speed = get_rational_string(field); } Tag::PhotographicSensitivity | Tag::ISOSpeed => { if data.iso.is_none() { data.iso = get_u32_value(field).map(|v| v as i32); } } Tag::DateTime | Tag::DateTimeOriginal => { if data.date_taken.is_none() { data.date_taken = parse_exif_datetime(field); } } _ => {} } } // Extract GPS coordinates if let Some(lat) = extract_gps_coordinate(&exif, Tag::GPSLatitude, Tag::GPSLatitudeRef) { data.gps_latitude = Some(lat); } if let Some(lon) = extract_gps_coordinate(&exif, Tag::GPSLongitude, Tag::GPSLongitudeRef) { data.gps_longitude = Some(lon); } if let Some(alt) = extract_gps_altitude(&exif) { data.gps_altitude = Some(alt); } debug!("Extracted EXIF data: {:?}", data); Ok(data) } fn get_string_value(field: &exif::Field) -> Option { match &field.value { Value::Ascii(vec) => { if let Some(bytes) = vec.first() { String::from_utf8(bytes.to_vec()) .ok() .map(|s| s.trim_end_matches('\0').to_string()) } else { None } } _ => { let display = field.display_value().to_string(); if display.is_empty() { None } else { Some(display) } } } } fn get_u32_value(field: &exif::Field) -> Option { match &field.value { Value::Short(vec) => vec.first().map(|&v| v as u32), Value::Long(vec) => vec.first().copied(), _ => None, } } fn get_rational_value(field: &exif::Field) -> Option { match &field.value { Value::Rational(vec) => { if let Some(rational) = vec.first() { if rational.denom == 0 { None } else { Some(rational.num as f64 / rational.denom as f64) } } else { None } } _ => None, } } fn get_rational_string(field: &exif::Field) -> Option { match &field.value { Value::Rational(vec) => { if let Some(rational) = vec.first() { if rational.denom == 0 { None } else if rational.num < rational.denom { Some(format!("{}/{}", rational.num, rational.denom)) } else { let value = rational.num as f64 / rational.denom as f64; Some(format!("{:.2}", value)) } } else { None } } _ => None, } } fn parse_exif_datetime(field: &exif::Field) -> Option { if let Some(datetime_str) = get_string_value(field) { use chrono::NaiveDateTime; // EXIF datetime format: "YYYY:MM:DD HH:MM:SS" // Note: EXIF dates are local time without timezone info // We return the timestamp as if it were UTC, and the client will display it as-is NaiveDateTime::parse_from_str(&datetime_str, "%Y:%m:%d %H:%M:%S") .ok() .map(|dt| dt.and_utc().timestamp()) } else { None } } fn extract_gps_coordinate(exif: &exif::Exif, coord_tag: Tag, ref_tag: Tag) -> Option { let coord_field = exif.get_field(coord_tag, In::PRIMARY)?; let ref_field = exif.get_field(ref_tag, In::PRIMARY)?; let coordinates = match &coord_field.value { Value::Rational(vec) => { if vec.len() < 3 { return None; } let degrees = vec[0].num as f64 / vec[0].denom as f64; let minutes = vec[1].num as f64 / vec[1].denom as f64; let seconds = vec[2].num as f64 / vec[2].denom as f64; degrees + (minutes / 60.0) + (seconds / 3600.0) } _ => return None, }; let reference = get_string_value(ref_field)?; let sign = if reference.starts_with('S') || reference.starts_with('W') { -1.0 } else { 1.0 }; Some(coordinates * sign) } fn extract_gps_altitude(exif: &exif::Exif) -> Option { let alt_field = exif.get_field(Tag::GPSAltitude, In::PRIMARY)?; match &alt_field.value { Value::Rational(vec) => { if let Some(rational) = vec.first() { if rational.denom == 0 { None } else { let altitude = rational.num as f64 / rational.denom as f64; // Check if below sea level if let Some(ref_field) = exif.get_field(Tag::GPSAltitudeRef, In::PRIMARY) && let Some(ref_val) = get_u32_value(ref_field) && ref_val == 1 { return Some(-altitude); } Some(altitude) } } else { None } } _ => None, } } #[cfg(test)] mod tests { use super::*; #[test] fn test_supports_exif_jpeg() { assert!(supports_exif(Path::new("test.jpg"))); assert!(supports_exif(Path::new("test.jpeg"))); assert!(supports_exif(Path::new("test.JPG"))); } #[test] fn test_supports_exif_raw_formats() { assert!(supports_exif(Path::new("test.nef"))); // Nikon assert!(supports_exif(Path::new("test.NEF"))); assert!(supports_exif(Path::new("test.cr2"))); // Canon assert!(supports_exif(Path::new("test.cr3"))); // Canon assert!(supports_exif(Path::new("test.arw"))); // Sony assert!(supports_exif(Path::new("test.dng"))); // Adobe DNG } #[test] fn test_supports_exif_tiff() { assert!(supports_exif(Path::new("test.tiff"))); assert!(supports_exif(Path::new("test.tif"))); assert!(supports_exif(Path::new("test.TIFF"))); } #[test] fn test_supports_exif_heif() { assert!(supports_exif(Path::new("test.heif"))); assert!(supports_exif(Path::new("test.heic"))); assert!(supports_exif(Path::new("test.avif"))); } #[test] fn test_supports_exif_png_webp() { assert!(supports_exif(Path::new("test.png"))); assert!(supports_exif(Path::new("test.PNG"))); assert!(supports_exif(Path::new("test.webp"))); assert!(supports_exif(Path::new("test.WEBP"))); } #[test] fn test_supports_exif_unsupported() { assert!(!supports_exif(Path::new("test.mp4"))); assert!(!supports_exif(Path::new("test.mov"))); assert!(!supports_exif(Path::new("test.txt"))); assert!(!supports_exif(Path::new("test.gif"))); } #[test] fn test_supports_exif_no_extension() { assert!(!supports_exif(Path::new("test"))); } }