From cbe9d8989a5852c1656276145dfe2e94ee4d6a80 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 30 Sep 2026 11:08:42 -0400 Subject: [PATCH] M3.1: lightcraft-meta EXIF/XMP/IPTC Metadata model; EXIF from TIFF/raw, JPEG APP1, PNG eXIf, WebP EXIF; XMP read (namespace-canonicalised, arrays, structs) and write (dc/xmp/ photoshop/exif/exifEX/tiff/lr + lc:settings JSON); IPTC-IIM record 2; container walkers for JPEG (incl. ICC and extended XMP), PNG, WebP. Adds quick-xml and miniz_oxide to workspace deps. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 17 + Cargo.toml | 4 + crates/meta/Cargo.toml | 8 + crates/meta/README.md | 41 +++ crates/meta/src/container.rs | 304 +++++++++++++++++ crates/meta/src/datetime.rs | 204 ++++++++++++ crates/meta/src/exif.rs | 255 +++++++++++++++ crates/meta/src/iptc.rs | 124 +++++++ crates/meta/src/lib.rs | 273 ++++++++++++++++ crates/meta/src/xmp.rs | 611 +++++++++++++++++++++++++++++++++++ crates/meta/tests/robust.rs | 36 +++ 11 files changed, 1877 insertions(+) create mode 100644 crates/meta/README.md create mode 100644 crates/meta/src/container.rs create mode 100644 crates/meta/src/datetime.rs create mode 100644 crates/meta/src/exif.rs create mode 100644 crates/meta/src/iptc.rs create mode 100644 crates/meta/src/xmp.rs create mode 100644 crates/meta/tests/robust.rs diff --git a/Cargo.lock b/Cargo.lock index d844d9f74..d6b9c0208 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -143,6 +143,14 @@ dependencies = [ [[package]] name = "lightcraft-meta" version = "0.1.0" +dependencies = [ + "lightcraft-geom", + "lightcraft-tiff", + "proptest", + "quick-xml", + "serde", + "serde_json", +] [[package]] name = "lightcraft-raster" @@ -238,6 +246,15 @@ version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" diff --git a/Cargo.toml b/Cargo.toml index 29847fce9..4344166a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,10 @@ rayon = "1" proptest = "1" bytemuck = { version = "1", features = ["derive"] } +# formats +quick-xml = "0.38" +miniz_oxide = "0.8" + # codecs image = { version = "0.25", default-features = false, features = ["png", "jpeg", "tiff", "webp"] } diff --git a/crates/meta/Cargo.toml b/crates/meta/Cargo.toml index 8161325b0..0ef8903d2 100644 --- a/crates/meta/Cargo.toml +++ b/crates/meta/Cargo.toml @@ -9,3 +9,11 @@ description = "LightCraft metadata: EXIF, XMP, IPTC (L1)" workspace = true [dependencies] +lightcraft-tiff = { workspace = true } +lightcraft-geom = { workspace = true } +serde = { workspace = true } +quick-xml = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/meta/README.md b/crates/meta/README.md new file mode 100644 index 000000000..83ee9e601 --- /dev/null +++ b/crates/meta/README.md @@ -0,0 +1,41 @@ +# lightcraft-meta (L1) + +Photo metadata: EXIF, XMP (read + write), IPTC-IIM, and the containers that carry them. + +## API + +| Function | Purpose | +|---|---| +| `read_exif(&[u8]) -> Metadata` | TIFF-structured Exif block (whole TIFF/DNG/raw file, or JPEG APP1 payload with/without `Exif\0\0`). `try_read_exif` reports header errors. | +| `from_tiff(&Tiff) -> Metadata` | Same, from an already parsed stream (used by `lightcraft-raw`). | +| `embedded(&[u8]) -> Embedded` | Sniffs JPEG / PNG / WebP / TIFF and returns the Exif, XMP (+ extended XMP), ICC and IPTC blocks. `jpeg_segments`, `png_chunks`, `webp_chunks` are the per-container walkers. | +| `parse_xmp(&str) -> Result` | Metadata + the opaque `lc:settings` JSON + every property (`prefix:name`, structs flattened as `a:b/c:d`). | +| `write_xmp(&Metadata, Option<&str>) -> String` | Complete `` packet; the second argument is LightCraft's full develop settings JSON. | +| `parse_iptc(&[u8]) -> Metadata` | IIM record 2: title (2:5), keywords (2:25), by-line (2:80), copyright (2:116), caption (2:120), date/time created (2:55/2:60). | +| `extract(&[u8]) -> Metadata` | Everything for a whole file. Precedence: EXIF → IPTC fills gaps → XMP overrides user fields (rating, label, title, caption, artist, copyright, keywords, GPS) and fills the rest. | + +`Metadata` carries camera (make, model, serial, software), lens (make, model, serial, specification), capture +(date-time with UTC offset and milliseconds, exposure time, f-number, ISO incl. extended ISO, focal length and +35 mm equivalent, flash, exposure bias, program, metering, white balance), orientation, GPS, dimensions and the +user fields (artist, copyright, title, caption, keywords, hierarchical keywords, rating −1…5, colour label). + +## XMP namespaces + +Written: `dc`, `xmp`, `photoshop`, `exif`, `exifEX`, `tiff`, `lr` (`hierarchicalSubject`) and ours, +`lc` = `http://ns.lightcraft.app/lc/1.0/` (`lc:settings`, an opaque JSON string). Reading canonicalises +prefixes by namespace URI, handles attribute and element forms, `rdf:Seq`/`Bag`/`Alt` (x-default first), +`rdf:parseType="Resource"` and nested-description structs, predefined and numeric entities. Nesting depth +and node count are limited. + +## Robustness + +No panics on malformed input: proptests feed random bytes to every container walker and random text to the +XMP parser; truncation tests cut a sample JPEG at every byte. + +## Sources + +CIPA DC-008 (Exif 2.32), TIFF 6.0, Adobe DNG 1.7 (tag numbers), ISO 16684-1 / Adobe XMP Specification +Parts 1–3 (packet, RDF serialisation, standard schemas, JPEG extended XMP), IPTC-IIM 4.2 and the IPTC Photo +Metadata Standard, ITU-T T.81 (JPEG markers), ICC.1 (APP2 embedding), PNG Specification 3rd ed. (`eXIf`, +`iTXt`), the WebP RIFF container specification, Adobe Photoshop File Format Specification (image resource +blocks). XML parsing: `quick-xml` (MIT). No third-party metadata code was consulted. diff --git a/crates/meta/src/container.rs b/crates/meta/src/container.rs new file mode 100644 index 000000000..87bf51149 --- /dev/null +++ b/crates/meta/src/container.rs @@ -0,0 +1,304 @@ +//! Locating metadata blocks inside image containers. +//! +//! - JPEG (ITU-T T.81 marker structure): APP1 `Exif\0\0`, APP1 `http://ns.adobe.com/xap/1.0/\0` (XMP), +//! APP1 `http://ns.adobe.com/xmp/extension/\0` (extended XMP, reassembled), APP2 `ICC_PROFILE\0` +//! (multi-chunk, reassembled in sequence order), APP13 `Photoshop 3.0\0` 8BIM resource 0x0404 (IPTC-IIM). +//! - PNG (PNG spec 3rd ed.): `eXIf`, `iTXt` with keyword `XML:com.adobe.xmp` (uncompressed). +//! - WebP (RIFF container spec): `EXIF`, `XMP `, `ICCP` chunks. +//! - TIFF-based files (TIFF, DNG and most raws): the whole file is the Exif block; XMP (700), ICC (34675) and +//! IPTC (33723) from IFD0. + +use lightcraft_tiff::{Tiff, tags}; + +/// Metadata blocks found in a file. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Embedded { + /// TIFF-structured Exif block (without the `Exif\0\0` prefix). + pub exif: Option>, + /// XMP packet (the main one; extended XMP is appended by [`Embedded::xmp_extended`]). + pub xmp: Option, + /// Extended XMP (JPEG only), reassembled. + pub xmp_extended: Option, + pub icc: Option>, + /// IPTC-IIM stream. + pub iptc: Option>, +} + +/// Sniff the container and collect its metadata blocks. Unknown containers yield an empty result. +pub fn embedded(bytes: &[u8]) -> Embedded { + if bytes.starts_with(&[0xff, 0xd8]) { + jpeg_segments(bytes) + } else if bytes.starts_with(b"\x89PNG\r\n\x1a\n") { + png_chunks(bytes) + } else if bytes.len() >= 12 && &bytes[..4] == b"RIFF" && &bytes[8..12] == b"WEBP" { + webp_chunks(bytes) + } else if let Ok(t) = Tiff::parse(bytes) { + let ifd0 = &t.ifds[0]; + Embedded { + exif: Some(bytes.to_vec()), + xmp: ifd0.bytes(tags::XMP).map(|b| String::from_utf8_lossy(b).into_owned()), + xmp_extended: None, + icc: ifd0.bytes(tags::ICC_PROFILE).map(|b| b.to_vec()), + iptc: ifd0.value(tags::IPTC_NAA).map(|v| match v { + lightcraft_tiff::Value::Byte(b) | lightcraft_tiff::Value::Undefined(b) => b.clone(), + other => lightcraft_tiff::writer::encode(t.order, other), + }), + } + } else { + Embedded::default() + } +} + +const XMP_SIG: &[u8] = b"http://ns.adobe.com/xap/1.0/\0"; +const XMP_EXT_SIG: &[u8] = b"http://ns.adobe.com/xmp/extension/\0"; + +/// Walk JPEG markers up to SOS and collect APP1 Exif / XMP, APP2 ICC and APP13 IPTC. +pub fn jpeg_segments(b: &[u8]) -> Embedded { + let mut out = Embedded::default(); + if !b.starts_with(&[0xff, 0xd8]) { + return out; + } + let mut icc_parts: Vec<(u8, Vec)> = Vec::new(); + let mut ext: Vec<(u32, Vec)> = Vec::new(); + let mut i = 2usize; + while i + 4 <= b.len() { + if b[i] != 0xff { + break; + } + let marker = b[i + 1]; + if marker == 0xff { + i += 1; // fill byte + continue; + } + if marker == 0xd8 || (0xd0..=0xd7).contains(&marker) || marker == 0x01 { + i += 2; + continue; + } + if marker == 0xd9 || marker == 0xda { + break; + } + let len = u16::from_be_bytes([b[i + 2], b[i + 3]]) as usize; + if len < 2 { + break; + } + let end = (i + 2 + len).min(b.len()); + let payload = &b[i + 4..end]; + match marker { + 0xe1 if payload.starts_with(b"Exif\0") && out.exif.is_none() => out.exif = Some(payload.get(6..).unwrap_or(&[]).to_vec()), + 0xe1 if payload.starts_with(XMP_SIG) && out.xmp.is_none() => { + out.xmp = Some(String::from_utf8_lossy(&payload[XMP_SIG.len()..]).into_owned()); + } + 0xe1 if payload.starts_with(XMP_EXT_SIG) => { + // GUID (32) + full length (4) + offset (4) + data + let p = &payload[XMP_EXT_SIG.len()..]; + if p.len() >= 40 { + let off = u32::from_be_bytes([p[36], p[37], p[38], p[39]]); + ext.push((off, p[40..].to_vec())); + } + } + 0xe2 if payload.starts_with(b"ICC_PROFILE\0") && payload.len() >= 14 => icc_parts.push((payload[12], payload[14..].to_vec())), + 0xed if payload.starts_with(b"Photoshop 3.0\0") && out.iptc.is_none() => out.iptc = photoshop_iptc(&payload[14..]), + _ => {} + } + i = end; + } + if !icc_parts.is_empty() { + icc_parts.sort_by_key(|p| p.0); + out.icc = Some(icc_parts.into_iter().flat_map(|p| p.1).collect()); + } + if !ext.is_empty() { + ext.sort_by_key(|p| p.0); + out.xmp_extended = Some(String::from_utf8_lossy(&ext.into_iter().flat_map(|p| p.1).collect::>()).into_owned()); + } + out +} + +/// IPTC-IIM block (resource 0x0404) from a Photoshop image-resource stream (`8BIM` records). +fn photoshop_iptc(mut b: &[u8]) -> Option> { + while b.len() >= 12 && &b[..4] == b"8BIM" { + let id = u16::from_be_bytes([b[4], b[5]]); + let name_len = b[6] as usize; + let name_total = (1 + name_len).next_multiple_of(2); + let size_at = 6 + name_total; + let size = u32::from_be_bytes(b.get(size_at..size_at + 4)?.try_into().ok()?) as usize; + let data_at = size_at + 4; + let data = b.get(data_at..data_at.checked_add(size)?)?; + if id == 0x0404 { + return Some(data.to_vec()); + } + b = b.get(data_at + size.next_multiple_of(2)..)?; + } + None +} + +/// PNG chunks: `eXIf`, uncompressed `iTXt` XMP. +pub fn png_chunks(b: &[u8]) -> Embedded { + let mut out = Embedded::default(); + let mut i = 8usize; + while i + 12 <= b.len() { + let len = u32::from_be_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize; + let typ = &b[i + 4..i + 8]; + let Some(data) = b.get(i + 8..(i + 8).saturating_add(len)) else { break }; + match typ { + b"eXIf" => out.exif = Some(crate::strip_exif_header(data).to_vec()), + b"iTXt" if data.starts_with(b"XML:com.adobe.xmp\0") => { + // keyword\0 compression-flag compression-method language\0 translated\0 text + let rest = &data[18..]; + if rest.len() >= 2 && rest[0] == 0 { + let rest = &rest[2..]; + let mut parts = rest.splitn(3, |&c| c == 0); + let (_, _, text) = (parts.next(), parts.next(), parts.next()); + if let Some(t) = text { + out.xmp = Some(String::from_utf8_lossy(t).into_owned()); + } + } + } + b"IEND" => break, + _ => {} + } + i += 12 + len; + } + out +} + +/// WebP RIFF chunks `EXIF`, `XMP `, `ICCP`. +pub fn webp_chunks(b: &[u8]) -> Embedded { + let mut out = Embedded::default(); + let mut i = 12usize; + while i + 8 <= b.len() { + let id = &b[i..i + 4]; + let len = u32::from_le_bytes([b[i + 4], b[i + 5], b[i + 6], b[i + 7]]) as usize; + let Some(data) = b.get(i + 8..(i + 8).saturating_add(len)) else { break }; + match id { + b"EXIF" => out.exif = Some(crate::strip_exif_header(data).to_vec()), + b"XMP " => out.xmp = Some(String::from_utf8_lossy(data).into_owned()), + b"ICCP" => out.icc = Some(data.to_vec()), + _ => {} + } + i += 8 + len + (len & 1); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::exif::tests::sample_exif; + use lightcraft_tiff::ByteOrder; + + fn seg(marker: u8, payload: &[u8]) -> Vec { + let mut v = vec![0xff, marker]; + v.extend_from_slice(&((payload.len() + 2) as u16).to_be_bytes()); + v.extend_from_slice(payload); + v + } + + pub(crate) fn sample_jpeg() -> Vec { + let mut j = vec![0xff, 0xd8]; + j.extend(seg(0xe0, b"JFIF\0\x01\x02\0\0\x01\0\x01\0\0")); + let mut exif = b"Exif\0\0".to_vec(); + exif.extend(sample_exif(ByteOrder::Big)); + j.extend(seg(0xe1, &exif)); + let meta = crate::Metadata { title: Some("Sunset".into()), rating: Some(4), ..Default::default() }; + let mut xmp = XMP_SIG.to_vec(); + xmp.extend(crate::write_xmp(&meta, Some("{\"exposure\":1.5}")).into_bytes()); + j.extend(seg(0xe1, &xmp)); + // ICC in two chunks, out of order + j.extend(seg(0xe2, b"ICC_PROFILE\0\x02\x02WORLD")); + j.extend(seg(0xe2, b"ICC_PROFILE\0\x01\x02HELLO")); + // IPTC + let iim = crate::iptc::tests::sample_iim(); + let mut ps = b"Photoshop 3.0\0".to_vec(); + ps.extend_from_slice(b"8BIM\x04\x0c\0\0"); + ps.extend_from_slice(&2u32.to_be_bytes()); + ps.extend_from_slice(&[1, 2]); + ps.extend_from_slice(b"8BIM\x04\x04\0\0"); + ps.extend_from_slice(&(iim.len() as u32).to_be_bytes()); + ps.extend_from_slice(&iim); + j.extend(seg(0xed, &ps)); + j.extend_from_slice(&[0xff, 0xda, 0, 2, 1, 2, 3, 0xff, 0xd9]); + j + } + + #[test] + fn jpeg_blocks() { + let j = sample_jpeg(); + let e = jpeg_segments(&j); + assert_eq!(e.icc.as_deref(), Some(&b"HELLOWORLD"[..])); + assert!(e.xmp.as_deref().unwrap().contains("Sunset")); + assert!(e.iptc.is_some()); + let m = crate::extract(&j); + assert_eq!(m.iso, Some(400)); + assert_eq!(m.title.as_deref(), Some("Sunset")); + assert_eq!(m.rating, Some(4)); // XMP overrides EXIF rating 3 + assert_eq!(m.keywords, vec!["alpha".to_string(), "beta".to_string()]); // from IPTC + assert_eq!(m.caption.as_deref(), Some("A caption")); + } + + #[test] + fn jpeg_extended_xmp() { + let mut j = vec![0xff, 0xd8]; + for (off, part) in [(5u32, &b"world"[..]), (0, b"hello")] { + let mut p = XMP_EXT_SIG.to_vec(); + p.extend_from_slice(&[b'A'; 32]); + p.extend_from_slice(&10u32.to_be_bytes()); + p.extend_from_slice(&off.to_be_bytes()); + p.extend_from_slice(part); + j.extend(seg(0xe1, &p)); + } + assert_eq!(jpeg_segments(&j).xmp_extended.as_deref(), Some("helloworld")); + } + + #[test] + fn png_and_webp() { + let exif = sample_exif(ByteOrder::Little); + let mut p = b"\x89PNG\r\n\x1a\n".to_vec(); + let mut chunk = |typ: &[u8], data: &[u8]| { + p.extend_from_slice(&(data.len() as u32).to_be_bytes()); + p.extend_from_slice(typ); + p.extend_from_slice(data); + p.extend_from_slice(&[0; 4]); + }; + chunk(b"eXIf", &exif); + let mut itxt = b"XML:com.adobe.xmp\0\0\0\0\0".to_vec(); + itxt.extend(crate::write_xmp(&crate::Metadata { label: Some("Red".into()), ..Default::default() }, None).into_bytes()); + chunk(b"iTXt", &itxt); + chunk(b"IEND", &[]); + let m = crate::extract(&p); + assert_eq!(m.iso, Some(400)); + assert_eq!(m.label.as_deref(), Some("Red")); + + let mut w = b"RIFF\0\0\0\0WEBP".to_vec(); + let mut body = |id: &[u8], data: &[u8]| { + w.extend_from_slice(id); + w.extend_from_slice(&(data.len() as u32).to_le_bytes()); + w.extend_from_slice(data); + if data.len() % 2 == 1 { + w.push(0); + } + }; + body(b"VP8X", &[0; 10]); + body(b"ICCP", b"abc"); + let mut ex = b"Exif\0\0".to_vec(); + ex.extend_from_slice(&exif); + body(b"EXIF", &ex); + let e = webp_chunks(&w); + assert_eq!(e.icc.as_deref(), Some(&b"abc"[..])); + assert_eq!(crate::extract(&w).f_number, Some(2.8)); + } + + #[test] + fn tiff_file_is_its_own_exif() { + let t = sample_exif(ByteOrder::Big); + assert_eq!(crate::extract(&t).model.as_deref(), Some("Model X")); + assert_eq!(embedded(b"not an image"), Embedded::default()); + } + + #[test] + fn truncated_containers_do_not_panic() { + let j = sample_jpeg(); + for n in 0..j.len() { + let _ = crate::extract(&j[..n]); + } + } +} diff --git a/crates/meta/src/datetime.rs b/crates/meta/src/datetime.rs new file mode 100644 index 000000000..ed77ee9ea --- /dev/null +++ b/crates/meta/src/datetime.rs @@ -0,0 +1,204 @@ +//! Capture date/time with optional UTC offset (Exif `DateTimeOriginal` + `OffsetTimeOriginal`, ISO 8601 in XMP). + +use serde::{Deserialize, Serialize}; + +/// A civil date-time as recorded by the camera; `offset_minutes` is the UTC offset when known. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] +pub struct DateTime { + pub year: u16, + pub month: u8, + pub day: u8, + pub hour: u8, + pub minute: u8, + pub second: u8, + /// Sub-second part in milliseconds. + pub millis: u16, + pub offset_minutes: Option, +} + +fn num(s: &str) -> Option { + if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + s.parse().ok() +} + +impl DateTime { + fn valid(self) -> Option { + let ok = (1..=12).contains(&self.month) + && (1..=31).contains(&self.day) + && self.hour < 24 + && self.minute < 60 + && self.second < 61 + && self.year > 0 + && self.offset_minutes.is_none_or(|o| o.abs() <= 18 * 60); + ok.then_some(self) + } + + /// Exif form `YYYY:MM:DD HH:MM:SS` (also tolerates `-` date separators and a missing time). + pub fn parse_exif(s: &str) -> Option { + let s = s.trim().trim_end_matches('\0'); + let (d, t) = s.split_once([' ', 'T']).unwrap_or((s, "")); + let dp: Vec<&str> = d.split([':', '-']).collect(); + if dp.len() != 3 { + return None; + } + let tp: Vec<&str> = if t.is_empty() { vec![] } else { t.split(':').collect() }; + let g = |v: &Vec<&str>, i: usize| v.get(i).map(|x| num(x.trim())).unwrap_or(Some(0)); + DateTime { + year: num(dp[0])?.try_into().ok()?, + month: num(dp[1])?.try_into().ok()?, + day: num(dp[2])?.try_into().ok()?, + hour: g(&tp, 0)?.try_into().ok()?, + minute: g(&tp, 1)?.try_into().ok()?, + second: g(&tp, 2)?.try_into().ok()?, + millis: 0, + offset_minutes: None, + } + .valid() + } + + /// Exif `OffsetTime*` form `+HH:MM` / `-HH:MM` (or `Z`). + pub fn parse_offset(s: &str) -> Option { + let s = s.trim().trim_end_matches('\0'); + if s == "Z" { + return Some(0); + } + let (sign, rest) = match s.as_bytes().first()? { + b'+' => (1, &s[1..]), + b'-' => (-1, &s[1..]), + _ => return None, + }; + let (h, m) = match rest.split_once(':') { + Some((h, m)) => (num(h)?, num(m)?), + None if rest.len() == 4 => (num(&rest[..2])?, num(&rest[2..])?), + None => (num(rest)?, 0), + }; + if h > 18 || m > 59 { + return None; + } + Some(sign * (h * 60 + m) as i16) + } + + /// Set the sub-second part from an Exif `SubSecTime*` string (digits are a decimal fraction). + pub fn with_subsec(mut self, s: &str) -> DateTime { + let digits: String = s.trim().chars().take_while(|c| c.is_ascii_digit()).take(3).collect(); + if !digits.is_empty() { + let v: u16 = digits.parse().unwrap_or(0); + self.millis = v * 10u16.pow(3 - digits.len() as u32); + } + self + } + + /// ISO 8601 (XMP date): `YYYY`, `YYYY-MM`, `YYYY-MM-DD`, `YYYY-MM-DDThh:mm[:ss[.s+]][TZD]`. + pub fn parse_iso(s: &str) -> Option { + let s = s.trim(); + let (d, t) = s.split_once('T').unwrap_or((s, "")); + let dp: Vec<&str> = d.split('-').collect(); + let year = num(dp.first()?)?.try_into().ok()?; + let month = dp.get(1).map(|m| num(m)).unwrap_or(Some(1))?.try_into().ok()?; + let day = dp.get(2).map(|m| num(m)).unwrap_or(Some(1))?.try_into().ok()?; + if dp.len() > 3 { + return None; + } + let mut dt = DateTime { year, month, day, ..Default::default() }; + if !t.is_empty() { + let (clock, off) = match t.find(['Z', '+', '-']) { + Some(i) => (&t[..i], Some(&t[i..])), + None => (t, None), + }; + let cp: Vec<&str> = clock.split(':').collect(); + dt.hour = num(cp.first()?)?.try_into().ok()?; + dt.minute = num(cp.get(1)?)?.try_into().ok()?; + if let Some(sec) = cp.get(2) { + let (whole, frac) = sec.split_once('.').unwrap_or((sec, "")); + dt.second = num(whole)?.try_into().ok()?; + dt = dt.with_subsec(frac); + } + if let Some(o) = off { + dt.offset_minutes = Some(Self::parse_offset(o)?); + } + } + dt.valid() + } + + /// Exif form `YYYY:MM:DD HH:MM:SS`. + pub fn to_exif(&self) -> String { + format!("{:04}:{:02}:{:02} {:02}:{:02}:{:02}", self.year, self.month, self.day, self.hour, self.minute, self.second) + } + + /// ISO 8601 with milliseconds when non-zero and the offset when known. + pub fn to_iso(&self) -> String { + let mut s = format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}", self.year, self.month, self.day, self.hour, self.minute, self.second); + if self.millis != 0 { + s += &format!(".{:03}", self.millis); + } + if let Some(o) = self.offset_minutes { + let sign = if o < 0 { '-' } else { '+' }; + s += &format!("{sign}{:02}:{:02}", o.abs() / 60, o.abs() % 60); + } + s + } + + /// Seconds since 1970-01-01T00:00:00 of the civil time (UTC when the offset is known, else "local" seconds). + pub fn unix_seconds(&self) -> i64 { + // days from civil (proleptic Gregorian), Howard Hinnant's public-domain algorithm + let (y, m, d) = (self.year as i64 - if self.month <= 2 { 1 } else { 0 }, self.month as i64, self.day as i64); + let era = y.div_euclid(400); + let yoe = y - era * 400; + let doy = (153 * (m + if m > 2 { -3 } else { 9 }) + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + let days = era * 146097 + doe - 719468; + days * 86400 + self.hour as i64 * 3600 + self.minute as i64 * 60 + self.second as i64 - self.offset_minutes.unwrap_or(0) as i64 * 60 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exif_forms() { + let d = DateTime::parse_exif("2024:06:01 13:45:59").unwrap(); + assert_eq!((d.year, d.month, d.day, d.hour, d.minute, d.second), (2024, 6, 1, 13, 45, 59)); + assert_eq!(d.to_exif(), "2024:06:01 13:45:59"); + assert!(DateTime::parse_exif("0000:00:00 00:00:00").is_none()); + assert!(DateTime::parse_exif(" : : : : ").is_none()); + assert!(DateTime::parse_exif("2024:13:01 00:00:00").is_none()); + assert_eq!(DateTime::parse_exif("2024-06-01").unwrap().hour, 0); + assert_eq!(DateTime::parse_exif("2024:06:01 13:45:59").unwrap().with_subsec("5").millis, 500); + assert_eq!(DateTime::parse_exif("2024:06:01 13:45:59").unwrap().with_subsec("0123").millis, 12); + } + + #[test] + fn offsets() { + assert_eq!(DateTime::parse_offset("+02:00"), Some(120)); + assert_eq!(DateTime::parse_offset("-05:30"), Some(-330)); + assert_eq!(DateTime::parse_offset("Z"), Some(0)); + assert_eq!(DateTime::parse_offset("+0930"), Some(570)); + assert_eq!(DateTime::parse_offset(" : "), None); + assert_eq!(DateTime::parse_offset("+99:00"), None); + } + + #[test] + fn iso_roundtrip() { + for s in ["2024-06-01T13:45:59", "2024-06-01T13:45:59.250+02:00", "1999-12-31T23:59:00-05:30", "2024-02-29T00:00:00Z"] { + let d = DateTime::parse_iso(s).unwrap(); + let again = DateTime::parse_iso(&d.to_iso()).unwrap(); + assert_eq!(d, again, "{s}"); + } + assert_eq!(DateTime::parse_iso("2024-02-29T00:00:00Z").unwrap().to_iso(), "2024-02-29T00:00:00+00:00"); + let d = DateTime::parse_iso("2019").unwrap(); + assert_eq!((d.year, d.month, d.day), (2019, 1, 1)); + assert_eq!(DateTime::parse_iso("2019-07-04T10:11").unwrap().minute, 11); + assert!(DateTime::parse_iso("nonsense").is_none()); + assert!(DateTime::parse_iso("2019-07-04Tab:cd").is_none()); + } + + #[test] + fn unix() { + assert_eq!(DateTime::parse_iso("1970-01-01T00:00:00Z").unwrap().unix_seconds(), 0); + assert_eq!(DateTime::parse_iso("2000-03-01T00:00:00Z").unwrap().unix_seconds(), 951868800); + assert_eq!(DateTime::parse_iso("2000-03-01T02:00:00+02:00").unwrap().unix_seconds(), 951868800); + } +} diff --git a/crates/meta/src/exif.rs b/crates/meta/src/exif.rs new file mode 100644 index 000000000..a480c0491 --- /dev/null +++ b/crates/meta/src/exif.rs @@ -0,0 +1,255 @@ +//! Exif / TIFF tags → [`Metadata`] (CIPA DC-008 Exif 2.32, TIFF 6.0, DNG 1.7 tag semantics). + +use crate::{DateTime, Flash, Gps, Metadata, Orientation}; +use lightcraft_tiff::{Ifd, Tiff, TiffError, tags as t}; + +/// Strip an `Exif\0\0` (or `Exif\0\xff`) prefix as found in JPEG APP1 and some WebP `EXIF` chunks. +pub fn strip_exif_header(b: &[u8]) -> &[u8] { + if b.len() >= 6 && &b[..4] == b"Exif" && b[4] == 0 { &b[6..] } else { b } +} + +/// Read metadata from a TIFF-structured Exif block (a whole TIFF/DNG/raw file, or the payload of a JPEG APP1 +/// Exif segment with or without the `Exif\0\0` prefix). Malformed input yields whatever could be read. +pub fn read_exif(bytes: &[u8]) -> Metadata { + try_read_exif(bytes).unwrap_or_default() +} + +/// Like [`read_exif`] but reports a structurally invalid TIFF header / IFD0. +pub fn try_read_exif(bytes: &[u8]) -> Result { + let tiff = Tiff::parse(strip_exif_header(bytes))?; + Ok(from_tiff(&tiff)) +} + +fn text(ifd: Option<&Ifd>, tag: u16) -> Option { + let s = ifd?.string(tag)?; + let s = s.trim().to_string(); + (!s.is_empty() && !s.bytes().all(|b| b == b' ' || b == 0)).then_some(s) +} + +fn pos(v: Option) -> Option { + v.filter(|x| x.is_finite() && *x > 0.0) +} + +/// Build [`Metadata`] from a parsed TIFF stream (IFD0 + Exif + GPS; DNG tags as fallbacks). +pub fn from_tiff(tiff: &Tiff) -> Metadata { + let ifd0 = tiff.ifds.first(); + let exif = tiff.exif(); + let gps = tiff.gps(); + let either = |tag: u16| exif.and_then(|e| e.value(tag)).or_else(|| ifd0.and_then(|i| i.value(tag))); + let mut m = Metadata { + make: text(ifd0, t::MAKE), + model: text(ifd0, t::MODEL).or_else(|| text(ifd0, t::UNIQUE_CAMERA_MODEL)), + software: text(ifd0, t::SOFTWARE), + artist: text(ifd0, t::ARTIST), + copyright: text(ifd0, t::COPYRIGHT), + caption: text(ifd0, t::IMAGE_DESCRIPTION), + serial_number: text(exif, t::BODY_SERIAL_NUMBER).or_else(|| text(ifd0, t::CAMERA_SERIAL_NUMBER)), + lens_make: text(exif, t::LENS_MAKE), + lens_model: text(exif, t::LENS_MODEL), + lens_serial_number: text(exif, t::LENS_SERIAL_NUMBER), + ..Default::default() + }; + // Exif requires LensSpecification/LensInfo as 4 rationals. + let spec = exif.and_then(|e| e.f64s(t::LENS_SPECIFICATION)).or_else(|| ifd0.and_then(|i| i.f64s(t::LENS_INFO))); + if let Some(s) = spec.filter(|s| s.len() == 4 && s[0] > 0.0) { + m.lens_spec = Some([s[0], s[1], s[2], s[3]]); + } + m.orientation = ifd0.and_then(|i| i.u16(t::ORIENTATION)).filter(|v| (1..=8).contains(v)).map(Orientation::from_exif); + m.rating = ifd0.and_then(|i| i.i64(t::RATING)).map(|r| r.clamp(-1, 5) as i8); + + m.exposure_time = pos(either(t::EXPOSURE_TIME).and_then(|v| v.get_f64(0))) + .or_else(|| exif.and_then(|e| e.f64(t::SHUTTER_SPEED_VALUE)).filter(|v| v.abs() < 64.0).map(|tv| 2f64.powf(-tv))); + m.f_number = pos(either(t::F_NUMBER).and_then(|v| v.get_f64(0))) + .or_else(|| exif.and_then(|e| e.f64(t::APERTURE_VALUE)).filter(|v| v.abs() < 64.0).map(|av| 2f64.powf(av / 2.0))); + let iso = either(t::ISO_SPEED).and_then(|v| v.get_u64(0)).filter(|&v| v > 0); + let iso_ext = exif.and_then(|e| e.u64(t::RECOMMENDED_EXPOSURE_INDEX).or_else(|| e.u64(t::ISO_SPEED_RATINGS_EXT))).filter(|&v| v > 0); + m.iso = match (iso, iso_ext) { + (Some(65535), Some(x)) | (None, Some(x)) => Some(x), + (Some(v), _) => Some(v), + _ => None, + } + .map(|v| v.min(u32::MAX as u64) as u32); + m.focal_length = pos(either(t::FOCAL_LENGTH).and_then(|v| v.get_f64(0))); + m.focal_length_35mm = pos(exif.and_then(|e| e.f64(t::FOCAL_LENGTH_35MM))); + m.exposure_bias = exif.and_then(|e| e.f64(t::EXPOSURE_BIAS)).filter(|v| v.is_finite() && v.abs() < 100.0); + m.exposure_program = exif.and_then(|e| e.u16(t::EXPOSURE_PROGRAM)); + m.metering_mode = exif.and_then(|e| e.u16(t::METERING_MODE)); + m.white_balance = exif.and_then(|e| e.u16(t::WHITE_BALANCE)); + m.flash = exif.and_then(|e| e.u16(t::FLASH)).map(|raw| Flash { fired: raw & 1 != 0, raw }); + + // capture time: DateTimeOriginal, then DateTimeDigitized, then IFD0 DateTime + let candidates = [ + (exif, t::DATE_TIME_ORIGINAL, t::OFFSET_TIME_ORIGINAL, t::SUBSEC_TIME_ORIGINAL), + (exif, t::DATE_TIME_DIGITIZED, t::OFFSET_TIME_DIGITIZED, t::SUBSEC_TIME_DIGITIZED), + (ifd0, t::DATE_TIME, t::OFFSET_TIME, t::SUBSEC_TIME), + ]; + for (ifd, dt_tag, off_tag, sub_tag) in candidates { + if let Some(mut dt) = text(ifd, dt_tag).and_then(|s| DateTime::parse_exif(&s)) { + if let Some(s) = text(exif, sub_tag) { + dt = dt.with_subsec(&s); + } + dt.offset_minutes = text(exif, off_tag).and_then(|s| DateTime::parse_offset(&s)); + m.capture_time = Some(dt); + break; + } + } + + m.width = exif.and_then(|e| e.u32(t::PIXEL_X_DIMENSION)).filter(|&v| v > 0).or_else(|| ifd0.and_then(|i| i.u32(t::IMAGE_WIDTH))); + m.height = exif.and_then(|e| e.u32(t::PIXEL_Y_DIMENSION)).filter(|&v| v > 0).or_else(|| ifd0.and_then(|i| i.u32(t::IMAGE_LENGTH))); + + m.gps = gps.and_then(read_gps); + if let Some(iptc) = ifd0.and_then(|i| i.value(t::IPTC_NAA)) { + // IPTC-NAA is often typed LONG; re-serialise to bytes in file order. + let bytes = match iptc { + lightcraft_tiff::Value::Byte(b) | lightcraft_tiff::Value::Undefined(b) => b.clone(), + other => lightcraft_tiff::writer::encode(tiff.order, other), + }; + m.fill_missing(&crate::parse_iptc(&bytes)); + } + if let Some(x) = ifd0.and_then(|i| i.bytes(t::XMP)).map(String::from_utf8_lossy) + && let Ok(x) = crate::parse_xmp(&x) + { + m.overlay_user_fields(&x.metadata); + m.fill_missing(&x.metadata); + } + m +} + +fn dms(ifd: &Ifd, tag: u16) -> Option { + let v = ifd.f64s(tag)?; + let d = *v.first()?; + let deg = d + v.get(1).copied().unwrap_or(0.0) / 60.0 + v.get(2).copied().unwrap_or(0.0) / 3600.0; + deg.is_finite().then_some(deg) +} + +fn read_gps(g: &Ifd) -> Option { + let mut lat = dms(g, t::GPS_LATITUDE)?; + let mut lon = dms(g, t::GPS_LONGITUDE)?; + if g.string(t::GPS_LATITUDE_REF).is_some_and(|s| s.starts_with('S')) { + lat = -lat; + } + if g.string(t::GPS_LONGITUDE_REF).is_some_and(|s| s.starts_with('W')) { + lon = -lon; + } + if !(-90.0..=90.0).contains(&lat) || !(-180.0..=180.0).contains(&lon) { + return None; + } + let altitude = g.f64(t::GPS_ALTITUDE).filter(|a| a.is_finite()).map(|a| if g.u32(t::GPS_ALTITUDE_REF) == Some(1) { -a } else { a }); + Some(Gps { latitude: lat, longitude: lon, altitude }) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use lightcraft_tiff::{ByteOrder, IfdBuilder, TiffWriter, Value}; + + pub(crate) fn sample_exif(order: ByteOrder) -> Vec { + let mut exif = IfdBuilder::new(); + exif.set(t::EXPOSURE_TIME, Value::Rational(vec![(1, 250)])); + exif.set(t::F_NUMBER, Value::Rational(vec![(28, 10)])); + exif.set(t::ISO_SPEED, Value::Short(vec![400])); + exif.set(t::DATE_TIME_ORIGINAL, Value::Ascii("2023:07:14 18:30:05".into())); + exif.set(t::OFFSET_TIME_ORIGINAL, Value::Ascii("+02:00".into())); + exif.set(t::SUBSEC_TIME_ORIGINAL, Value::Ascii("42".into())); + exif.set(t::EXPOSURE_BIAS, Value::SRational(vec![(-2, 3)])); + exif.set(t::METERING_MODE, Value::Short(vec![5])); + exif.set(t::FLASH, Value::Short(vec![0x19])); + exif.set(t::FOCAL_LENGTH, Value::Rational(vec![(50, 1)])); + exif.set(t::FOCAL_LENGTH_35MM, Value::Short(vec![75])); + exif.set(t::WHITE_BALANCE, Value::Short(vec![0])); + exif.set(t::EXPOSURE_PROGRAM, Value::Short(vec![3])); + exif.set(t::LENS_MAKE, Value::Ascii("LensCo".into())); + exif.set(t::LENS_MODEL, Value::Ascii("50mm F1.8".into())); + exif.set(t::BODY_SERIAL_NUMBER, Value::Ascii("12345".into())); + exif.set(t::LENS_SPECIFICATION, Value::Rational(vec![(50, 1), (50, 1), (18, 10), (18, 10)])); + exif.set(t::PIXEL_X_DIMENSION, Value::Long(vec![6000])); + exif.set(t::PIXEL_Y_DIMENSION, Value::Long(vec![4000])); + let mut gps = IfdBuilder::new(); + gps.set(t::GPS_LATITUDE_REF, Value::Ascii("S".into())); + gps.set(t::GPS_LATITUDE, Value::Rational(vec![(33, 1), (51, 1), (3564, 100)])); + gps.set(t::GPS_LONGITUDE_REF, Value::Ascii("E".into())); + gps.set(t::GPS_LONGITUDE, Value::Rational(vec![(151, 1), (12, 1), (4032, 100)])); + gps.set(t::GPS_ALTITUDE_REF, Value::Byte(vec![1])); + gps.set(t::GPS_ALTITUDE, Value::Rational(vec![(125, 10)])); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("Maker Inc.".into())); + ifd0.set(t::MODEL, Value::Ascii("Model X ".into())); + ifd0.set(t::ORIENTATION, Value::Short(vec![6])); + ifd0.set(t::ARTIST, Value::Ascii("Jane".into())); + ifd0.set(t::COPYRIGHT, Value::Ascii("(c) Jane".into())); + ifd0.set(t::RATING, Value::Short(vec![3])); + ifd0.set_child(t::EXIF_IFD, exif); + ifd0.set_child(t::GPS_IFD, gps); + TiffWriter::new(order, false).write(&[ifd0]).unwrap() + } + + #[test] + fn reads_all_fields() { + for order in [ByteOrder::Little, ByteOrder::Big] { + let m = read_exif(&sample_exif(order)); + assert_eq!(m.make.as_deref(), Some("Maker Inc.")); + assert_eq!(m.model.as_deref(), Some("Model X")); + assert_eq!(m.exposure_time, Some(0.004)); + assert_eq!(m.f_number, Some(2.8)); + assert_eq!(m.iso, Some(400)); + let dt = m.capture_time.unwrap(); + assert_eq!(dt.to_iso(), "2023-07-14T18:30:05.420+02:00"); + assert!((m.exposure_bias.unwrap() + 0.6667).abs() < 1e-3); + assert_eq!(m.metering_mode, Some(5)); + assert_eq!(m.flash, Some(Flash { fired: true, raw: 0x19 })); + assert_eq!(m.focal_length, Some(50.0)); + assert_eq!(m.focal_length_35mm, Some(75.0)); + assert_eq!(m.orientation, Some(Orientation::Rotate90)); + assert_eq!(m.lens_model.as_deref(), Some("50mm F1.8")); + assert_eq!(m.lens_make.as_deref(), Some("LensCo")); + assert_eq!(m.serial_number.as_deref(), Some("12345")); + assert_eq!(m.lens_spec, Some([50.0, 50.0, 1.8, 1.8])); + assert_eq!((m.width, m.height), (Some(6000), Some(4000))); + assert_eq!(m.artist.as_deref(), Some("Jane")); + assert_eq!(m.rating, Some(3)); + let g = m.gps.unwrap(); + assert!((g.latitude + 33.8599).abs() < 1e-4, "{g:?}"); + assert!((g.longitude - 151.2112).abs() < 1e-4); + assert_eq!(g.altitude, Some(-12.5)); + } + } + + #[test] + fn exif_prefix_and_garbage() { + let mut b = b"Exif\0\0".to_vec(); + b.extend(sample_exif(ByteOrder::Little)); + assert_eq!(read_exif(&b).iso, Some(400)); + assert_eq!(read_exif(b"garbage"), Metadata::default()); + assert!(try_read_exif(b"garbage").is_err()); + } + + #[test] + fn apex_fallbacks_and_extended_iso() { + let mut exif = IfdBuilder::new(); + exif.set(t::SHUTTER_SPEED_VALUE, Value::SRational(vec![(8, 1)])); + exif.set(t::APERTURE_VALUE, Value::Rational(vec![(4, 1)])); + exif.set(t::ISO_SPEED, Value::Short(vec![65535])); + exif.set(t::RECOMMENDED_EXPOSURE_INDEX, Value::Long(vec![102400])); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::DATE_TIME, Value::Ascii("2020:01:02 03:04:05".into())); + ifd0.set_child(t::EXIF_IFD, exif); + let m = read_exif(&TiffWriter::default().write(&[ifd0]).unwrap()); + assert_eq!(m.exposure_time, Some(1.0 / 256.0)); + assert_eq!(m.f_number, Some(4.0)); + assert_eq!(m.iso, Some(102400)); + assert_eq!(m.capture_time.unwrap().to_exif(), "2020:01:02 03:04:05"); + } + + #[test] + fn xmp_in_ifd0_overrides_user_fields() { + let mut meta = Metadata { rating: Some(5), title: Some("From XMP".into()), ..Default::default() }; + meta.keywords = vec!["a".into()]; + let x = crate::write_xmp(&meta, None); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::RATING, Value::Short(vec![1])); + ifd0.set(t::XMP, Value::Byte(x.into_bytes())); + let m = read_exif(&TiffWriter::default().write(&[ifd0]).unwrap()); + assert_eq!(m.rating, Some(5)); + assert_eq!(m.title.as_deref(), Some("From XMP")); + } +} diff --git a/crates/meta/src/iptc.rs b/crates/meta/src/iptc.rs new file mode 100644 index 000000000..24955748f --- /dev/null +++ b/crates/meta/src/iptc.rs @@ -0,0 +1,124 @@ +//! IPTC-IIM (Information Interchange Model 4.2) — the record-2 datasets people still carry in JPEGs. +//! +//! Stream of `0x1C record dataset size(u16 BE, or extended) data` tags. Text is UTF-8 when the record-1 +//! `CodedCharacterSet` (1:90) is `ESC % G`, otherwise decoded as UTF-8 when valid and Latin-1 otherwise. + +use crate::{DateTime, Metadata}; + +fn decode(b: &[u8], utf8: bool) -> String { + match std::str::from_utf8(b) { + Ok(s) => s.trim_end_matches('\0').trim().to_string(), + Err(_) if utf8 => String::from_utf8_lossy(b).trim().to_string(), + Err(_) => b.iter().map(|&c| c as char).collect::().trim().to_string(), + } +} + +/// Parse an IIM stream into the fields [`Metadata`] carries (title, keywords, artist, copyright, caption, +/// creation date/time). +pub fn parse_iptc(b: &[u8]) -> Metadata { + let mut m = Metadata::default(); + let mut utf8 = false; + let mut date: Option = None; + let mut time: Option = None; + let mut i = 0usize; + while i + 5 <= b.len() { + if b[i] != 0x1c { + break; + } + let (rec, ds) = (b[i + 1], b[i + 2]); + let mut size = u16::from_be_bytes([b[i + 3], b[i + 4]]) as usize; + let mut at = i + 5; + if size & 0x8000 != 0 { + // extended dataset: next (size & 0x7fff) bytes hold the length + let n = size & 0x7fff; + if n == 0 || n > 4 || at + n > b.len() { + break; + } + size = b[at..at + n].iter().fold(0usize, |a, &c| (a << 8) | c as usize); + at += n; + } + let Some(data) = b.get(at..at.saturating_add(size)) else { break }; + match (rec, ds) { + (1, 90) => utf8 = data == b"\x1b%G", + (2, 5) => m.title = Some(decode(data, utf8)).filter(|s| !s.is_empty()), + (2, 25) => { + let k = decode(data, utf8); + if !k.is_empty() && !m.keywords.contains(&k) { + m.keywords.push(k); + } + } + (2, 80) => m.artist = Some(decode(data, utf8)).filter(|s| !s.is_empty()), + (2, 116) => m.copyright = Some(decode(data, utf8)).filter(|s| !s.is_empty()), + (2, 120) => m.caption = Some(decode(data, utf8)).filter(|s| !s.is_empty()), + (2, 55) => date = Some(decode(data, utf8)), + (2, 60) => time = Some(decode(data, utf8)), + _ => {} + } + i = at + size; + } + if let Some(d) = date.filter(|d| d.len() == 8 && d.bytes().all(|c| c.is_ascii_digit())) { + let mut s = format!("{}-{}-{}", &d[..4], &d[4..6], &d[6..8]); + if let Some(t) = time.filter(|t| t.is_ascii() && t.len() >= 6 && t[..6].bytes().all(|c| c.is_ascii_digit())) { + s += &format!("T{}:{}:{}", &t[..2], &t[2..4], &t[4..6]); + if t.len() >= 11 { + s += &format!("{}:{}", &t[6..9], &t[9..11]); + } + } + m.capture_time = DateTime::parse_iso(&s); + } + m +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + + fn ds(out: &mut Vec, rec: u8, ds: u8, data: &[u8]) { + out.extend_from_slice(&[0x1c, rec, ds]); + out.extend_from_slice(&(data.len() as u16).to_be_bytes()); + out.extend_from_slice(data); + } + + pub(crate) fn sample_iim() -> Vec { + let mut v = Vec::new(); + ds(&mut v, 1, 90, b"\x1b%G"); + ds(&mut v, 2, 0, &[0, 4]); + ds(&mut v, 2, 5, "Tïtle".as_bytes()); + ds(&mut v, 2, 25, b"alpha"); + ds(&mut v, 2, 25, b"beta"); + ds(&mut v, 2, 25, b"alpha"); + ds(&mut v, 2, 80, b"Photographer"); + ds(&mut v, 2, 116, b"CC0"); + ds(&mut v, 2, 120, b"A caption"); + ds(&mut v, 2, 55, b"20210304"); + ds(&mut v, 2, 60, b"101112+0100"); + v + } + + #[test] + fn parses_record2() { + let m = parse_iptc(&sample_iim()); + assert_eq!(m.title.as_deref(), Some("Tïtle")); + assert_eq!(m.keywords, vec!["alpha".to_string(), "beta".to_string()]); + assert_eq!(m.artist.as_deref(), Some("Photographer")); + assert_eq!(m.copyright.as_deref(), Some("CC0")); + assert_eq!(m.caption.as_deref(), Some("A caption")); + assert_eq!(m.capture_time.unwrap().to_iso(), "2021-03-04T10:11:12+01:00"); + } + + #[test] + fn latin1_and_extended_and_garbage() { + let mut v = Vec::new(); + ds(&mut v, 2, 5, &[0x43, 0xe9]); // "Cé" in Latin-1 + v.extend_from_slice(&[0x1c, 2, 120, 0x80, 0x02, 0x00, 0x03]); + v.extend_from_slice(b"abc"); + let m = parse_iptc(&v); + assert_eq!(m.title.as_deref(), Some("Cé")); + assert_eq!(m.caption.as_deref(), Some("abc")); + let s = sample_iim(); + for n in 0..s.len() { + let _ = parse_iptc(&s[..n]); + } + assert_eq!(parse_iptc(&[0x1c, 2, 5, 0xff, 0xff]), Metadata::default()); + } +} diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index 45278f224..936fb21eb 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -1 +1,274 @@ +//! 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. +//! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw). #![forbid(unsafe_code)] + +mod container; +mod datetime; +mod exif; +mod iptc; +mod xmp; + +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}; +pub use iptc::parse_iptc; +pub use lightcraft_geom::Orientation; +pub use xmp::{LC_NS, XmpData, XmpError, parse_xmp, write_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, +} + +/// 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, + pub title: Option, + pub caption: 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, +} + +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, + title, + caption, + 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(); + } + } + + /// 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, gps); + if !other.keywords.is_empty() { + self.keywords = other.keywords.clone(); + } + if !other.hierarchical_keywords.is_empty() { + self.hierarchical_keywords = other.hierarchical_keywords.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 +} + +/// 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 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")); + } +} diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs new file mode 100644 index 000000000..3fa3c7e31 --- /dev/null +++ b/crates/meta/src/xmp.rs @@ -0,0 +1,611 @@ +//! XMP packets (ISO 16684-1 / Adobe XMP Specification Part 1–2): read and write. +//! +//! Reading builds a small namespace-resolved tree with `quick-xml`, then collects every property of every +//! `rdf:Description` (attribute form, element form, `rdf:Seq`/`rdf:Bag`/`rdf:Alt` arrays, `rdf:parseType="Resource"` +//! and nested-description structs, flattened as `parent/field`). Prefixes are canonicalised by namespace URI, so +//! a packet that binds `http://purl.org/dc/elements/1.1/` to `foo:` still yields `dc:title`. +//! +//! Writing emits the interchange subset in standard namespaces (dc, xmp, photoshop, exif, exifEX, tiff, lr) and +//! LightCraft's full develop settings as an opaque JSON string in `lc:settings`. + +use crate::{DateTime, Flash, Gps, Metadata, Orientation, parse_number}; +use quick_xml::escape::escape; +use quick_xml::events::Event; +use std::collections::BTreeMap; + +/// LightCraft's XMP namespace URI (prefix `lc`). +pub const LC_NS: &str = "http://ns.lightcraft.app/lc/1.0/"; + +const NAMESPACES: &[(&str, &str)] = &[ + ("x", "adobe:ns:meta/"), + ("rdf", "http://www.w3.org/1999/02/22-rdf-syntax-ns#"), + ("dc", "http://purl.org/dc/elements/1.1/"), + ("xmp", "http://ns.adobe.com/xap/1.0/"), + ("xmpRights", "http://ns.adobe.com/xap/1.0/rights/"), + ("photoshop", "http://ns.adobe.com/photoshop/1.0/"), + ("exif", "http://ns.adobe.com/exif/1.0/"), + ("exifEX", "http://cipa.jp/exif/1.0/"), + ("tiff", "http://ns.adobe.com/tiff/1.0/"), + ("aux", "http://ns.adobe.com/exif/1.0/aux/"), + ("lr", "http://ns.adobe.com/lightroom/1.0/"), + ("Iptc4xmpCore", "http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/"), + ("lc", LC_NS), +]; + +/// XMP read failure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum XmpError { + Xml(String), + /// Nesting / size limits exceeded. + TooComplex, +} + +impl std::fmt::Display for XmpError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + XmpError::Xml(e) => write!(f, "XMP is not well-formed XML: {e}"), + XmpError::TooComplex => write!(f, "XMP packet too deeply nested or too large"), + } + } +} + +impl std::error::Error for XmpError {} + +/// Result of [`parse_xmp`]. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct XmpData { + pub metadata: Metadata, + /// The opaque `lc:settings` JSON string, if present. + pub lc_settings: Option, + /// Every property found, keyed `prefix:name` (struct fields as `prefix:name/prefix:field`); arrays keep + /// item order, language alternatives put `x-default` first. + pub properties: BTreeMap>, +} + +#[derive(Debug, Default)] +struct Node { + name: String, + attrs: Vec<(String, String)>, + children: Vec, + text: String, +} + +impl Node { + fn attr(&self, n: &str) -> Option<&str> { + self.attrs.iter().find(|(k, _)| k == n).map(|(_, v)| v.as_str()) + } +} + +const MAX_DEPTH: usize = 64; +const MAX_NODES: usize = 200_000; + +fn canonical(qname: &str, scopes: &[Vec<(String, String)>]) -> String { + let (prefix, local) = qname.split_once(':').unwrap_or(("", qname)); + if prefix == "xml" || prefix == "xmlns" { + return qname.to_string(); + } + let uri = scopes.iter().rev().flat_map(|s| s.iter().rev()).find(|(p, _)| p == prefix).map(|(_, u)| u.as_str()); + match uri { + Some(u) => match NAMESPACES.iter().find(|(_, nu)| *nu == u) { + Some((p, _)) => format!("{p}:{local}"), + None if prefix.is_empty() => local.to_string(), + None => qname.to_string(), + }, + None => qname.to_string(), + } +} + +fn entity(name: &str) -> Option { + Some(match name { + "lt" => '<', + "gt" => '>', + "amp" => '&', + "quot" => '"', + "apos" => '\'', + _ => { + let n = name.strip_prefix('#')?; + let v = match n.strip_prefix(['x', 'X']) { + Some(h) => u32::from_str_radix(h, 16).ok()?, + None => n.parse().ok()?, + }; + char::from_u32(v)? + } + }) +} + +/// Unescape attribute values (predefined + numeric references; unknown references kept verbatim). +fn unescape_attr(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + let mut rest = s; + while let Some(i) = rest.find('&') { + out.push_str(&rest[..i]); + rest = &rest[i..]; + match rest.find(';').and_then(|j| entity(&rest[1..j]).map(|c| (c, j))) { + Some((c, j)) => { + out.push(c); + rest = &rest[j + 1..]; + } + None => { + out.push('&'); + rest = &rest[1..]; + } + } + } + out.push_str(rest); + out +} + +fn parse_tree(s: &str) -> Result { + let mut reader = quick_xml::Reader::from_str(s); + reader.config_mut().check_end_names = false; + let mut stack: Vec = vec![Node { name: "#root".into(), ..Default::default() }]; + let mut scopes: Vec> = vec![vec![("xml".into(), "http://www.w3.org/XML/1998/namespace".into())]]; + let mut count = 0usize; + loop { + let ev = reader.read_event().map_err(|e| XmpError::Xml(e.to_string()))?; + match ev { + Event::Start(ref e) | Event::Empty(ref e) => { + count += 1; + if count > MAX_NODES || stack.len() > MAX_DEPTH { + return Err(XmpError::TooComplex); + } + let mut scope = Vec::new(); + let mut raw_attrs = Vec::new(); + for a in e.attributes().with_checks(false).flatten() { + let k = String::from_utf8_lossy(a.key.as_ref()).into_owned(); + let v = unescape_attr(&String::from_utf8_lossy(&a.value)); + if k == "xmlns" { + scope.push((String::new(), v)); + } else if let Some(p) = k.strip_prefix("xmlns:") { + scope.push((p.to_string(), v)); + } else { + raw_attrs.push((k, v)); + } + } + scopes.push(scope); + let name = canonical(&String::from_utf8_lossy(e.name().as_ref()), &scopes); + let attrs = raw_attrs.into_iter().map(|(k, v)| (canonical(&k, &scopes), v)).collect(); + let node = Node { name, attrs, ..Default::default() }; + if matches!(ev, Event::Empty(_)) { + scopes.pop(); + if let Some(parent) = stack.last_mut() { + parent.children.push(node); + } + } else { + stack.push(node); + } + } + Event::End(_) => { + if stack.len() > 1 { + let n = stack.pop().expect("len > 1"); + scopes.pop(); + if let Some(parent) = stack.last_mut() { + parent.children.push(n); + } + } + } + Event::Text(t) => { + if let Some(top) = stack.last_mut() { + top.text.push_str(&t.decode().map_err(|e| XmpError::Xml(e.to_string()))?); + } + } + Event::CData(t) => { + if let Some(top) = stack.last_mut() { + top.text.push_str(&String::from_utf8_lossy(&t)); + } + } + Event::GeneralRef(r) => { + let name = String::from_utf8_lossy(&r).into_owned(); + if let Some(top) = stack.last_mut() { + match entity(&name) { + Some(c) => top.text.push(c), + None => { + top.text.push('&'); + top.text.push_str(&name); + top.text.push(';'); + } + } + } + } + Event::Eof => break, + _ => {} + } + } + // close unterminated elements leniently + while stack.len() > 1 { + let n = stack.pop().expect("len > 1"); + stack.last_mut().expect("root").children.push(n); + } + Ok(stack.pop().expect("root")) +} + +fn is_rdf_meta_attr(k: &str) -> bool { + matches!(k, "rdf:about" | "rdf:ID" | "rdf:nodeID" | "xml:lang" | "rdf:parseType" | "rdf:resource" | "rdf:datatype") || k.starts_with("xmlns") +} + +fn collect_description(d: &Node, prefix: &str, props: &mut BTreeMap>) { + for (k, v) in &d.attrs { + if !is_rdf_meta_attr(k) { + props.entry(format!("{prefix}{k}")).or_default().push(v.clone()); + } + } + for c in &d.children { + collect_property(c, prefix, props); + } +} + +fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap>) { + let key = format!("{prefix}{}", p.name); + if let Some(arr) = p.children.iter().find(|c| matches!(c.name.as_str(), "rdf:Seq" | "rdf:Bag" | "rdf:Alt")) { + let mut items: Vec<(bool, String)> = arr + .children + .iter() + .filter(|li| li.name == "rdf:li") + .map(|li| { + (li.attr("xml:lang") == Some("x-default"), li.attr("rdf:resource").map(str::to_string).unwrap_or_else(|| li.text.trim().to_string())) + }) + .collect(); + if arr.name == "rdf:Alt" { + items.sort_by_key(|(default, _)| !*default); + } + props.entry(key).or_default().extend(items.into_iter().map(|(_, v)| v)); + return; + } + let struct_prefix = format!("{key}/"); + if p.attr("rdf:parseType") == Some("Resource") { + for c in &p.children { + collect_property(c, &struct_prefix, props); + } + return; + } + if let Some(d) = p.children.iter().find(|c| c.name == "rdf:Description") { + collect_description(d, &struct_prefix, props); + return; + } + let field_attrs: Vec<&(String, String)> = p.attrs.iter().filter(|(k, _)| !is_rdf_meta_attr(k)).collect(); + if !field_attrs.is_empty() && p.children.is_empty() && p.text.trim().is_empty() { + for (k, v) in field_attrs { + props.entry(format!("{struct_prefix}{k}")).or_default().push(v.clone()); + } + return; + } + let v = p.attr("rdf:resource").map(str::to_string).unwrap_or_else(|| p.text.trim().to_string()); + props.entry(key).or_default().push(v); +} + +fn find_descriptions<'a>(n: &'a Node, out: &mut Vec<&'a Node>) { + if n.name == "rdf:Description" { + out.push(n); + return; + } + for c in &n.children { + find_descriptions(c, out); + } +} + +/// XMP GPS coordinate `DDD,MM,SSk` or `DDD,MM.mmk` (k = N/S/E/W) → signed degrees. +fn parse_gps_coord(s: &str) -> Option { + let s = s.trim(); + let dir = s.chars().last()?; + let body = &s[..s.len() - dir.len_utf8()]; + let parts: Vec = body.split(',').map(|p| p.trim().parse::().ok()).collect::>()?; + let v = match parts.as_slice() { + [d] => *d, + [d, m] => d + m / 60.0, + [d, m, sec] => d + m / 60.0 + sec / 3600.0, + _ => return None, + }; + let v = match dir.to_ascii_uppercase() { + 'N' | 'E' => v, + 'S' | 'W' => -v, + _ => return None, + }; + v.is_finite().then_some(v) +} + +fn fmt_gps_coord(v: f64, pos: char, neg: char) -> String { + let a = v.abs(); + let d = a.trunc(); + let m = (a - d) * 60.0; + format!("{},{:.6}{}", d as i64, m, if v < 0.0 { neg } else { pos }) +} + +/// Parse an XMP packet (with or without the `` wrapper / `x:xmpmeta` element). +pub fn parse_xmp(s: &str) -> Result { + let s = s.trim_start_matches('\u{feff}'); + let tree = parse_tree(s)?; + let mut descs = Vec::new(); + find_descriptions(&tree, &mut descs); + let mut props = BTreeMap::new(); + for d in descs { + collect_description(d, "", &mut props); + } + let first = |k: &str| props.get(k).and_then(|v| v.first()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + let num = |k: &str| first(k).and_then(|s| parse_number(&s)); + let mut m = Metadata { + make: first("tiff:Make"), + model: first("tiff:Model"), + serial_number: first("exifEX:BodySerialNumber").or_else(|| first("aux:SerialNumber")), + software: first("xmp:CreatorTool"), + lens_make: first("exifEX:LensMake"), + lens_model: first("exifEX:LensModel").or_else(|| first("aux:Lens")), + lens_serial_number: first("exifEX:LensSerialNumber").or_else(|| first("aux:LensSerialNumber")), + exposure_time: num("exif:ExposureTime").filter(|v| *v > 0.0), + f_number: num("exif:FNumber").filter(|v| *v > 0.0), + focal_length: num("exif:FocalLength").filter(|v| *v > 0.0), + focal_length_35mm: num("exif:FocalLengthIn35mmFilm").filter(|v| *v > 0.0), + exposure_bias: num("exif:ExposureBiasValue"), + exposure_program: num("exif:ExposureProgram").map(|v| v as u16), + metering_mode: num("exif:MeteringMode").map(|v| v as u16), + white_balance: num("exif:WhiteBalance").map(|v| v as u16), + orientation: num("tiff:Orientation").map(|v| v as u16).filter(|v| (1..=8).contains(v)).map(Orientation::from_exif), + width: num("tiff:ImageWidth").or_else(|| num("exif:PixelXDimension")).map(|v| v as u32), + height: num("tiff:ImageLength").or_else(|| num("exif:PixelYDimension")).map(|v| v as u32), + title: first("dc:title"), + caption: first("dc:description"), + copyright: first("dc:rights"), + rating: num("xmp:Rating").map(|v| v.round().clamp(-1.0, 5.0) as i8), + label: first("xmp:Label"), + ..Default::default() + }; + m.iso = num("exif:ISOSpeedRatings").or_else(|| num("exifEX:PhotographicSensitivity")).filter(|v| *v > 0.0).map(|v| v as u32); + if let Some(v) = props.get("exif:ISOSpeedRatings").and_then(|v| v.first()).and_then(|s| parse_number(s)) { + m.iso = Some(v as u32); + } + if let Some(spec) = props.get("exifEX:LensSpecification").filter(|v| v.len() == 4) { + let v: Vec = spec.iter().filter_map(|s| parse_number(s)).collect(); + if v.len() == 4 { + m.lens_spec = Some([v[0], v[1], v[2], v[3]]); + } + } + if let Some(creators) = props.get("dc:creator").filter(|c| !c.is_empty()) { + m.artist = Some(creators.join("; ")); + } + m.keywords = props.get("dc:subject").cloned().unwrap_or_default().into_iter().filter(|k| !k.is_empty()).collect(); + m.hierarchical_keywords = props.get("lr:hierarchicalSubject").cloned().unwrap_or_default().into_iter().filter(|k| !k.is_empty()).collect(); + m.capture_time = + ["exif:DateTimeOriginal", "photoshop:DateCreated", "xmp:CreateDate"].iter().find_map(|k| first(k).and_then(|s| DateTime::parse_iso(&s))); + if let Some(fired) = first("exif:Flash/exif:Fired") { + let b = |k: &str| first(k).is_some_and(|s| s.eq_ignore_ascii_case("true")); + let n = |k: &str| num(k).unwrap_or(0.0) as u16; + let fired = fired.eq_ignore_ascii_case("true"); + let raw = fired as u16 + | (n("exif:Flash/exif:Return") & 3) << 1 + | (n("exif:Flash/exif:Mode") & 3) << 3 + | (b("exif:Flash/exif:Function") as u16) << 5 + | (b("exif:Flash/exif:RedEyeMode") as u16) << 6; + m.flash = Some(Flash { fired, raw }); + } + if let (Some(lat), Some(lon)) = + (first("exif:GPSLatitude").and_then(|s| parse_gps_coord(&s)), first("exif:GPSLongitude").and_then(|s| parse_gps_coord(&s))) + { + let altitude = num("exif:GPSAltitude").map(|a| if first("exif:GPSAltitudeRef").as_deref() == Some("1") { -a } else { a }); + m.gps = Some(Gps { latitude: lat, longitude: lon, altitude }); + } + let lc_settings = props.get("lc:settings").and_then(|v| v.first()).cloned(); + Ok(XmpData { metadata: m, lc_settings, properties: props }) +} + +fn frac(v: f64) -> String { + if v > 0.0 && v < 1.0 { + let inv = 1.0 / v; + if (inv - inv.round()).abs() < 1e-6 { + return format!("1/{}", inv.round() as u64); + } + } + let (n, d) = lightcraft_tiff::writer::rational(v); + format!("{n}/{d}") +} + +fn sfrac(v: f64) -> String { + let (n, d) = lightcraft_tiff::writer::srational(v); + format!("{n}/{d}") +} + +/// Serialise the interchange subset of `meta` (plus `lc_settings`, an opaque JSON string, in `lc:settings`) +/// as a complete XMP packet. +pub fn write_xmp(meta: &Metadata, lc_settings: Option<&str>) -> String { + let mut simple: Vec<(&str, String)> = Vec::new(); + let mut push = |k: &'static str, v: Option| { + if let Some(v) = v { + simple.push((k, v)); + } + }; + push("tiff:Make", meta.make.clone()); + push("tiff:Model", meta.model.clone()); + push("tiff:Orientation", meta.orientation.map(|o| o.to_exif().to_string())); + push("tiff:ImageWidth", meta.width.map(|v| v.to_string())); + push("tiff:ImageLength", meta.height.map(|v| v.to_string())); + push("xmp:CreatorTool", meta.software.clone()); + push("xmp:Rating", meta.rating.map(|v| v.to_string())); + push("xmp:Label", meta.label.clone()); + push("exif:DateTimeOriginal", meta.capture_time.map(|d| d.to_iso())); + push("photoshop:DateCreated", meta.capture_time.map(|d| d.to_iso())); + push("exif:ExposureTime", meta.exposure_time.map(frac)); + push("exif:FNumber", meta.f_number.map(frac)); + push("exif:FocalLength", meta.focal_length.map(frac)); + push("exif:FocalLengthIn35mmFilm", meta.focal_length_35mm.map(|v| format!("{}", v.round() as i64))); + push("exif:ExposureBiasValue", meta.exposure_bias.map(sfrac)); + push("exif:ExposureProgram", meta.exposure_program.map(|v| v.to_string())); + push("exif:MeteringMode", meta.metering_mode.map(|v| v.to_string())); + push("exif:WhiteBalance", meta.white_balance.map(|v| v.to_string())); + push("exifEX:BodySerialNumber", meta.serial_number.clone()); + push("exifEX:LensMake", meta.lens_make.clone()); + push("exifEX:LensModel", meta.lens_model.clone()); + push("exifEX:LensSerialNumber", meta.lens_serial_number.clone()); + if let Some(g) = meta.gps { + push("exif:GPSLatitude", Some(fmt_gps_coord(g.latitude, 'N', 'S'))); + push("exif:GPSLongitude", Some(fmt_gps_coord(g.longitude, 'E', 'W'))); + if let Some(a) = g.altitude { + push("exif:GPSAltitude", Some(frac(a.abs()))); + push("exif:GPSAltitudeRef", Some(if a < 0.0 { "1" } else { "0" }.into())); + } + } + push("lc:settings", lc_settings.map(str::to_string)); + + let mut x = String::new(); + x.push_str("\n"); + x.push_str("\n"); + x.push_str(" \n"); + x.push_str(" \n"); + for (k, v) in &simple { + x.push_str(&format!(" <{k}>{}\n", escape(v.as_str()))); + } + let array = |x: &mut String, k: &str, kind: &str, items: &[String], lang: bool| { + if items.is_empty() { + return; + } + x.push_str(&format!(" <{k}>\n \n")); + for it in items { + if lang { + x.push_str(&format!(" {}\n", escape(it.as_str()))); + } else { + x.push_str(&format!(" {}\n", escape(it.as_str()))); + } + } + x.push_str(&format!(" \n \n")); + }; + let one = |v: &Option| v.iter().cloned().collect::>(); + array(&mut x, "dc:title", "Alt", &one(&meta.title), true); + array(&mut x, "dc:description", "Alt", &one(&meta.caption), true); + array(&mut x, "dc:rights", "Alt", &one(&meta.copyright), true); + array(&mut x, "dc:creator", "Seq", &one(&meta.artist), false); + array(&mut x, "dc:subject", "Bag", &meta.keywords, false); + array(&mut x, "lr:hierarchicalSubject", "Bag", &meta.hierarchical_keywords, false); + array(&mut x, "exif:ISOSpeedRatings", "Seq", &meta.iso.map(|v| vec![v.to_string()]).unwrap_or_default(), false); + if let Some(s) = meta.lens_spec { + array(&mut x, "exifEX:LensSpecification", "Seq", &s.iter().map(|&v| frac(v)).collect::>(), false); + } + if let Some(f) = meta.flash { + let r = f.raw; + let tf = |b: bool| if b { "True" } else { "False" }; + x.push_str(&format!( + " \n {}\n {}\n {}\n {}\n {}\n \n", + tf(f.fired), + (r >> 1) & 3, + (r >> 3) & 3, + tf(r & 0x20 != 0), + tf(r & 0x40 != 0) + )); + } + x.push_str(" \n \n\n"); + x +} + +#[cfg(test)] +mod tests { + use super::*; + + fn full() -> Metadata { + Metadata { + make: Some("Maker & Sons".into()), + model: Some("X <1>".into()), + serial_number: Some("SN1".into()), + software: Some("LightCraft".into()), + lens_make: Some("L".into()), + lens_model: Some("24-70mm".into()), + lens_serial_number: Some("LS".into()), + lens_spec: Some([24.0, 70.0, 2.8, 2.8]), + capture_time: DateTime::parse_iso("2022-05-06T07:08:09.123-04:00"), + exposure_time: Some(1.0 / 125.0), + f_number: Some(5.6), + iso: Some(800), + focal_length: Some(35.0), + focal_length_35mm: Some(52.0), + flash: Some(Flash { fired: true, raw: 0x59 }), + exposure_bias: Some(-1.0 / 3.0), + exposure_program: Some(2), + metering_mode: Some(3), + white_balance: Some(1), + orientation: Some(Orientation::Rotate270), + gps: Some(Gps { latitude: 48.858222, longitude: -2.2945, altitude: Some(-3.5) }), + width: Some(8000), + height: Some(6000), + artist: Some("Ann \"Photo\" Lee".into()), + copyright: Some("© 2022".into()), + title: Some("Tïtle".into()), + caption: Some("Line one\nline two".into()), + keywords: vec!["tree".into(), "sky & sea".into()], + hierarchical_keywords: vec!["Places|France|Paris".into()], + rating: Some(-1), + label: Some("Green".into()), + } + } + + #[test] + fn roundtrip_all_fields() { + let m = full(); + let json = r#"{"version":1,"exposure":0.5,"note":"<&>"}"#; + let x = write_xmp(&m, Some(json)); + let d = parse_xmp(&x).unwrap(); + assert_eq!(d.lc_settings.as_deref(), Some(json)); + let mut got = d.metadata.clone(); + // floats go through rationals + assert!((got.exposure_bias.unwrap() + 1.0 / 3.0).abs() < 1e-5); + got.exposure_bias = m.exposure_bias; + let g = got.gps.unwrap(); + assert!((g.latitude - 48.858222).abs() < 1e-6 && (g.longitude + 2.2945).abs() < 1e-6, "{g:?}"); + got.gps = m.gps; + assert_eq!(got, m); + } + + #[test] + fn empty_metadata_roundtrip() { + let x = write_xmp(&Metadata::default(), None); + let d = parse_xmp(&x).unwrap(); + assert_eq!(d.metadata, Metadata::default()); + assert_eq!(d.lc_settings, None); + } + + #[test] + fn attribute_form_foreign_prefixes_and_structs() { + let x = r#" + + TitelTitle & more é + + k1k2 + "#; + let d = parse_xmp(x).unwrap(); + let m = d.metadata; + assert_eq!(m.rating, Some(4)); + assert_eq!(m.label.as_deref(), Some("Blue")); + assert_eq!(m.f_number, Some(4.0)); + assert_eq!(m.title.as_deref(), Some("Title & more é")); + assert_eq!(m.keywords, vec!["k1".to_string(), "k2".to_string()]); + assert_eq!(m.flash, Some(Flash { fired: false, raw: 0x10 })); + let g = m.gps.unwrap(); + assert!((g.latitude - 10.5).abs() < 1e-9); + assert!((g.longitude + 20.26).abs() < 1e-9); + assert_eq!(d.properties["dc:title"], vec!["Title & more é".to_string(), "Titel".to_string()]); + } + + #[test] + fn malformed_inputs() { + assert!(parse_xmp("").is_ok() || parse_xmp("").is_err()); + assert_eq!(parse_xmp("").unwrap().metadata, Metadata::default()); + assert_eq!(parse_xmp("not xml at all").unwrap().metadata, Metadata::default()); + let deep = "".repeat(1000); + assert_eq!(parse_xmp(&deep), Err(XmpError::TooComplex)); + let x = write_xmp(&full(), Some("{}")); + for n in (0..x.len()).step_by(7) { + if x.is_char_boundary(n) { + let _ = parse_xmp(&x[..n]); + } + } + } + + #[test] + fn gps_coords() { + assert_eq!(parse_gps_coord("45,30N"), Some(45.5)); + assert_eq!(parse_gps_coord("45,30,36S"), Some(-45.51)); + assert_eq!(parse_gps_coord("45X"), None); + assert_eq!(parse_gps_coord(""), None); + assert_eq!(parse_gps_coord("é"), None); + assert_eq!(fmt_gps_coord(-45.51, 'N', 'S'), "45,30.600000S"); + } +} diff --git a/crates/meta/tests/robust.rs b/crates/meta/tests/robust.rs new file mode 100644 index 000000000..679fc49dc --- /dev/null +++ b/crates/meta/tests/robust.rs @@ -0,0 +1,36 @@ +use lightcraft_meta::{Metadata, extract, parse_iptc, parse_xmp, read_exif, write_xmp}; +use proptest::prelude::*; + +proptest! { + #![proptest_config(ProptestConfig { cases: 1500, .. ProptestConfig::default() })] + + #[test] + fn random_containers_never_panic(mut data in proptest::collection::vec(any::(), 0..600), kind in 0usize..5) { + let heads: [&[u8]; 5] = [b"\xff\xd8\xff\xe1", b"\x89PNG\r\n\x1a\n", b"RIFF\0\0\0\0WEBP", b"II*\0", b"MM\0*"]; + let h = heads[kind]; + if data.len() >= h.len() { + data[..h.len()].copy_from_slice(h); + } + let _ = extract(&data); + let _ = read_exif(&data); + let _ = parse_iptc(&data); + } + + #[test] + fn random_text_xmp_never_panics(s in ".{0,400}") { + let _ = parse_xmp(&s); + } + + #[test] + fn user_text_roundtrips(title in "[a-zA-Z0-9 &<>\"'éü€;|/\\\\]{1,40}", kw in proptest::collection::vec("[a-zA-Z0-9 &<>\"'|]{1,12}", 0..5), rating in -1i8..=5) { + let title = title.trim().to_string(); + prop_assume!(!title.is_empty()); + let kw: Vec = kw.into_iter().map(|k| k.trim().to_string()).filter(|k| !k.is_empty()).collect(); + let m = Metadata { title: Some(title.clone()), keywords: kw.clone(), rating: Some(rating), ..Default::default() }; + let d = parse_xmp(&write_xmp(&m, Some(&title))).unwrap(); + prop_assert_eq!(d.metadata.title, Some(title.clone())); + prop_assert_eq!(d.metadata.keywords, kw); + prop_assert_eq!(d.metadata.rating, Some(rating)); + prop_assert_eq!(d.lc_settings, Some(title)); + } +}