//! Photo metadata for LightCraft: EXIF (TIFF/raw files, JPEG APP1, PNG `eXIf`, WebP `EXIF`), XMP packets //! (read + write, standard namespaces plus our own `lc:` namespace) and basic IPTC-IIM. //! //! Entry points: //! - [`read_exif`] — a TIFF-structured Exif block → [`Metadata`]; [`from_tiff`] for an already parsed stream. //! - [`embedded`] / [`jpeg_segments`] / [`png_chunks`] / [`webp_chunks`] — locate Exif / XMP / ICC / IPTC blocks. //! - [`parse_xmp`] / [`write_xmp`] — XMP packets (interchange fields + the opaque `lc:settings` JSON). //! - [`parse_iptc`] — IPTC-IIM record 2 datasets. //! - [`parse_gpx`] — GPS track logs (GPX) for geotagging by capture time. //! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw, Canon CR3). //! - [`cr3`] — the Canon CR3 container (metadata blocks, XMP, the JPEG and raw tracks). #![forbid(unsafe_code)] mod container; pub mod cr3; mod datetime; mod exif; pub mod focus; mod focus_read; mod gpx; mod iptc; pub mod tags; mod xmp; mod xmp_merge; pub use container::{Embedded, embedded, jpeg_segments, png_chunks, webp_chunks}; pub use datetime::DateTime; pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, write_exif}; pub use focus::FocusPoint; pub use gpx::{GpxError, Match, TrackPoint, Tracklog, parse_gpx}; pub use iptc::parse_iptc; pub use lightcraft_geom::{Orientation, Rect}; pub use tags::{TagRow, file_tag_rows, tag_rows}; pub use xmp::{CRS_NS, LC_NS, XmpData, XmpError, XmpValue, parse_xmp, write_xmp, write_xmp_lc}; pub use xmp_merge::{MergeRules, merge_xmp}; use serde::{Deserialize, Serialize}; /// GPS position (WGS 84). Latitude/longitude in signed decimal degrees (north / east positive). #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct Gps { pub latitude: f64, pub longitude: f64, /// Metres above sea level (negative below). pub altitude: Option, } /// Exif `Flash` value decoded. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct Flash { pub fired: bool, /// The raw Exif `Flash` bit field. pub raw: u16, } /// A named region of interest on a photo (MWG Region Guidelines, `mwg-rs:Regions`): most often a face, /// drawn by Lightroom, digiKam, Picasa or similar tools. Read-only for now — LightCraft does not write /// regions yet (see `docs/xmp-interop.md`). #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Region { /// Normalized to the image's full, oriented frame (`Metadata::width`/`height`), y-down, 0..1 on /// both axes — MWG's own convention, and what `AppliedToDimensions` areas are converted to. pub rect: Rect, pub kind: RegionKind, /// `mwg-rs:Name` (e.g. the person's name). pub name: Option, pub description: Option, } /// `mwg-rs:Type`. Unrecognised values are kept verbatim rather than dropped. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub enum RegionKind { Face, Pet, Focus, BarCode, /// `mwg-rs:Type` was missing, empty, or a value we don't recognise. Other(String), } /// Everything LightCraft shows or searches about a photo. All fields are optional; unknown = `None`/empty. #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct Metadata { // camera pub make: Option, pub model: Option, pub serial_number: Option, pub software: Option, // lens pub lens_make: Option, pub lens_model: Option, pub lens_serial_number: Option, /// Min/max focal length (mm) and min/max f-number at those, as in Exif `LensSpecification`. pub lens_spec: Option<[f64; 4]>, // capture pub capture_time: Option, /// Seconds. pub exposure_time: Option, pub f_number: Option, pub iso: Option, /// Millimetres. pub focal_length: Option, pub focal_length_35mm: Option, pub flash: Option, /// EV. pub exposure_bias: Option, /// Exif `ExposureProgram` code. pub exposure_program: Option, /// Exif `MeteringMode` code. pub metering_mode: Option, /// Exif `WhiteBalance` (0 auto, 1 manual). pub white_balance: Option, pub orientation: Option, pub gps: Option, /// Pixel dimensions as recorded (not oriented). pub width: Option, pub height: Option, // descriptive / user pub artist: Option, pub copyright: Option, /// Copyright status (`xmpRights:Marked`): `true` = copyrighted, `false` = public domain, `None` = unknown. pub copyright_marked: Option, /// Rights usage terms (`xmpRights:UsageTerms`). pub usage_terms: Option, /// Copyright info URL (`xmpRights:WebStatement`). pub copyright_url: Option, pub title: Option, pub caption: Option, /// Accessibility text (`Iptc4xmpCore:AltTextAccessibility`). pub alt_text: Option, /// Long accessibility description (`Iptc4xmpCore:ExtDescrAccessibility`). pub extended_description: Option, /// Place within the city (`Iptc4xmpCore:Location`). pub sublocation: Option, /// `photoshop:City`, `photoshop:State`, `photoshop:Country`. pub city: Option, pub state: Option, pub country: Option, pub keywords: Vec, /// Hierarchical keywords, `|`-separated paths (`lr:hierarchicalSubject` convention). pub hierarchical_keywords: Vec, /// -1 = rejected, 0 = unrated, 1..=5 stars. pub rating: Option, /// Colour label name (e.g. "Red"). pub label: Option, /// Face/pet/focus/barcode regions (`mwg-rs:Regions`), read from XMP only. pub regions: Vec, /// Where the camera focused, from its maker notes ([`focus`]; Sony only for now), on the upright photo. #[serde(default, skip_serializing_if = "Option::is_none")] pub focus: Option, } macro_rules! fill { ($self:ident, $o:ident, $($f:ident),*) => { $( if $self.$f.is_none() { $self.$f = $o.$f.clone(); } )* }; } macro_rules! overlay { ($self:ident, $o:ident, $($f:ident),*) => { $( if $o.$f.is_some() { $self.$f = $o.$f.clone(); } )* }; } impl Metadata { /// Fill every empty field of `self` from `other`. pub fn fill_missing(&mut self, other: &Metadata) { fill!( self, other, make, model, serial_number, software, lens_make, lens_model, lens_serial_number, lens_spec, capture_time, exposure_time, f_number, iso, focal_length, focal_length_35mm, flash, exposure_bias, exposure_program, metering_mode, white_balance, orientation, gps, width, height, artist, copyright, copyright_marked, usage_terms, copyright_url, title, caption, alt_text, extended_description, sublocation, city, state, country, rating, label ); if self.keywords.is_empty() { self.keywords = other.keywords.clone(); } if self.hierarchical_keywords.is_empty() { self.hierarchical_keywords = other.hierarchical_keywords.clone(); } if self.regions.is_empty() { self.regions = other.regions.clone(); } if self.focus.is_none() { self.focus = other.focus.clone(); } } /// Replace the user-editable fields (rating, label, title, caption, artist, copyright, keywords, GPS) with /// `other`'s where `other` has them — the XMP-over-EXIF precedence rule. pub fn overlay_user_fields(&mut self, other: &Metadata) { overlay!(self, other, rating, label, title, caption, artist, copyright, copyright_marked, usage_terms, copyright_url, gps); if !other.keywords.is_empty() { self.keywords = other.keywords.clone(); } if !other.hierarchical_keywords.is_empty() { self.hierarchical_keywords = other.hierarchical_keywords.clone(); } if !other.regions.is_empty() { self.regions = other.regions.clone(); } } /// Exposure time formatted like cameras show it: `1/250`, `0.5`, `2″`. pub fn exposure_display(&self) -> Option { let t = self.exposure_time?; if t.is_nan() || t <= 0.0 { return None; } Some(if t < 0.3 { format!("1/{}", (1.0 / t).round()) } else if t < 1.0 { format!("{}", (t * 10.0).round() / 10.0) } else { format!("{}″", (t * 10.0).round() / 10.0) }) } /// Human name of the metering mode. pub fn metering_name(&self) -> Option<&'static str> { Some(match self.metering_mode? { 1 => "Average", 2 => "Center-weighted average", 3 => "Spot", 4 => "Multi-spot", 5 => "Pattern", 6 => "Partial", 255 => "Other", _ => "Unknown", }) } /// Human name of the exposure program. pub fn exposure_program_name(&self) -> Option<&'static str> { Some(match self.exposure_program? { 1 => "Manual", 2 => "Program AE", 3 => "Aperture-priority AE", 4 => "Shutter speed priority AE", 5 => "Creative (slow speed)", 6 => "Action (high speed)", 7 => "Portrait", 8 => "Landscape", 9 => "Bulb", _ => "Not defined", }) } } /// Read everything we understand from a whole file (JPEG, PNG, WebP, TIFF-based incl. DNG/raw). /// Precedence: EXIF, then IPTC fills gaps, then XMP overrides user fields and fills the rest. pub fn extract(bytes: &[u8]) -> Metadata { let e = embedded(bytes); let mut m = e.exif.as_deref().map(read_exif).unwrap_or_default(); if let Some(i) = e.iptc.as_deref() { m.fill_missing(&parse_iptc(i)); } if let Some(x) = e.xmp.as_deref().and_then(|x| parse_xmp(x).ok()) { m.overlay_user_fields(&x.metadata); m.fill_missing(&x.metadata); } m } /// A lens name from its focal and aperture range (Exif LensSpecification, or a Nikon maker note's Lens), written the /// way Lightroom shows a lens it has no name for: `50.0 mm f/1.8`, `18.0-135.0 mm f/3.5-5.6`. pub fn lens_spec_name(spec: [f64; 4]) -> Option { let [short, long, wide, narrow] = spec; if ![short, long, wide, narrow].iter().all(|v| v.is_finite() && *v > 0.0) || long < short { return None; } let focal = if (long - short).abs() < 0.05 { format!("{short:.1}") } else { format!("{short:.1}-{long:.1}") }; let aperture = if (narrow - wide).abs() < 0.05 { format!("{wide:.1}") } else { format!("{wide:.1}-{narrow:.1}") }; Some(format!("{focal} mm f/{aperture}")) } /// Lenient number parsing used by XMP/IPTC: `"28/10"`, `"2.8"`, `"+0.7"`. pub(crate) fn parse_number(s: &str) -> Option { let s = s.trim(); if let Some((n, d)) = s.split_once('/') { let (n, d) = (n.trim().parse::().ok()?, d.trim().parse::().ok()?); return (d != 0.0).then_some(n / d).filter(|v| v.is_finite()); } s.parse::().ok().filter(|v| v.is_finite()) } #[cfg(test)] mod tests { use super::*; #[test] fn lens_names_from_a_spec_read_like_lightroom() { assert_eq!(lens_spec_name([50.0, 50.0, 1.8, 1.8]).as_deref(), Some("50.0 mm f/1.8")); assert_eq!(lens_spec_name([18.0, 135.0, 3.5, 5.6]).as_deref(), Some("18.0-135.0 mm f/3.5-5.6")); assert_eq!(lens_spec_name([24.0, 70.0, 2.8, 2.8]).as_deref(), Some("24.0-70.0 mm f/2.8")); assert_eq!(lens_spec_name([70.0, 210.0, 4.0, 5.6]).as_deref(), Some("70.0-210.0 mm f/4.0-5.6")); assert_eq!(lens_spec_name([0.0, 0.0, 0.0, 0.0]), None); assert_eq!(lens_spec_name([200.0, 80.0, 2.8, 2.8]), None); assert_eq!(lens_spec_name([f64::NAN, 50.0, 1.8, 1.8]), None); } #[test] fn numbers() { assert_eq!(parse_number("28/10"), Some(2.8)); assert_eq!(parse_number(" +0.7 "), Some(0.7)); assert_eq!(parse_number("1/0"), None); assert_eq!(parse_number("abc"), None); assert_eq!(parse_number("inf"), None); } #[test] fn exposure_display() { let mut m = Metadata { exposure_time: Some(1.0 / 250.0), ..Default::default() }; assert_eq!(m.exposure_display().as_deref(), Some("1/250")); m.exposure_time = Some(0.5); assert_eq!(m.exposure_display().as_deref(), Some("0.5")); m.exposure_time = Some(2.0); assert_eq!(m.exposure_display().as_deref(), Some("2″")); m.exposure_time = Some(0.0); assert_eq!(m.exposure_display(), None); } #[test] fn merge_rules() { let mut a = Metadata { make: Some("A".into()), rating: Some(1), ..Default::default() }; let b = Metadata { make: Some("B".into()), model: Some("M".into()), rating: Some(4), keywords: vec!["k".into()], ..Default::default() }; a.fill_missing(&b); assert_eq!(a.make.as_deref(), Some("A")); assert_eq!(a.model.as_deref(), Some("M")); assert_eq!(a.rating, Some(1)); a.overlay_user_fields(&b); assert_eq!(a.rating, Some(4)); assert_eq!(a.make.as_deref(), Some("A")); assert_eq!(a.keywords, vec!["k".to_string()]); } #[test] fn names() { let m = Metadata { metering_mode: Some(5), exposure_program: Some(3), ..Default::default() }; assert_eq!(m.metering_name(), Some("Pattern")); assert_eq!(m.exposure_program_name(), Some("Aperture-priority AE")); } }