diff --git a/Cargo.lock b/Cargo.lock index 1e9d85ef..b531fce6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1275,6 +1275,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "craft-metadata" +version = "0.1.0" +dependencies = [ + "chrono", + "craft-tiff", + "proptest", + "quick-xml 0.41.0", + "serde", + "serde_json", +] + [[package]] name = "craft-metafile" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 507ab141..7059505c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -95,6 +95,7 @@ craft-text = { path = "crates/text" } craft-tga = { path = "crates/tga" } craft-timeline = { path = "crates/timeline" } craft-tiff = { path = "crates/tiff" } +craft-metadata = { path = "crates/metadata" } craft-ui = { path = "crates/ui" } craft-update = { path = "crates/update" } craft-vcard = { path = "crates/vcard" } diff --git a/README.md b/README.md index 3830d056..3776d0b5 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,10 @@ craft- = { git = "https://github.com/storytold/craft-libs", rev = "` form on craft-xml elements, value codecs and the package writer; and InDesign Tagged Text, read and written. - [`craft-timeline`](crates/timeline/README.md): editorial timeline interchange: exact media time and SMPTE timecode, import/export reports, media path and `file://` URL handling, Avid Log Exchange (ALE) and CMX 3600 EDL documents, format detection. From FilmCraft. +- [`craft-metadata`](crates/metadata/README.md): photo metadata, from LightCraft: EXIF (read and write, + maker-note lens and focus point, every tag by name), IPTC-IIM, XMP packets (read, write, and a merge + that keeps what another program wrote), GPX track logs, and the blocks that carry them in JPEG, PNG, + WebP, TIFF-based, RAF and CR3 files. One `Metadata` model for all of them. Built on `craft-tiff`. - [`craft-tiff`](crates/tiff/README.md): TIFF and BigTIFF IFD reader and writer, from LightCraft: every field type, the IFD chain, SubIFDs and Exif/GPS/Interop pointer IFDs, maker-note IFDs (Canon, Nikon, Sony, Fujifilm, Olympus, Panasonic, Pentax), strip and tile layout. Bounded and diff --git a/crates/metadata/Cargo.toml b/crates/metadata/Cargo.toml new file mode 100644 index 00000000..20749261 --- /dev/null +++ b/crates/metadata/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "craft-metadata" +description = "Photo metadata: EXIF, IPTC-IIM, XMP packets (read, write, merge) and GPX track logs, the blocks that carry them in JPEG, PNG, WebP, TIFF and CR3 files, and one model they all read into." +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +repository.workspace = true + +[lints] +workspace = true + +[dependencies] +craft-tiff = { workspace = true } +serde = { workspace = true } +quick-xml = "0.41" +chrono = { workspace = true } + +[dev-dependencies] +proptest = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/metadata/README.md b/crates/metadata/README.md new file mode 100644 index 00000000..1345ead5 --- /dev/null +++ b/crates/metadata/README.md @@ -0,0 +1,61 @@ +# craft-metadata + +Photo metadata: EXIF, IPTC-IIM, XMP packets and GPX track logs, the blocks that carry them in image +files, and one `Metadata` model they all read into. From LightCraft (`lightcraft-meta`). TIFF +structure comes from `craft-tiff`. + +One module per format; they share `Metadata`, `DateTime`, `Gps` and `Orientation`, which is why they +are one crate. + +| Function | Purpose | +|---|---| +| `read_exif(&[u8]) -> Metadata` | A TIFF-structured Exif block: a whole TIFF/DNG/raw file, or a JPEG APP1 payload with or without `Exif\0\0`. `try_read_exif` reports header errors, `try_read_exif_with` takes `craft_tiff::ParseOptions`. | +| `from_tiff(&Tiff) -> Metadata` | The same from an already parsed stream. | +| `write_exif(&Metadata) -> Vec` | An Exif block for an exported file. | +| `tags::tag_rows`, `tags::file_tag_rows` | Every tag with its name and value as text, maker notes included. | +| `focus::read`, `focus::read_header` | Where the camera focused, from the maker note (Sony), on the upright photo. `read_header` reads only the first kilobytes of a file through a callback. | +| `parse_iptc(&[u8]) -> Metadata` | IIM record 2: title (2:5), keywords (2:25), by-line (2:80), copyright (2:116), caption (2:120), date and time created (2:55, 2:60). | +| `parse_xmp(&str) -> Result` | `Metadata`, every property (`prefix:name`, structs flattened as `a:b/c:d`) and the structured values. `parse_xmp_with` also canonicalises the caller's own namespaces. | +| `write_xmp(&Metadata) -> String` | A complete `` packet. `write_xmp_with` sets the toolkit name and adds simple properties in the caller's namespace. | +| `merge_xmp(existing, fresh, MergeRules)` | Updates a packet another program wrote: owned properties are replaced, everything else stays byte for byte. | +| `parse_gpx(&str) -> Result` | GPX track points; `Tracklog::locate` gives the position at a capture time. | +| `embedded(&[u8]) -> Embedded` | Sniffs JPEG, PNG, WebP, TIFF-based files, Fujifilm RAF and Canon CR3 and returns the Exif, XMP (and extended XMP), ICC and IPTC blocks. `jpeg_segments`, `png_chunks`, `webp_chunks` are the walkers. | +| `cr3::parse_cr3` | The Canon CR3 container: `CMT1`…`CMT4`, the XMP `uuid` box, the thumbnail, the tracks. | +| `extract(&[u8]) -> Metadata` | All of it for a whole file. Precedence: EXIF, then IPTC fills gaps, then XMP overrides the user fields (rating, label, title, caption, artist, copyright, keywords, GPS) and fills the rest. `from_embedded` does the same for blocks already located. | + +HEIF, PSD and MRW files are read by `craft-heif`, `craft-psd` and `craft-raw`. Put the blocks they +return into an `Embedded` and call `from_embedded`. + +## Capture time + +EXIF candidates are tried in order: DateTimeOriginal, DateTimeDigitized, then IFD0 DateTime. A clock +with more than three colon-separated components is rejected as a whole, so the next candidate or the +XMP supplies the time. ISO 8601 times in XMP need a complete clock. Calendar checks go through +`chrono`. Invalid GPX point times count as untimed. + +## XMP namespaces + +Written: `dc`, `xmp`, `xmpRights`, `photoshop`, `exif`, `exifEX`, `tiff`, `aux`, `lr`, +`Iptc4xmpCore`, and the one namespace the caller passes. Read as well: `crs`, `mwg-rs` regions +(`stArea`, `stDim`). Reading canonicalises prefixes by namespace URI and handles attribute and element +forms, `rdf:Seq`/`Bag`/`Alt` (x-default first), `rdf:parseType="Resource"` and nested-description +structs, predefined and numeric entities. + +A foreign namespace that uses a recognised prefix cannot supply recognised properties. Its keys are +`Q{hex-encoded namespace URI}local`. Merging keeps the original foreign XML. + +## Limits + +XMP: 64 levels, 200 000 nodes, 1 MiB of flattened keys and 8 MiB of opaque names per packet +(`XmpError::TooComplex`). GPX: 64 levels, 5 000 000 points. CR3: 4096 boxes. JPEG ICC is returned only when its APP2 chunks form a +complete, consistent, uniquely numbered sequence. TIFF limits are `craft-tiff`'s. No input panics: +proptests feed random bytes to every container walker and random text to the XMP parser, and a sample +JPEG is cut at every byte. + +## Sources + +CIPA DC-008 (Exif 2.32), TIFF 6.0, Adobe DNG 1.7 (tag numbers), ISO 16684-1 and the Adobe XMP +Specification Parts 1 to 3, IPTC-IIM 4.2 and the IPTC Photo Metadata Standard, the MWG Region +Guidelines, ITU-T T.81 (JPEG markers), ICC.1 (APP2 embedding), the PNG Specification 3rd ed. (`eXIf`, +`iTXt`), the WebP RIFF container specification, ISO/IEC 14496-12 (box structure), GPX 1.1. XML parsing: +`quick-xml` (MIT). No third-party metadata code was consulted. diff --git a/crates/metadata/src/container.rs b/crates/metadata/src/container.rs new file mode 100644 index 00000000..66067758 --- /dev/null +++ b/crates/metadata/src/container.rs @@ -0,0 +1,450 @@ +//! 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. +//! - Canon CR3 (ISO base media file, see [`crate::cr3`]): `CMT1`/`CMT2`/`CMT4` merged into one Exif block; the +//! XMP `uuid` box. +//! +//! PSD, MRW and HEIF files are read by `craft-psd`, `craft-raw` and `craft-heif`. + +use craft_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(b"FUJIFILMCCD-RAW") { + // Fujifilm RAF: metadata lives in the embedded JPEG whose (offset, length) are big-endian u32s at byte 84 + let be = |i: usize| bytes.get(i..i + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]) as usize); + return match (be(84), be(88)) { + (Some(o), Some(l)) if o > 0 => bytes.get(o..o.saturating_add(l).min(bytes.len())).map(jpeg_segments).unwrap_or_default(), + _ => Embedded::default(), + }; + } + if let Some(c) = crate::cr3::parse_cr3(bytes) { + return Embedded { exif: crate::cr3::merged_exif(&c), xmp: c.xmp.map(|b| String::from_utf8_lossy(b).into_owned()), ..Default::default() }; + } + 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 { + craft_tiff::Value::Byte(b) | craft_tiff::Value::Undefined(b) => b.clone(), + other => craft_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: [Option<&[u8]>; 255] = [None; 255]; + let mut icc_total = None; + let mut icc_invalid = false; + 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 Some(start) = i.checked_add(4) else { break }; + let Some(declared_end) = i.checked_add(2).and_then(|at| at.checked_add(len)) else { break }; + let complete = declared_end <= b.len(); + let end = declared_end.min(b.len()); + let Some(payload) = b.get(start..end) else { break }; + 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") => { + if !complete || payload.len() < 14 { + icc_invalid = true; + } else if let Some(header) = payload.get(12..14) { + let (Some(&sequence), Some(&total)) = (header.first(), header.get(1)) else { + icc_invalid = true; + i = end; + continue; + }; + if sequence == 0 || total == 0 || sequence > total || icc_total.is_some_and(|expected| expected != total) { + icc_invalid = true; + } else { + icc_total = Some(total); + if let Some(slot) = icc_parts.get_mut(usize::from(sequence - 1)) { + if slot.is_some() { + icc_invalid = true; + } else if let Some(data) = payload.get(14..) { + *slot = Some(data); + } else { + icc_invalid = true; + } + } else { + icc_invalid = true; + } + } + } else { + icc_invalid = true; + } + } + 0xed if payload.starts_with(b"Photoshop 3.0\0") && out.iptc.is_none() => out.iptc = photoshop_iptc(&payload[14..]), + _ => {} + } + i = end; + } + if let Some(total) = icc_total { + let mut profile_len = 0usize; + for chunk in icc_parts.iter().take(usize::from(total)) { + let Some(bytes) = chunk else { + icc_invalid = true; + break; + }; + let Some(next_len) = profile_len.checked_add(bytes.len()) else { + icc_invalid = true; + break; + }; + profile_len = next_len; + } + let maximum_len = usize::from(total).checked_mul(65_519); + if maximum_len.is_none_or(|maximum| profile_len > maximum) { + icc_invalid = true; + } + if !icc_invalid { + let mut profile = Vec::with_capacity(profile_len); + for bytes in icc_parts.iter().take(usize::from(total)).flatten() { + profile.extend_from_slice(bytes); + } + out.icc = Some(profile); + } + } + 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 craft_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 + } + + fn icc_seg(sequence: u8, total: u8, data: &[u8]) -> Vec { + let mut payload = b"ICC_PROFILE\0".to_vec(); + payload.extend_from_slice(&[sequence, total]); + payload.extend_from_slice(data); + seg(0xe2, &payload) + } + + fn jpeg_with_icc(parts: &[Vec]) -> Vec { + let mut jpeg = vec![0xff, 0xd8]; + let packet = crate::write_xmp(&crate::Metadata { title: Some("retained".into()), ..Default::default() }); + let mut xmp = XMP_SIG.to_vec(); + xmp.extend_from_slice(packet.as_bytes()); + jpeg.extend_from_slice(&seg(0xe1, &xmp)); + for part in parts { + jpeg.extend_from_slice(part); + } + jpeg.extend_from_slice(&[0xff, 0xda, 0, 2]); + jpeg + } + + 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).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 jpeg_icc_accepts_reordered_chunks() { + let parts = [icc_seg(2, 2, b"world"), icc_seg(1, 2, b"hello")]; + let embedded = jpeg_segments(&jpeg_with_icc(&parts)); + assert_eq!(embedded.icc.as_deref(), Some(&b"helloworld"[..])); + } + + #[test] + fn jpeg_icc_rejects_incomplete_or_incoherent_chunks() { + let cases = [ + ("missing", vec![icc_seg(1, 2, b"helloworld")]), + ("duplicate", vec![icc_seg(1, 2, b"hello"), icc_seg(1, 2, b"world")]), + ("zero sequence", vec![icc_seg(0, 1, b"helloworld")]), + ("zero total", vec![icc_seg(1, 0, b"helloworld")]), + ("out of range", vec![icc_seg(2, 1, b"helloworld")]), + ("gap", vec![icc_seg(1, 3, b"hello"), icc_seg(3, 3, b"world")]), + ("inconsistent totals", vec![icc_seg(1, 2, b"hello"), icc_seg(2, 3, b"world")]), + ("short header", vec![seg(0xe2, b"ICC_PROFILE\0\x01")]), + ]; + for (name, parts) in cases { + let embedded = jpeg_segments(&jpeg_with_icc(&parts)); + assert!(embedded.icc.is_none(), "{name} ICC chunks must be rejected"); + assert!(embedded.xmp.as_deref().is_some_and(|x| x.contains("retained")), "{name}: unrelated XMP must remain readable"); + } + } + + #[test] + fn jpeg_icc_rejects_truncated_segment_and_keeps_other_metadata() { + let mut truncated = icc_seg(1, 1, b"profile"); + let declared = u16::from_be_bytes([truncated[2], truncated[3]]).saturating_add(1); + truncated[2..4].copy_from_slice(&declared.to_be_bytes()); + let mut jpeg = vec![0xff, 0xd8]; + let xmp = crate::write_xmp(&crate::Metadata { title: Some("kept".into()), ..Default::default() }); + let mut packet = XMP_SIG.to_vec(); + packet.extend_from_slice(xmp.as_bytes()); + jpeg.extend_from_slice(&seg(0xe1, &packet)); + jpeg.extend_from_slice(&truncated); + + let embedded = jpeg_segments(&jpeg); + assert!(embedded.icc.is_none()); + assert!(embedded.xmp.as_deref().is_some_and(|x| x.contains("kept"))); + } + + #[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() }).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 raf_reads_its_embedded_jpeg() { + let j = sample_jpeg(); + let mut raf = b"FUJIFILMCCD-RAW 0201FF000000TEST".to_vec(); + raf.resize(120, 0); + raf[84..88].copy_from_slice(&120u32.to_be_bytes()); + raf[88..92].copy_from_slice(&(j.len() as u32).to_be_bytes()); + raf.extend_from_slice(&j); + assert_eq!(crate::extract(&raf).iso, Some(400)); + for n in 0..raf.len() { + let _ = crate::extract(&raf[..n]); + } + } + + #[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/metadata/src/cr3.rs b/crates/metadata/src/cr3.rs new file mode 100644 index 00000000..50f3f28d --- /dev/null +++ b/crates/metadata/src/cr3.rs @@ -0,0 +1,742 @@ +//! Canon CR3: the ISO base media file container (ISO/IEC 14496-12 box structure; CR3 layout from Laurent +//! Clévy's public CR3 format notes, confirmed black-box on EOS R6 Mark III files). +//! +//! - `ftyp` with major brand `crx `. +//! - `moov` holds a Canon `uuid` box (85c0b687-820f-11e0-8111-f4ce462b6a48) whose children are `CMT1` (a TIFF +//! stream: IFD0), `CMT2` (TIFF: the Exif IFD), `CMT3` (TIFF: the Canon maker note), `CMT4` (TIFF: GPS) and the +//! `THMB` thumbnail, then one `trak` per stream: a full-size JPEG, a reduced raw, the full raw (`CRAW` sample +//! entries; raws carry a `CMP1` coding header) and timed metadata (`CTMD`). +//! - A top-level `uuid` box with the XMP uuid (be7acfcb-97a9-42e8-9c71-999491e3afac, XMP spec part 3) holds the +//! XMP packet. +//! +//! Every size and offset is checked against the buffer; nesting and box counts are bounded. + +/// A track's sample-entry kind. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Cr3TrackKind { + /// `CRAW` with a `JPEG` child: the full-size JPEG. + Jpeg, + /// `CRAW` raw image: dimensions and the `CMP1` coding header (byte range in the file). + Raw { + width: u16, + height: u16, + cmp1: Option<(usize, usize)>, + /// `CDI1/IAD1` image-area descriptor (byte range in the file). + iad1: Option<(usize, usize)>, + }, + /// Anything else (`CTMD` timed metadata, unknown entries). + Other([u8; 4]), +} + +/// One track's single sample: where it is in the file. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Cr3Track { + pub kind: Cr3TrackKind, + /// Byte offset and length of the sample (the first one) in the file, when the tables give them. + pub data: Option<(usize, usize)>, +} + +/// Canon's `CMP1` image coding descriptor, separate from the encoded track sample. +/// +/// Parsed from the payload (after the eight-byte box header). Geometry is the full sensor mosaic, +/// including optically masked borders. Unsupported encoding modes are retained for the decoder to +/// report explicitly, rather than confused with the full-size embedded JPEG. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Cr3Compression { + pub version: u16, + pub width: u32, + pub height: u32, + pub tile_width: u32, + pub tile_height: u32, + pub bit_depth: u8, + pub planes: u8, + /// 0 = RGGB, 1 = GRBG, 2 = GBRG, 3 = BGGR. + pub cfa_pattern: u8, + pub encoding: u8, + pub levels: u8, + pub tile_flags: u8, + /// Number of bytes at the start of the sample before its entropy-coded data. + pub header_size: u32, + pub median_bit_depth: Option, +} + +impl Cr3Compression { + /// Read the public `CMP1` field layout. Truncated or impossible descriptors return `None`. + pub fn parse(payload: &[u8]) -> Option { + // The first word is reserved in the public notes; real files use FF00, although older + // descriptions guessed FFFF. It is not a reliable format signature. + let header_len = usize::from(be16(payload, 2)?).checked_add(4)?; + if header_len < 52 || header_len > payload.len() { + return None; + } + let packed = *payload.get(25)?; + let coding = *payload.get(26)?; + let mut out = Self { + version: be16(payload, 4)?, + width: be32(payload, 8)?, + height: be32(payload, 12)?, + tile_width: be32(payload, 16)?, + tile_height: be32(payload, 20)?, + bit_depth: *payload.get(24)?, + planes: packed >> 4, + cfa_pattern: packed & 15, + encoding: coding >> 4, + levels: coding & 15, + tile_flags: *payload.get(27)?, + header_size: be32(payload, 28)?, + median_bit_depth: None, + }; + if out.width == 0 + || out.height == 0 + || out.tile_width == 0 + || out.tile_height == 0 + || out.tile_width > out.width + || out.tile_height > out.height + || !(1..=16).contains(&out.bit_depth) + || !(1..=4).contains(&out.planes) + || (out.planes > 1 && out.cfa_pattern > 3) + || out.header_size == 0 + { + return None; + } + if payload.get(32).is_some_and(|flags| flags & 0x80 != 0) { + // Extended CMP1: a four-byte length, flags, reserved bytes and six words. The optional + // median precision is used by type-3 burst encodings, not ordinary Bayer raw/craw. + let extension_len = usize::try_from(be32(payload, 52)?).ok()?; + let extension_end = 52usize.checked_add(extension_len)?; + if extension_len < 32 || extension_end > payload.len() { + return None; + } + if payload.get(56).is_some_and(|flags| flags & 0x40 != 0) { + let precision = *payload.get(84).filter(|_| extension_end > 84)?; + if !(1..=16).contains(&precision) { + return None; + } + out.median_bit_depth = Some(precision); + } + } + Some(out) + } +} + +/// `IAD1` geometry. Coordinates are inclusive sensor offsets: `[left, top, right, bottom]`. +/// A full raw descriptor distinguishes valid sensor pixels from the recommended final crop. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Cr3ImageArea { + pub width: u16, + pub height: u16, + pub crop: [u16; 4], + pub active: Option<[u16; 4]>, + pub masked_left: [u16; 4], + pub masked_top: Option<[u16; 4]>, +} + +impl Cr3ImageArea { + pub fn parse(payload: &[u8]) -> Option { + let bounds = |at: usize| Some([be16(payload, at)?, be16(payload, at + 2)?, be16(payload, at + 4)?, be16(payload, at + 6)?]); + let width = be16(payload, 4)?; + let height = be16(payload, 6)?; + if width == 0 || height == 0 { + return None; + } + let full = be16(payload, 10)? == 2; + Some(Self { + width, + height, + crop: bounds(16)?, + masked_left: bounds(24)?, + masked_top: if full { Some(bounds(32)?) } else { None }, + active: if full { Some(bounds(40)?) } else { None }, + }) + } +} + +impl Cr3Track { + /// Decode this track's image coding descriptor without reading its pixel data. + pub fn compression(&self, bytes: &[u8]) -> Option { + let Cr3TrackKind::Raw { cmp1: Some((at, len)), .. } = self.kind else { return None }; + Cr3Compression::parse(bytes.get(at..at.checked_add(len)?)?) + } + + pub fn image_area(&self, bytes: &[u8]) -> Option { + let Cr3TrackKind::Raw { iad1: Some((at, len)), .. } = self.kind else { return None }; + Cr3ImageArea::parse(bytes.get(at..at.checked_add(len)?)?) + } +} + +/// The parts of a CR3 file that are read. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Cr3<'a> { + /// `CMT1`…`CMT4` (index 0…3). + pub cmt: [Option<&'a [u8]>; 4], + pub thumbnail: Option<&'a [u8]>, + pub xmp: Option<&'a [u8]>, + pub tracks: Vec, +} + +/// One independent TIFF block from Canon timed metadata. Its offsets remain relative to this block. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Cr3TimedExif<'a> { + /// CTMD record type (7, 8 or 9). + pub record_type: u16, + /// Exif IFD (`0x8769`) or Canon maker note (`0x927c`). + pub tag: u32, + pub data: &'a [u8], +} + +impl Cr3<'_> { + /// Read TIFF blocks in the first samples of CTMD tracks. As-shot white balance and sensor + /// levels often live here rather than in the static `CMT3` maker note. Both record layers use + /// little-endian byte lengths; TIFF blocks retain their own byte order and offset origin. + pub fn timed_exif<'a>(&self, bytes: &'a [u8]) -> Vec> { + let mut out = Vec::new(); + let mut budget = MAX_BOXES; + for track in &self.tracks { + if track.kind != Cr3TrackKind::Other(*b"CTMD") { + continue; + } + let Some(sample) = track.data.and_then(|(at, len)| at.checked_add(len).and_then(|end| bytes.get(at..end))) else { continue }; + let mut at = 0usize; + while budget > 0 { + budget -= 1; + let Some(len) = le32(sample, at).and_then(|v| usize::try_from(v).ok()) else { break }; + let Some(end) = at.checked_add(len).filter(|&end| len >= 12 && end <= sample.len()) else { break }; + let Some(record) = sample.get(at..end) else { break }; + let Some(kind) = le16(record, 4) else { break }; + if matches!(kind, 7..=9) { + let mut item_at = 12usize; + while budget > 0 { + budget -= 1; + let Some(item_len) = le32(record, item_at).and_then(|v| usize::try_from(v).ok()) else { break }; + let Some(item_end) = item_at.checked_add(item_len).filter(|&end| item_len >= 8 && end <= record.len()) else { break }; + let Some(item) = record.get(item_at..item_end) else { break }; + if let Some(tag @ (0x8769 | 0x927c)) = le32(item, 4) + && let Some(data) = item.get(8..) + { + out.push(Cr3TimedExif { record_type: kind, tag, data }); + } + item_at = item_end; + } + } + at = end; + } + } + out + } +} + +const CANON_UUID: [u8; 16] = [0x85, 0xc0, 0xb6, 0x87, 0x82, 0x0f, 0x11, 0xe0, 0x81, 0x11, 0xf4, 0xce, 0x46, 0x2b, 0x6a, 0x48]; +/// The `uuid` of the top-level box holding the XMP packet. +pub const XMP_UUID: [u8; 16] = [0xbe, 0x7a, 0xcf, 0xcb, 0x97, 0xa9, 0x42, 0xe8, 0x9c, 0x71, 0x99, 0x94, 0x91, 0xe3, 0xaf, 0xac]; +const MAX_DEPTH: usize = 12; +pub(crate) const MAX_BOXES: usize = 4096; +/// A `VisualSampleEntry` (78 bytes after the box header) plus 4 bytes Canon adds before the child boxes. +const CRAW_CHILDREN_AT: usize = 82; + +/// Does this look like a CR3 file (`ftyp` box with major brand `crx `)? +pub fn is_cr3(bytes: &[u8]) -> bool { + bytes.get(4..12) == Some(b"ftypcrx ".as_slice()) +} + +/// Parse a CR3 file's boxes; `None` when it is not a CR3 file. Of the top-level boxes only `moov` +/// and the XMP `uuid` are read (a reader of a sparse file relies on it to skip the others). +pub fn parse_cr3(bytes: &[u8]) -> Option> { + if !is_cr3(bytes) { + return None; + } + let mut out = Cr3::default(); + let mut budget = MAX_BOXES; + for b in boxes(bytes, 0, bytes.len(), &mut budget) { + match &b.kind { + b"moov" => moov(bytes, &b, &mut out, &mut budget), + b"uuid" if b.uuid == Some(XMP_UUID) => out.xmp = bytes.get(b.body..b.end), + _ => {} + } + } + Some(out) +} + +pub(crate) struct BoxRef { + pub(crate) kind: [u8; 4], + /// Start of the payload (after the header and, for `uuid`, the 16-byte uuid). + pub(crate) body: usize, + pub(crate) end: usize, + pub(crate) uuid: Option<[u8; 16]>, +} + +/// The boxes directly inside `[from, to)`; stops at the first malformed header. +pub(crate) fn boxes(bytes: &[u8], from: usize, to: usize, budget: &mut usize) -> Vec { + let mut out = Vec::new(); + let mut at = from; + let to = to.min(bytes.len()); + while at.saturating_add(8) <= to && *budget > 0 { + *budget -= 1; + let (Some(size), Some(kind)) = (be32(bytes, at), bytes.get(at + 4..at + 8)) else { break }; + let mut kind4 = [0u8; 4]; + kind4.copy_from_slice(kind); + let (len, mut hdr) = match size { + 0 => (to - at, 8), + 1 => match be64(bytes, at + 8).and_then(|v| usize::try_from(v).ok()) { + Some(l) => (l, 16), + None => break, + }, + n => (n as usize, 8), + }; + let Some(end) = at.checked_add(len).filter(|&e| e <= to && len >= hdr) else { break }; + let mut uuid = None; + if &kind4 == b"uuid" { + let Some(u) = bytes.get(at + hdr..at + hdr + 16).filter(|_| at + hdr + 16 <= end) else { break }; + let mut a = [0u8; 16]; + a.copy_from_slice(u); + uuid = Some(a); + hdr += 16; + } + out.push(BoxRef { kind: kind4, body: at + hdr, end, uuid }); + at = end; + } + out +} + +fn moov<'a>(bytes: &'a [u8], m: &BoxRef, out: &mut Cr3<'a>, budget: &mut usize) { + for b in boxes(bytes, m.body, m.end, budget) { + match &b.kind { + b"uuid" if b.uuid == Some(CANON_UUID) => { + for c in boxes(bytes, b.body, b.end, budget) { + let slot = match &c.kind { + b"CMT1" => 0, + b"CMT2" => 1, + b"CMT3" => 2, + b"CMT4" => 3, + b"THMB" => { + out.thumbnail = bytes.get(c.body..c.end); + continue; + } + _ => continue, + }; + out.cmt[slot] = bytes.get(c.body..c.end); + } + } + b"trak" => { + if let Some(t) = trak(bytes, &b, budget) { + out.tracks.push(t); + } + } + _ => {} + } + } +} + +/// Descend `trak` → `mdia` → `minf` → `stbl` and read its sample entry and first sample's location. +fn trak(bytes: &[u8], t: &BoxRef, budget: &mut usize) -> Option { + let mut stbl = None; + let mut stack = vec![(t.body, t.end, 0usize)]; + while let Some((from, to, depth)) = stack.pop() { + if depth > MAX_DEPTH { + continue; + } + for b in boxes(bytes, from, to, budget) { + match &b.kind { + b"mdia" | b"minf" => stack.push((b.body, b.end, depth + 1)), + b"stbl" => stbl = Some(b), + _ => {} + } + } + } + let stbl = stbl?; + let (mut kind, mut size, mut offset) = (None, None, None); + for b in boxes(bytes, stbl.body, stbl.end, budget) { + let body = bytes.get(b.body..b.end)?; + match &b.kind { + // full box: version/flags, entry count, entries + b"stsd" if be32(body, 4)? > 0 => kind = boxes(bytes, b.body + 8, b.end, budget).first().map(|e| sample_entry(bytes, e, budget)), + // version/flags, sample_size (0 = per-sample table), count, sizes + b"stsz" => { + size = match be32(body, 4)? { + 0 if be32(body, 8)? > 0 => be32(body, 12), + 0 => None, + n if be32(body, 8)? > 0 => Some(n), + _ => None, + } + } + b"co64" if be32(body, 4)? > 0 => offset = be64(body, 8).and_then(|v| usize::try_from(v).ok()), + b"stco" if be32(body, 4)? > 0 => offset = be32(body, 8).map(|v| v as usize), + _ => {} + } + } + let data = match (offset, size) { + (Some(o), Some(s)) if o.checked_add(s as usize).is_some_and(|e| e <= bytes.len()) => Some((o, s as usize)), + _ => None, + }; + Some(Cr3Track { kind: kind?, data }) +} + +fn sample_entry(bytes: &[u8], e: &BoxRef, budget: &mut usize) -> Cr3TrackKind { + if &e.kind != b"CRAW" { + return Cr3TrackKind::Other(e.kind); + } + let Some(body) = bytes.get(e.body..e.end) else { return Cr3TrackKind::Other(e.kind) }; + let (Some(width), Some(height)) = (be16(body, 24), be16(body, 26)) else { + return Cr3TrackKind::Other(e.kind); + }; + let (mut cmp1, mut iad1, mut rendered) = (None, None, None); + for c in boxes(bytes, e.body.saturating_add(CRAW_CHILDREN_AT), e.end, budget) { + match &c.kind { + b"JPEG" => rendered = Some(Cr3TrackKind::Jpeg), + // HDR-capable cameras also put an HEVC preview in a CRAW entry, + // often with the same displayed dimensions as the main raw track. + b"HEVC" => rendered = Some(Cr3TrackKind::Other(c.kind)), + b"CMP1" => cmp1 = Some((c.body, c.end - c.body)), + b"CDI1" => { + for area in boxes(bytes, c.body.saturating_add(4), c.end, budget) { + if &area.kind == b"IAD1" { + iad1 = Some((area.body, area.end - area.body)); + } + } + } + _ => {} + } + } + // A present, malformed CMP1 must remain a raw descriptor so its corruption + // is reported. Only an explicitly identified rendered codec can establish + // that a CRAW entry without CMP1 is a preview rather than a broken raw. + if cmp1.is_none() + && let Some(kind) = rendered + { + return kind; + } + Cr3TrackKind::Raw { width, height, cmp1, iad1 } +} + +/// A big-endian `u16` at `at`, or `None` when the buffer is shorter than that. +pub(crate) fn be16(b: &[u8], at: usize) -> Option { + b.get(at..at.checked_add(2)?).map(|s| u16::from_be_bytes([s[0], s[1]])) +} +/// A big-endian `u32` at `at`, or `None` when the buffer is shorter than that. +pub(crate) fn be32(b: &[u8], at: usize) -> Option { + b.get(at..at.checked_add(4)?).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]])) +} +/// A big-endian `u64` at `at`, or `None` when the buffer is shorter than that. +pub(crate) fn be64(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(8)?)?; + let mut a = [0u8; 8]; + a.copy_from_slice(s); + Some(u64::from_be_bytes(a)) +} + +fn le16(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(2)?)?; + Some(u16::from_le_bytes([s[0], s[1]])) +} + +fn le32(b: &[u8], at: usize) -> Option { + let s = b.get(at..at.checked_add(4)?)?; + Some(u32::from_le_bytes([s[0], s[1], s[2], s[3]])) +} + +/// Merge `CMT1` (IFD0), `CMT2` (Exif) and `CMT4` (GPS) into one TIFF stream, so the Exif readers see a CR3 +/// like any TIFF-based raw. The maker note (`CMT3`) is left out: its offsets are relative to its own block. +pub fn merged_exif(c: &Cr3<'_>) -> Option> { + use craft_tiff::{IfdBuilder, Tiff, TiffWriter, tags as t}; + let ifd0 = Tiff::parse(c.cmt[0]?).ok()?; + let order = ifd0.order; + let first = |i: usize| c.cmt[i].and_then(|b| Tiff::parse(b).ok()).and_then(|t| t.ifds.into_iter().next()); + // pointers and offsets that would dangle once the IFDs move + const SKIP: [u16; 9] = [t::EXIF_IFD, t::GPS_IFD, t::INTEROP_IFD, t::SUB_IFDS, t::MAKER_NOTE, 273, 279, 513, 514]; + let build = |ifd: &craft_tiff::Ifd| { + let mut b = IfdBuilder::new(); + for e in ifd.entries.iter().filter(|e| !SKIP.contains(&e.tag)) { + b.set(e.tag, e.value.clone()); + } + b + }; + let mut root = build(ifd0.ifds.first()?); + if let Some(exif) = first(1) { + root.set_child(t::EXIF_IFD, build(&exif)); + } + if let Some(gps) = first(3) { + root.set_child(t::GPS_IFD, build(&gps)); + } + TiffWriter::new(order, false).write(&[root]).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn bx(kind: &[u8; 4], body: &[u8]) -> Vec { + let mut v = ((body.len() + 8) as u32).to_be_bytes().to_vec(); + v.extend_from_slice(kind); + v.extend_from_slice(body); + v + } + fn uuid(u: &[u8; 16], body: &[u8]) -> Vec { + let mut b = u.to_vec(); + b.extend_from_slice(body); + bx(b"uuid", &b) + } + fn full(kind: &[u8; 4], body: &[u8]) -> Vec { + let mut b = vec![0u8; 4]; + b.extend_from_slice(body); + bx(kind, &b) + } + fn craw(w: u16, h: u16, children: &[u8]) -> Vec { + let mut b = vec![0u8; CRAW_CHILDREN_AT]; + b[24..26].copy_from_slice(&w.to_be_bytes()); + b[26..28].copy_from_slice(&h.to_be_bytes()); + b.extend_from_slice(children); + bx(b"CRAW", &b) + } + fn trak(entry: Vec, offset: u64, size: u32) -> Vec { + let mut stsd = 1u32.to_be_bytes().to_vec(); + stsd.extend(entry); + let mut stsz = 0u32.to_be_bytes().to_vec(); + stsz.extend(1u32.to_be_bytes()); + stsz.extend(size.to_be_bytes()); + let mut co64 = 1u32.to_be_bytes().to_vec(); + co64.extend(offset.to_be_bytes()); + let stbl = [full(b"stsd", &stsd), full(b"stsz", &stsz), full(b"co64", &co64)].concat(); + bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &stbl)))) + } + fn tiff_with(tag: u16, v: craft_tiff::Value) -> Vec { + let b = craft_tiff::IfdBuilder::new().with(tag, v); + craft_tiff::TiffWriter::new(craft_tiff::ByteOrder::Little, false).write(&[b]).unwrap() + } + + /// A small synthetic CR3: Canon uuid with CMT1/CMT2/CMT4, a JPEG track, a raw track, XMP. + pub(crate) fn sample() -> Vec { + use craft_tiff::{Value, tags as t}; + let cmt1 = tiff_with(t::MODEL, Value::Ascii("Canon EOS Test".into())); + let cmt2 = tiff_with(t::ISO_SPEED, Value::Short(vec![400])); + let cmt4 = tiff_with(1, Value::Ascii("N".into())); + let canon = uuid(&CANON_UUID, &[bx(b"CMT1", &cmt1), bx(b"CMT2", &cmt2), bx(b"CMT4", &cmt4), bx(b"THMB", b"thumb")].concat()); + let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + let payload_at = 4096u64; + let jpeg_t = trak(craw(64, 48, &bx(b"JPEG", &[0; 4])), payload_at, 16); + let raw_t = trak(craw(60, 40, &bx(b"CMP1", &[0xff, 0x10, 0, 0x30])), payload_at + 16, 32); + file.extend(bx(b"moov", &[canon, jpeg_t, raw_t].concat())); + file.extend(uuid(&XMP_UUID, b"")); + file.resize(payload_at as usize + 64, 0); + file + } + + #[test] + fn parses_canon_boxes_tracks_and_xmp() { + let f = sample(); + assert!(is_cr3(&f)); + let c = parse_cr3(&f).unwrap(); + assert!(c.cmt[0].is_some() && c.cmt[1].is_some() && c.cmt[2].is_none() && c.cmt[3].is_some()); + assert_eq!(c.thumbnail, Some(b"thumb".as_slice())); + assert_eq!(c.xmp, Some(b"".as_slice())); + assert_eq!(c.tracks.len(), 2); + assert_eq!(c.tracks[0], Cr3Track { kind: Cr3TrackKind::Jpeg, data: Some((4096, 16)) }); + let Cr3TrackKind::Raw { width, height, cmp1: Some((at, len)), .. } = c.tracks[1].kind else { panic!("{:?}", c.tracks[1]) }; + assert_eq!((width, height, len), (60, 40, 4)); + assert_eq!(&f[at..at + 2], &[0xff, 0x10]); + assert_eq!(c.tracks[1].data, Some((4112, 32))); + } + + #[test] + fn hevc_preview_is_distinct_from_an_equal_size_raw_track() { + let preview = trak(craw(6000, 4000, &bx(b"HEVC", &[0; 4])), 4096, 16); + let reduced = trak(craw(1624, 1080, &bx(b"CMP1", &compression_payload(0))), 4112, 16); + let sensor = trak(craw(6000, 4000, &bx(b"CMP1", &compression_payload(0))), 4128, 16); + let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + file.extend(bx(b"moov", &[preview, reduced, sensor].concat())); + file.resize(4144, 0); + let container = parse_cr3(&file).unwrap(); + assert_eq!(container.tracks[0].kind, Cr3TrackKind::Other(*b"HEVC")); + assert!(container.tracks[0].compression(&file).is_none()); + assert!(matches!(container.tracks[2].kind, Cr3TrackKind::Raw { width: 6000, height: 4000, cmp1: Some(_), .. })); + assert_eq!(container.tracks[2].compression(&file).unwrap().width, 6888); + } + + #[test] + fn rendered_child_does_not_hide_a_malformed_raw_descriptor() { + for codec in [b"JPEG", b"HEVC"] { + for first in [true, false] { + let rendered = bx(codec, &[0; 4]); + let corrupt = bx(b"CMP1", &[0xff, 0]); + let children = if first { [rendered, corrupt] } else { [corrupt, rendered] }.concat(); + let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + file.extend(bx(b"moov", &trak(craw(6000, 4000, &children), 4096, 16))); + file.resize(4112, 0); + let container = parse_cr3(&file).unwrap(); + assert!(matches!(container.tracks[0].kind, Cr3TrackKind::Raw { cmp1: Some(_), .. })); + assert!(container.tracks[0].compression(&file).is_none()); + } + } + // Absence alone is not evidence of a preview: retain a broken raw so + // main-track selection cannot quietly fall back to a reduced image. + let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + file.extend(bx(b"moov", &trak(craw(6000, 4000, &bx(b"free", &[])), 4096, 16))); + file.resize(4112, 0); + assert!(matches!(parse_cr3(&file).unwrap().tracks[0].kind, Cr3TrackKind::Raw { cmp1: None, .. })); + } + + #[test] + fn merged_exif_reads_like_a_tiff_raw() { + let f = sample(); + let m = crate::read_exif(&merged_exif(&parse_cr3(&f).unwrap()).unwrap()); + assert_eq!(m.model.as_deref(), Some("Canon EOS Test")); + assert_eq!(m.iso, Some(400)); + } + + fn compression_payload(levels: u8) -> Vec { + let mut b = vec![0u8; 52]; + b[0..2].copy_from_slice(&0xff00u16.to_be_bytes()); + b[2..4].copy_from_slice(&48u16.to_be_bytes()); + b[4..6].copy_from_slice(&0x100u16.to_be_bytes()); + for (at, value) in [(8, 6888u32), (12, 4546), (16, 3444), (20, 4546), (28, 216)] { + b[at..at + 4].copy_from_slice(&value.to_be_bytes()); + } + b[24] = 14; + b[25] = 0x40; + b[26] = levels; + b + } + + #[test] + fn compression_descriptor_retains_sensor_geometry_and_cfa() { + let b = compression_payload(0); + let h = Cr3Compression::parse(&b).unwrap(); + assert_eq!((h.width, h.height, h.tile_width, h.tile_height), (6888, 4546, 3444, 4546)); + assert_eq!((h.version, h.bit_depth, h.planes, h.cfa_pattern, h.encoding, h.levels, h.header_size), (0x100, 14, 4, 0, 0, 0, 216)); + let mut craw = compression_payload(3); + craw[25] = 0x41; + craw[27] = 0x80; + let h = Cr3Compression::parse(&craw).unwrap(); + assert_eq!((h.cfa_pattern, h.levels, h.tile_flags), (1, 3, 0x80)); + for len in 0..b.len() { + assert!(Cr3Compression::parse(&b[..len]).is_none()); + } + for (at, value) in [(24, 0), (24, 17), (25, 0), (25, 0x44)] { + let mut bad = b.clone(); + bad[at] = value; + assert!(Cr3Compression::parse(&bad).is_none()); + } + } + + #[test] + fn extended_descriptor_checks_optional_median_precision() { + let mut b = compression_payload(0x30); + b[32] = 0x80; + b.extend_from_slice(&33u32.to_be_bytes()); + b.resize(85, 0); + b[56] = 0xc0; + b[84] = 14; + assert_eq!(Cr3Compression::parse(&b).unwrap().median_bit_depth, Some(14)); + assert!(Cr3Compression::parse(&b[..84]).is_none()); + b[84] = 0; + assert!(Cr3Compression::parse(&b).is_none()); + } + + #[test] + fn raw_track_reads_distinct_active_area_and_default_crop() { + let mut area = vec![0u8; 48]; + for (at, value) in [ + (4, 6888u16), + (6, 4546), + (10, 2), + (16, 156), + (18, 158), + (20, 6875), + (22, 4537), + (24, 0), + (26, 0), + (28, 143), + (30, 4545), + (32, 144), + (34, 0), + (36, 6887), + (38, 45), + (40, 144), + (42, 46), + (44, 6887), + (46, 4545), + ] { + area[at..at + 2].copy_from_slice(&value.to_be_bytes()); + } + let cmp1 = bx(b"CMP1", &compression_payload(0)); + let iad1 = full(b"CDI1", &bx(b"IAD1", &area)); + let track = trak(craw(6888, 4546, &[cmp1, iad1].concat()), 4096, 32); + let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + file.extend(bx(b"moov", &track)); + file.resize(4128, 0); + let c = parse_cr3(&file).unwrap(); + assert_eq!(c.tracks[0].compression(&file).unwrap().width, 6888); + let geometry = c.tracks[0].image_area(&file).unwrap(); + assert_eq!(geometry.crop, [156, 158, 6875, 4537]); + assert_eq!(geometry.active, Some([144, 46, 6887, 4545])); + assert_eq!(geometry.masked_left, [0, 0, 143, 4545]); + for len in 0..area.len() { + assert!(Cr3ImageArea::parse(&area[..len]).is_none()); + } + } + + #[test] + fn truncated_sample_table_does_not_read_the_next_box() { + let stbl = [ + full(b"stsd", &[1u32.to_be_bytes().to_vec(), craw(16, 16, &bx(b"CMP1", &compression_payload(0)))].concat()), + bx(b"stsz", &[]), + full(b"co64", &[1u32.to_be_bytes().to_vec(), 256u64.to_be_bytes().to_vec()].concat()), + ] + .concat(); + let mut file = bx(b"ftyp", b"crx \0\0\0\x01crx isom"); + file.extend(bx(b"moov", &bx(b"trak", &bx(b"mdia", &bx(b"minf", &bx(b"stbl", &stbl)))))); + file.resize(512, 0); + assert!(parse_cr3(&file).unwrap().tracks.is_empty()); + } + + #[test] + fn timed_metadata_exposes_the_dynamic_maker_note() { + use craft_tiff::{Value, tags as t}; + let exif = tiff_with(t::ISO_SPEED, Value::Short(vec![800])); + let maker = tiff_with(0x4001, Value::Short(vec![48, 100, 200])); + let item = |tag: u32, data: &[u8]| { + let mut b = ((data.len() + 8) as u32).to_le_bytes().to_vec(); + b.extend_from_slice(&tag.to_le_bytes()); + b.extend_from_slice(data); + b + }; + let items = [item(0x8769, &exif), item(0x927c, &maker)].concat(); + let mut sample = vec![0u8; 12]; + sample[0..4].copy_from_slice(&((12 + items.len()) as u32).to_le_bytes()); + sample[4..6].copy_from_slice(&8u16.to_le_bytes()); + sample.extend(items); + let container = Cr3 { tracks: vec![Cr3Track { kind: Cr3TrackKind::Other(*b"CTMD"), data: Some((0, sample.len())) }], ..Default::default() }; + let parts = container.timed_exif(&sample); + assert_eq!(parts.len(), 2); + assert_eq!((parts[0].record_type, parts[0].tag, parts[0].data), (8, 0x8769, exif.as_slice())); + assert_eq!((parts[1].record_type, parts[1].tag, parts[1].data), (8, 0x927c, maker.as_slice())); + for len in 0..sample.len() { + assert!(container.timed_exif(&sample[..len]).is_empty()); + } + for at in [0, 12] { + let mut corrupt = sample.clone(); + corrupt[at..at + 4].copy_from_slice(&u32::MAX.to_le_bytes()); + assert!(container.timed_exif(&corrupt).is_empty()); + corrupt[at..at + 4].copy_from_slice(&0u32.to_le_bytes()); + assert!(container.timed_exif(&corrupt).is_empty()); + } + } + + #[test] + fn hostile_input_never_panics() { + let f = sample(); + // every truncation and single-byte corruption parses to something or nothing + for n in 0..f.len().min(800) { + let _ = parse_cr3(&f[..n]).map(|c| merged_exif(&c)); + } + for i in 0..f.len().min(800) { + for v in [0u8, 1, 0x7f, 0xff] { + let mut g = f.clone(); + g[i] = v; + let _ = parse_cr3(&g).map(|c| merged_exif(&c)); + } + } + // a sample offset past the end is not reported + let mut g = f.clone(); + g.truncate(4100); + assert!(parse_cr3(&g).unwrap().tracks.iter().all(|t| t.data.is_none())); + } +} diff --git a/crates/metadata/src/datetime.rs b/crates/metadata/src/datetime.rs new file mode 100644 index 00000000..0ecedae3 --- /dev/null +++ b/crates/metadata/src/datetime.rs @@ -0,0 +1,320 @@ +//! 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 { + // A timestamp must represent a real Gregorian calendar day, not just + // numbers in the range 1..=31. Invalid dates would otherwise be + // accepted from EXIF/XMP and silently normalized during time arithmetic. + // chrono rejects impossible days (leap years and all); the time is checked + // by hand so a leap second (`second == 60`) stays acceptable. + let day_ok = self.year > 0 && chrono::NaiveDate::from_ymd_opt(i32::from(self.year), u32::from(self.month), u32::from(self.day)).is_some(); + let ok = day_ok && self.hour < 24 && self.minute < 60 && self.second < 61 && self.offset_minutes.is_none_or(|o| o.unsigned_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 (hour, minute, second) = if t.is_empty() { + (0, 0, 0) + } else { + let mut parts = t.split(':'); + let hour = num(parts.next()?.trim())?; + let minute = parts.next().map(|part| num(part.trim())).unwrap_or(Some(0))?; + let second = parts.next().map(|part| num(part.trim())).unwrap_or(Some(0))?; + // A fourth value cannot be represented; accepting only the prefix could turn a + // malformed DateTimeOriginal into a plausible capture time and block fallbacks. + if parts.next().is_some() { + return None; + } + (hour, minute, second) + }; + DateTime { + year: num(dp[0])?.try_into().ok()?, + month: num(dp[1])?.try_into().ok()?, + day: num(dp[2])?.try_into().ok()?, + hour: hour.try_into().ok()?, + minute: minute.try_into().ok()?, + second: second.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.get(..2)?)?, num(rest.get(2..)?)?), + None => (num(rest)?, 0), + }; + if h > 18 || m > 59 || (h == 18 && m > 0) { + 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) = match s.split_once('T') { + Some((d, t)) if !t.is_empty() => (d, t), + Some(_) => return None, + None => (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 mut cp = clock.split(':'); + dt.hour = num(cp.next()?)?.try_into().ok()?; + dt.minute = num(cp.next()?)?.try_into().ok()?; + if let Some(sec) = cp.next() { + let (whole, fraction) = match sec.split_once('.') { + Some((whole, fraction)) => { + if fraction.is_empty() || !fraction.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + (whole, Some(fraction)) + } + None => (sec, None), + }; + dt.second = num(whole)?.try_into().ok()?; + if let Some(fraction) = fraction { + dt = dt.with_subsec(fraction); + } + } + if cp.next().is_some() { + return None; + } + 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 { '+' }; + let o = o.unsigned_abs(); + s += &format!("{sign}{:02}:{:02}", o / 60, o % 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 { + // Every `DateTime` reaching here is a real calendar day (`valid()` ran in the parsers); + // an out-of-range one (built by hand) reads as the epoch rather than panicking. + let days = chrono::NaiveDate::from_ymd_opt(i32::from(self.year), u32::from(self.month), u32::from(self.day)) + .zip(chrono::NaiveDate::from_ymd_opt(1970, 1, 1)) + .map(|(d, epoch)| (d - epoch).num_days()) + .unwrap_or(0); + 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()); + // Invalid calendar days must never enter persisted capture metadata. + for value in ["2023:02:29", "2024:02:30", "2024:04:31", "1900:02:29", "2024:01:00"] { + assert!(DateTime::parse_exif(value).is_none(), "{value}"); + } + assert!(DateTime::parse_exif("2024:02:29").is_some()); + assert!(DateTime::parse_exif("2000:02:29").is_some()); + for value in ["2023-02-29", "2024-02-30", "1900-02-29", "2024-06-31T10:00:00Z"] { + assert!(DateTime::parse_iso(value).is_none(), "{value}"); + } + assert!(DateTime::parse_iso("2000-02-29T23:59:59Z").is_some()); + 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 exif_clock_rejects_extra_components() { + for value in + ["2026:01:01 12:34:56:99", "2026:01:01 12:34:56:junk", "2026:01:01 12:34:56:", "2026:01:01 12:34:56::", "2026:01:01 12:34:56:99:junk"] + { + assert!(DateTime::parse_exif(value).is_none(), "accepted malformed EXIF clock {value:?}"); + } + } + + #[test] + fn exif_clock_supported_forms_keep_their_meaning() { + for (value, expected) in [ + ("2026:01:01", "2026-01-01T00:00:00"), + ("2026:01:01 12", "2026-01-01T12:00:00"), + ("2026:01:01 12:34", "2026-01-01T12:34:00"), + ("2026:01:01 12:34:56", "2026-01-01T12:34:56"), + ("2026-01-01T12:34:56", "2026-01-01T12:34:56"), + (" 2026:01:01 12:34:56\0", "2026-01-01T12:34:56"), + ("2026:01:01T12:34:60", "2026-01-01T12:34:60"), + ] { + assert_eq!(DateTime::parse_exif(value).map(|date| date.to_iso()), Some(expected.to_string()), "{value:?}"); + } + } + + #[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("+18:00"), Some(1080)); + assert_eq!(DateTime::parse_offset("-18:00"), Some(-1080)); + assert_eq!(DateTime::parse_offset("+18:01"), None); + assert_eq!(DateTime::parse_offset("-18:01"), None); + assert_eq!(DateTime::parse_offset(" : "), None); + assert_eq!(DateTime::parse_offset("+99:00"), None); + } + + #[test] + fn extreme_offsets_do_not_overflow() { + // `DateTime` deserializes with public fields, so an offset can arrive without passing `valid()` + let d = DateTime { offset_minutes: Some(i16::MIN), ..DateTime::parse_iso("2024-06-01T13:45:59").unwrap() }; + assert_eq!(d.to_iso(), "2024-06-01T13:45:59-546:08"); + assert_eq!(d.valid(), None); + } + + #[test] + fn unicode_offsets_are_rejected_without_panicking() { + for offset in ["+1€", "-1€", "+€1", "-€1"] { + assert_eq!(DateTime::parse_offset(offset), None); + let date = format!("2024-06-01T13:45:59{offset}"); + let metadata = crate::Metadata { capture_time: DateTime::parse_iso("2024-06-01T13:45:59Z"), ..Default::default() }; + let xmp = crate::write_xmp(&metadata).replace("2024-06-01T13:45:59+00:00", &date); + assert!(crate::parse_xmp(&xmp).unwrap().metadata.capture_time.is_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 iso_rejects_incomplete_or_unconsumed_clock_text() { + for value in [ + "2026-01-01T", + "2026-01-01T12:34:56:99Z", + "2026-01-01T12:34:56.Z", + "2026-01-01T12:34:56.123junkZ", + "2026-01-01T12:34:56.1.2Z", + "2026-01-01T12:34:56.12 3Z", + "2026-01-01T12:34:56:99Z", + "2026-01-01T12::34Z", + ] { + assert!(DateTime::parse_iso(value).is_none(), "accepted malformed timestamp {value:?}"); + } + } + + #[test] + fn iso_validates_clock_while_preserving_supported_forms() { + assert_eq!(DateTime::parse_iso("2019").unwrap().to_iso(), "2019-01-01T00:00:00"); + assert_eq!(DateTime::parse_iso("2019-07").unwrap().to_iso(), "2019-07-01T00:00:00"); + assert_eq!(DateTime::parse_iso("2019-07-04").unwrap().to_iso(), "2019-07-04T00:00:00"); + assert_eq!(DateTime::parse_iso("2019-07-04T10:11").unwrap().to_iso(), "2019-07-04T10:11:00"); + assert_eq!(DateTime::parse_iso("2019-07-04T10:11:12").unwrap().to_iso(), "2019-07-04T10:11:12"); + assert_eq!(DateTime::parse_iso("2019-07-04T10:11:12Z").unwrap().offset_minutes, Some(0)); + assert_eq!(DateTime::parse_iso("2019-07-04T10:11:12-05:30").unwrap().offset_minutes, Some(-330)); + assert_eq!(DateTime::parse_iso("2019-07-04T10:11:12.123456Z").unwrap().millis, 123); + assert_eq!(DateTime::parse_iso(" 2019-07-04T10:11:12.5Z ").unwrap().millis, 500); + assert_eq!(DateTime::parse_iso("2019-07-04T10:11:60Z").unwrap().second, 60); + } + + #[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/metadata/src/exif.rs b/crates/metadata/src/exif.rs new file mode 100644 index 00000000..47c9cd19 --- /dev/null +++ b/crates/metadata/src/exif.rs @@ -0,0 +1,759 @@ +//! 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 craft_tiff::{Ifd, ParseOptions, 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 { + try_read_exif_with(bytes, &ParseOptions::default()) +} + +/// [`try_read_exif`] with the caller's parser options (its value allocation budget in particular). +/// A decoder working from a sparse buffer — only selected ranges of the logical file are actually +/// loaded, the rest is zeros — must not size its allocations by the logical file length, which +/// unrelated bytes of the file can inflate at no cost to the attacker. +pub fn try_read_exif_with(bytes: &[u8], opts: &ParseOptions) -> Result { + let data = strip_exif_header(bytes); + let tiff = Tiff::parse_with(data, opts)?; + let mut m = from_tiff(&tiff); + // Panasonic (and some Canons) write no LensModel, only the name in their maker note + if m.lens_model.is_none() { + m.lens_model = panasonic_maker_note_lens(data, &tiff).or_else(|| canon_maker_note_lens(data, &tiff)); + } + // the maker note's values are offsets into the stream, which `from_tiff` doesn't have + m.focus = crate::focus::read(data, &tiff, m.make.as_deref(), m.model.as_deref(), m.orientation); + Ok(m) +} + +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) +} + +/// The orientation the Exif's own first IFD states. [`from_tiff`] also reports one that only the XMP packet in +/// the Exif's tag 700 states; a viewer displays a photo by the Exif's alone, so that one can't tell how a photo +/// is stored. +pub(crate) fn ifd0_orientation(bytes: &[u8]) -> Option { + let tiff = Tiff::parse_with(strip_exif_header(bytes), &ParseOptions::default()).ok()?; + tiff.ifds.first().and_then(|i| i.u16(t::ORIENTATION)).filter(|v| (1..=8).contains(v)).map(Orientation::from_exif) +} + +/// 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]]); + } + // Nikon DSLRs write neither LensModel nor LensSpecification, only the same four values in their maker note + if m.lens_spec.is_none() { + m.lens_spec = nikon_lens_spec(exif, m.make.as_deref(), tiff.order); + } + 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 { + craft_tiff::Value::Byte(b) | craft_tiff::Value::Undefined(b) => b.clone(), + other => craft_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) + { + // (the size stays the one Exif states: `extract` turns regions by it, and a size only the XMP states may be the upright one) + let size = (m.width, m.height); + m.overlay_user_fields(&x.metadata); + m.fill_missing(&x.metadata); + (m.width, m.height) = size; + } + m +} + +/// Degrees/minutes/seconds triplet as decimal degrees. A component that is present but not a number (a rational +/// with a zero denominator, as in the placeholder GPS block phones write without a fix) makes the whole value unusable. +fn dms(ifd: &Ifd, tag: u16) -> Option { + let v = ifd.value(tag)?; + let part = |i: usize| if i < v.count() { v.get_f64(i) } else { Some(0.0) }; + let d = v.get_f64(0)?; + let deg = d + part(1)? / 60.0 + part(2)? / 3600.0; + deg.is_finite().then_some(deg) +} + +/// `Some(true)` for the negative hemisphere (`neg`), `Some(false)` for `pos`, `None` when the ref is missing or invalid. +fn hemisphere(g: &Ifd, tag: u16, pos: char, neg: char) -> Option { + match g.string(tag)?.trim_start().chars().next()?.to_ascii_uppercase() { + c if c == neg => Some(true), + c if c == pos => Some(false), + _ => None, + } +} + +fn read_gps(g: &Ifd) -> Option { + let mut lat = dms(g, t::GPS_LATITUDE)?; + let mut lon = dms(g, t::GPS_LONGITUDE)?; + if hemisphere(g, t::GPS_LATITUDE_REF, 'N', 'S')? { + lat = -lat; + } + if hemisphere(g, t::GPS_LONGITUDE_REF, 'E', '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 }) +} + +/// Panasonic maker note tag `0x0051` (LensType in the ExifTool Panasonic tag names): the lens name, ASCII. +const PANASONIC_LENS_TYPE: u16 = 0x0051; +/// RW2/RWL IFD0 tag `0x002E` (JpgFromRaw): a JPEG whose APP1 holds the full Exif, maker note included. +const PANASONIC_JPG_FROM_RAW: u16 = 0x002E; + +/// The lens name in the Panasonic maker note (tag `0x0051`) of this Exif or RW2 stream (`data` starts at its +/// TIFF header): the note of its own Exif IFD, else, for a raw, the note in its JpgFromRaw's Exif. +pub(crate) fn panasonic_maker_note_lens(data: &[u8], tiff: &Tiff) -> Option { + panasonic_lens(data, tiff).or_else(|| panasonic_jpg_from_raw_lens(tiff)) +} + +/// The lens name in a `Panasonic\0` maker note of this Exif stream (`data` starts at its TIFF header). +fn panasonic_lens(data: &[u8], tiff: &Tiff) -> Option { + let exif = tiff.exif()?; + if !exif.bytes(t::MAKER_NOTE)?.starts_with(b"Panasonic\0") { + return None; + } + let e = exif.get(t::MAKER_NOTE)?; + let make = tiff.ifds.first().and_then(|i| i.string(t::MAKE)).unwrap_or_default(); + let mn = craft_tiff::makernote::parse_makernote(data, e.offset, e.count() as u64, tiff.order, &make)?; + text(Some(&mn.ifd), PANASONIC_LENS_TYPE) +} + +/// The lens name from the Exif of a RW2's (or Leica RWL's) embedded JpgFromRaw. +fn panasonic_jpg_from_raw_lens(tiff: &Tiff) -> Option { + let jpg = tiff.ifds.first()?.bytes(PANASONIC_JPG_FROM_RAW)?; + let exif = crate::jpeg_segments(jpg).exif?; + let data = strip_exif_header(&exif); + panasonic_lens(data, &Tiff::parse(data).ok()?) +} + +/// Canon maker note tag `0x0095` (LensModel in the ExifTool Canon tag names): the lens name as ASCII. Older +/// bodies instead record a numeric LensType (CameraSettings, tag `0x0001`, index 22), which is no name to read. +const CANON_LENS_MODEL: u16 = 0x0095; + +/// The lens name in a Canon maker note of this Exif stream (`data` starts at its TIFF header): the note of +/// its own Exif IFD. Only consulted when Make is Canon, so other vendors' plain notes are never read as lenses. +fn canon_maker_note_lens(data: &[u8], tiff: &Tiff) -> Option { + let make = tiff.ifds.first().and_then(|i| i.string(t::MAKE)).unwrap_or_default(); + if !make.trim().to_ascii_uppercase().starts_with("CANON") { + return None; + } + let exif = tiff.exif()?; + let e = exif.get(t::MAKER_NOTE)?; + let mn = craft_tiff::makernote::parse_makernote(data, e.offset, e.count() as u64, tiff.order, &make)?; + text(Some(&mn.ifd), CANON_LENS_MODEL) +} + +/// Nikon maker note tag `0x0084` (Lens in the ExifTool Nikon tag names): shortest and longest focal length, then the +/// widest aperture at each, as rationals, like Exif LensSpecification. Zero for a lens that doesn't report itself. +const NIKON_LENS: u16 = 0x0084; + +/// The lens a Nikon camera records in its maker note, when it's plausible. +fn nikon_lens_spec(exif: Option<&Ifd>, make: Option<&str>, order: craft_tiff::ByteOrder) -> Option<[f64; 4]> { + let make = make?; + if !make.trim().to_ascii_uppercase().starts_with("NIKON") { + return None; + } + let note = exif?.bytes(t::MAKER_NOTE)?; + let mn = craft_tiff::makernote::parse_makernote(note, 0, note.len() as u64, order, make)?; + let s = mn.ifd.f64s(NIKON_LENS)?; + let [short, long, wide, narrow] = s.as_slice() else { return None }; + let focal = |f: f64| f.is_finite() && f > 0.0 && f < 10_000.0; + let aperture = |a: f64| a.is_finite() && a > 0.0 && a < 100.0; + (focal(*short) && focal(*long) && long >= short && aperture(*wide) && aperture(*narrow)).then_some([*short, *long, *wide, *narrow]) +} + +#[cfg(test)] +pub(crate) mod tests { + use super::*; + use craft_tiff::{ByteOrder, IfdBuilder, TiffWriter, Value}; + + fn capture_time_tiff(original: Option<&str>, digitized: Option<&str>, ifd0_time: Option<&str>, xmp: Option<&str>) -> Vec { + let mut exif = IfdBuilder::new(); + if let Some(value) = original { + exif.set(t::DATE_TIME_ORIGINAL, Value::Ascii(value.into())); + } + if let Some(value) = digitized { + exif.set(t::DATE_TIME_DIGITIZED, Value::Ascii(value.into())); + } + let mut ifd0 = IfdBuilder::new(); + if let Some(value) = ifd0_time { + ifd0.set(t::DATE_TIME, Value::Ascii(value.into())); + } + ifd0.set_child(t::EXIF_IFD, exif); + if let Some(packet) = xmp { + ifd0.set(t::XMP, Value::Byte(packet.as_bytes().to_vec())); + } + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() + } + + #[test] + fn exif_clock_invalid_original_uses_the_next_valid_exif_time() { + let invalid = "2026:01:01 12:34:56:99"; + let digitized = capture_time_tiff(Some(invalid), Some("2026:01:02 10:00:00"), Some("2026:01:03 11:00:00"), None); + assert_eq!(read_exif(&digitized).capture_time.map(|date| date.to_iso()), Some("2026-01-02T10:00:00".into())); + + let ifd0 = capture_time_tiff(Some(invalid), Some("2026:01:02 10:00:00:"), Some("2026:01:03 11:00:00"), None); + assert_eq!(read_exif(&ifd0).capture_time.map(|date| date.to_iso()), Some("2026-01-03T11:00:00".into())); + + let none = capture_time_tiff(Some(invalid), Some("2026:01:02 10:00:00:junk"), Some("2026:01:03 11:00:00:"), None); + assert_eq!(read_exif(&none).capture_time, None); + + let original = capture_time_tiff(Some("2026:01:04 12:00:00"), Some("2026:01:02 10:00:00"), Some("2026:01:03 11:00:00"), None); + assert_eq!(read_exif(&original).capture_time.map(|date| date.to_iso()), Some("2026-01-04T12:00:00".into())); + } + + #[test] + fn exif_clock_invalid_original_allows_xmp_fallback_and_keeps_raw_tag() { + let capture = DateTime::parse_iso("2026-01-02T10:00:00Z"); + let packet = crate::write_xmp(&Metadata { capture_time: capture, ..Default::default() }); + let bytes = capture_time_tiff(Some("2026:01:01 12:34:56:99"), None, None, Some(&packet)); + assert_eq!(crate::extract(&bytes).capture_time.map(|date| date.to_iso()), Some("2026-01-02T10:00:00+00:00".into())); + + let without_fallback = capture_time_tiff(Some("2026:01:01 12:34:56:junk"), None, None, None); + assert_eq!(crate::extract(&without_fallback).capture_time, None); + assert!(crate::embedded(&without_fallback).exif.is_some(), "rejecting the projection must retain the embedded EXIF block"); + } + + /// An Exif block from a Panasonic body: no LensModel, the lens name in maker note tag 0x0051, whose value + /// lies outside the note's IFD at an offset relative to the Exif TIFF header (as Panasonic writes it). + pub(crate) fn panasonic_exif(order: ByteOrder, lens: Option<&str>, lens_model: Option<&str>) -> Vec { + let note = |at: u32| { + let mut v = b"Panasonic\0\0\0".to_vec(); + order.put_u16(&mut v, 1); + order.put_u16(&mut v, if lens.is_some() { PANASONIC_LENS_TYPE } else { 0x0001 }); + order.put_u16(&mut v, 2); + let name = format!("{:<34}\0", lens.unwrap_or("0210")); + order.put_u32(&mut v, name.len() as u32); + order.put_u32(&mut v, at + 30); + order.put_u32(&mut v, 0); + v.extend_from_slice(name.as_bytes()); + v + }; + let build = |at: u32| { + let mut exif = IfdBuilder::new(); + exif.set(t::MAKER_NOTE, Value::Undefined(note(at))); + exif.set(t::FOCAL_LENGTH, Value::Rational(vec![(25, 1)])); + if let Some(l) = lens_model { + exif.set(t::LENS_MODEL, Value::Ascii(l.into())); + } + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("Panasonic".into())); + ifd0.set_child(t::EXIF_IFD, exif); + TiffWriter::new(order, false).write(&[ifd0]).unwrap() + }; + // the note's own offset is only known once it is laid out; its length doesn't change + let first = build(0); + let at = Tiff::parse(&first).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset as u32; + build(at) + } + + #[test] + fn panasonic_maker_note_names_the_lens() { + for order in [ByteOrder::Little, ByteOrder::Big] { + let m = read_exif(&panasonic_exif(order, Some("LUMIX G 25/F1.7"), None)); + assert_eq!(m.lens_model.as_deref(), Some("LUMIX G 25/F1.7")); + assert_eq!(m.focal_length, Some(25.0)); + } + // Exif's own LensModel wins, and a note without 0x0051 leaves the lens empty + let m = read_exif(&panasonic_exif(ByteOrder::Little, Some("LUMIX G 25/F1.7"), Some("Other 25mm"))); + assert_eq!(m.lens_model.as_deref(), Some("Other 25mm")); + assert_eq!(read_exif(&panasonic_exif(ByteOrder::Little, None, None)).lens_model, None); + } + + /// A RW2 has the full Exif only in the APP1 of its JpgFromRaw (IFD0 0x002E), not in its own Exif IFD. + pub(crate) fn panasonic_rw2(lens: &str) -> Vec { + let mut jpg = vec![0xff, 0xd8, 0xff, 0xe1]; + let mut app1 = b"Exif\0\0".to_vec(); + app1.extend(panasonic_exif(ByteOrder::Little, Some(lens), None)); + jpg.extend_from_slice(&((app1.len() + 2) as u16).to_be_bytes()); + jpg.extend(app1); + jpg.extend_from_slice(&[0xff, 0xda, 0, 2, 0xff, 0xd9]); + let mut exif = IfdBuilder::new(); + exif.set(t::FOCAL_LENGTH, Value::Rational(vec![(85, 1)])); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("Panasonic".into())); + ifd0.set(PANASONIC_JPG_FROM_RAW, Value::Undefined(jpg)); + ifd0.set_child(t::EXIF_IFD, exif); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() + } + + #[test] + fn rw2_reads_the_lens_from_its_jpg_from_raw() { + let rw2 = panasonic_rw2("LUMIX S 85/F1.8"); + let m = read_exif(&rw2); + assert_eq!(m.lens_model.as_deref(), Some("LUMIX S 85/F1.8")); + assert_eq!(m.focal_length, Some(85.0)); + assert_eq!(crate::extract(&rw2).lens_model.as_deref(), Some("LUMIX S 85/F1.8")); + } + + /// Canon maker note tags: `0x0095` holds the lens name as ASCII; `0x0001` is CameraSettings, a table of + /// int16s whose index 22 is the numeric LensType. + const CANON_LENS_MODEL_TAG: u16 = 0x0095; + const CANON_CAMERA_SETTINGS: u16 = 0x0001; + + /// An Exif block from a Canon EOS 5D: `Canon` / `Canon EOS 5D` in IFD0, no LensModel tag, and a plain maker + /// note whose IFD (value offsets relative to the enclosing TIFF header, as Canon writes it) records + /// CameraSettings with the numeric LensType at index 22 and, when `lens` is given, the name at `0x0095`. + fn canon_exif(order: ByteOrder, lens: Option<&str>) -> Vec { + let note = |at: u32| { + // The plain IFD: entry count, one or two 12-byte entries and the next-IFD pointer, then the + // out-of-line values (CameraSettings, then the lens name when present). + // pointer, then the out-of-line values (CameraSettings, then the lens name when present). + let settings_at = at + 2 + 12 * (1 + lens.is_some() as u32) + 4; + let name_at = settings_at + 60; + let mut v = Vec::new(); + let entry = |v: &mut Vec, tag: u16, kind: u16, count: u32, off: u32| { + order.put_u16(v, tag); + order.put_u16(v, kind); + order.put_u32(v, count); + order.put_u32(v, off); + }; + order.put_u16(&mut v, 1 + lens.is_some() as u16); + entry(&mut v, CANON_CAMERA_SETTINGS, 3, 30, settings_at); + if let Some(name) = lens { + entry(&mut v, CANON_LENS_MODEL_TAG, 2, name.len() as u32, name_at); + } + order.put_u32(&mut v, 0); + for i in 0..30u16 { + order.put_u16(&mut v, if i == 22 { 178 } else { 0 }); // 22: numeric LensType, no name + } + if let Some(name) = lens { + v.extend_from_slice(name.as_bytes()); + } + v + }; + let build = |at: u32| { + let mut exif = IfdBuilder::new(); + exif.set(t::MAKER_NOTE, Value::Undefined(note(at))); + exif.set(t::FOCAL_LENGTH, Value::Rational(vec![(50, 1)])); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("Canon".into())); + ifd0.set(t::MODEL, Value::Ascii("Canon EOS 5D".into())); + ifd0.set_child(t::EXIF_IFD, exif); + TiffWriter::new(order, false).write(&[ifd0]).unwrap() + }; + // the note's own offset is only known once it is laid out; its length doesn't change + let first = build(0); + let at = Tiff::parse(&first).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset as u32; + build(at) + } + + #[test] + fn canon_maker_note_lens_fills_lens_model() { + for order in [ByteOrder::Little, ByteOrder::Big] { + let m = read_exif(&canon_exif(order, Some("Canon EF 50mm f/1.8 II"))); + assert_eq!(m.lens_model.as_deref(), Some("Canon EF 50mm f/1.8 II")); + // a note that only records the numeric LensType has no name to fill in + assert_eq!(read_exif(&canon_exif(order, None)).lens_model, None); + } + } + + /// An Exif block from a Nikon body whose maker note records the lens (tag 0x0084) and no LensModel or + /// LensSpecification, as the D700, D750, D80 and D5300 write it. + fn nikon_exif(make: &str, lens: Option<[(u32, u32); 4]>, spec: Option<[(u32, u32); 4]>) -> Vec { + let mut note = IfdBuilder::new(); + note.set(0x0001, Value::Undefined(b"0210".to_vec())); + if let Some(l) = lens { + note.set(NIKON_LENS, Value::Rational(l.to_vec())); + } + let mut blob = b"Nikon\0\x02\x10\0\0".to_vec(); + blob.extend(TiffWriter::new(ByteOrder::Little, false).write(&[note]).unwrap()); + let mut exif = IfdBuilder::new(); + exif.set(t::MAKER_NOTE, Value::Undefined(blob)); + if let Some(sp) = spec { + exif.set(t::LENS_SPECIFICATION, Value::Rational(sp.to_vec())); + } + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii(make.into())); + ifd0.set_child(t::EXIF_IFD, exif); + TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap() + } + + #[test] + fn nikon_maker_note_lens_fills_the_lens_spec() { + let zoom = [(18, 1), (135, 1), (35, 10), (56, 10)]; + assert_eq!(read_exif(&nikon_exif("NIKON CORPORATION", Some(zoom), None)).lens_spec, Some([18.0, 135.0, 3.5, 5.6])); + // Exif's own LensSpecification wins, another make's note is not read, and a lens that reports nothing is left out + let prime = [(50, 1), (50, 1), (18, 10), (18, 10)]; + assert_eq!(read_exif(&nikon_exif("NIKON CORPORATION", Some(zoom), Some(prime))).lens_spec, Some([50.0, 50.0, 1.8, 1.8])); + assert_eq!(read_exif(&nikon_exif("Canon", Some(zoom), None)).lens_spec, None); + assert_eq!(read_exif(&nikon_exif("NIKON CORPORATION", Some([(0, 1), (0, 1), (0, 1), (0, 1)]), None)).lens_spec, None); + assert_eq!(read_exif(&nikon_exif("NIKON CORPORATION", Some([(200, 1), (80, 1), (28, 10), (28, 10)]), None)).lens_spec, None); + assert_eq!(read_exif(&nikon_exif("NIKON CORPORATION", None, None)).lens_spec, None); + } + + 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 placeholder_gps_block_is_no_location() { + // Android phones without a fix write NUL refs and 0/0 rationals. + let read = |lat_ref: &str, lat: Vec<(u32, u32)>| { + let mut gps = IfdBuilder::new(); + gps.set(t::GPS_LATITUDE_REF, Value::Ascii(lat_ref.into())); + gps.set(t::GPS_LATITUDE, Value::Rational(lat)); + gps.set(t::GPS_LONGITUDE_REF, Value::Ascii("E".into())); + gps.set(t::GPS_LONGITUDE, Value::Rational(vec![(151, 1), (12, 1), (4032, 100)])); + let mut ifd0 = IfdBuilder::new(); + ifd0.set_child(t::GPS_IFD, gps); + read_exif(&TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap()).gps + }; + let valid = vec![(33, 1), (51, 1), (3564, 100)]; + assert!(read("N", valid.clone()).is_some()); + assert_eq!(read("N", vec![(0, 0), (0, 0), (0, 0)]), None, "0/0 rationals"); + assert_eq!(read("N", vec![(33, 1), (0, 0), (0, 0)]), None, "0/0 minutes"); + assert_eq!(read("", valid.clone()), None, "NUL ref"); + assert_eq!(read("", valid.clone()), None, "empty ref"); + assert_eq!(read("X", valid), None, "invalid ref"); + } + + #[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); + 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")); + } +} + +/// An exact `1/N` rational for a sub-second value whose reciprocal is a whole number (shutter speeds such as +/// 1/12800 s, which [`craft_tiff::writer::rational`]'s decimal denominators cannot represent). +fn reciprocal(v: Option) -> Option { + let v = v.filter(|v| v.is_finite() && *v > 0.0 && *v < 1.0)?; + let inv = 1.0 / v; + ((inv - inv.round()).abs() < 1e-6 && inv.round() <= u32::MAX as f64).then(|| craft_tiff::Value::Rational(vec![(1, inv.round() as u32)])) +} + +/// Serialise the interchange subset of `meta` as a TIFF-structured Exif block (little-endian, no +/// `Exif\0\0` prefix): IFD0 (make, model, software, artist, copyright, description, orientation, +/// date), the Exif IFD (capture settings, lens) and, when present, the GPS IFD. +pub fn write_exif(meta: &Metadata) -> Vec { + use craft_tiff::writer::{rational, srational}; + use craft_tiff::{ByteOrder, IfdBuilder, TiffWriter, Value}; + let ascii = |s: &Option| s.as_ref().filter(|v| !v.is_empty()).map(|v| Value::Ascii(v.clone())); + let mut ifd0 = IfdBuilder::new(); + for (tag, v) in [ + (t::MAKE, ascii(&meta.make)), + (t::MODEL, ascii(&meta.model)), + (t::SOFTWARE, ascii(&meta.software)), + (t::ARTIST, ascii(&meta.artist)), + (t::COPYRIGHT, ascii(&meta.copyright)), + (t::IMAGE_DESCRIPTION, ascii(&meta.caption)), + (t::DATE_TIME, meta.capture_time.map(|d| Value::Ascii(d.to_exif()))), + (t::ORIENTATION, meta.orientation.map(|o| Value::Short(vec![o.to_exif()]))), + ] { + if let Some(v) = v { + ifd0.set(tag, v); + } + } + let mut ex = IfdBuilder::new(); + ex.set(t::EXIF_VERSION, Value::Undefined(b"0232".to_vec())); + let r = |v: Option| v.filter(|v| v.is_finite() && *v > 0.0).map(|v| Value::Rational(vec![rational(v)])); + for (tag, v) in [ + (t::DATE_TIME_ORIGINAL, meta.capture_time.map(|d| Value::Ascii(d.to_exif()))), + (t::EXPOSURE_TIME, reciprocal(meta.exposure_time).or_else(|| r(meta.exposure_time))), + (t::F_NUMBER, r(meta.f_number)), + (t::FOCAL_LENGTH, r(meta.focal_length)), + (t::ISO_SPEED, meta.iso.map(|i| Value::Short(vec![i.min(65_535) as u16]))), + (t::ISO_SPEED_RATINGS_EXT, meta.iso.filter(|&i| i > 65_534).map(|i| Value::Long(vec![i]))), + (t::LENS_MAKE, ascii(&meta.lens_make)), + (t::LENS_MODEL, ascii(&meta.lens_model)), + (t::EXPOSURE_BIAS, meta.exposure_bias.map(|v| Value::SRational(vec![srational(v)]))), + ] { + if let Some(v) = v { + ex.set(tag, v); + } + } + if let Some(d) = meta.capture_time { + // an offset past 18 h is not a real one (a deserialized value can be anything): write none + let offset = d.offset_minutes.filter(|o| o.unsigned_abs() <= 18 * 60).map(|o| { + let sign = if o < 0 { '-' } else { '+' }; + format!("{sign}{:02}:{:02}", o.unsigned_abs() / 60, o.unsigned_abs() % 60) + }); + let subsec = (d.millis != 0).then(|| format!("{:03}", d.millis)); + for (tag, v) in [(t::OFFSET_TIME, &offset), (t::OFFSET_TIME_ORIGINAL, &offset), (t::SUBSEC_TIME, &subsec), (t::SUBSEC_TIME_ORIGINAL, &subsec)] + { + if let Some(v) = v { + ex.set(tag, Value::Ascii(v.clone())); + } + } + } + ifd0.set_child(t::EXIF_IFD, ex); + if let Some(g) = meta.gps { + let dms = |v: f64| { + let v = v.abs(); + let d = v.floor(); + let m = ((v - d) * 60.0).floor(); + let s = (v - d - m / 60.0) * 3600.0; + Value::Rational(vec![(d as u32, 1), (m as u32, 1), ((s * 1000.0).round() as u32, 1000)]) + }; + let mut gps = IfdBuilder::new(); + gps.set(t::GPS_VERSION_ID, Value::Byte(vec![2, 3, 0, 0])); + gps.set(t::GPS_LATITUDE_REF, Value::Ascii(if g.latitude < 0.0 { "S" } else { "N" }.into())); + gps.set(t::GPS_LATITUDE, dms(g.latitude)); + gps.set(t::GPS_LONGITUDE_REF, Value::Ascii(if g.longitude < 0.0 { "W" } else { "E" }.into())); + gps.set(t::GPS_LONGITUDE, dms(g.longitude)); + if let Some(a) = g.altitude { + gps.set(t::GPS_ALTITUDE_REF, Value::Byte(vec![u8::from(a < 0.0)])); + gps.set(t::GPS_ALTITUDE, Value::Rational(vec![rational(a.abs())])); + } + ifd0.set_child(t::GPS_IFD, gps); + } + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap_or_default() +} + +#[cfg(test)] +mod write_tests { + use super::*; + + #[test] + fn capture_time_offset_and_subsec_round_trip() { + for iso in ["2026-09-20T05:40:00.250+02:00", "2026-09-20T05:40:00.005-03:30", "2026-09-20T05:40:00"] { + let m = Metadata { capture_time: DateTime::parse_iso(iso), ..Default::default() }; + assert_eq!(read_exif(&write_exif(&m)).capture_time, m.capture_time, "{iso}"); + } + } + + /// A deserialized offset can be any i16, `i16::MIN` included: writing must not overflow. + #[test] + fn extreme_capture_time_offsets_do_not_panic() { + for o in [i16::MIN, i16::MAX, -1081, 1081] { + let mut dt = DateTime::parse_iso("2026-09-20T05:40:00").unwrap(); + dt.offset_minutes = Some(o); + let m = Metadata { capture_time: Some(dt), ..Default::default() }; + assert_eq!(read_exif(&write_exif(&m)).capture_time.map(|d| d.offset_minutes), Some(None), "{o}"); + } + } + + #[test] + fn exif_round_trips() { + let m = Metadata { + make: Some("Synthetic".into()), + model: Some("X2".into()), + copyright: Some("© 2026 A. Person".into()), + artist: Some("A. Person".into()), + capture_time: DateTime::parse_iso("2026-09-20T05:40:00"), + exposure_time: Some(1.0 / 250.0), + f_number: Some(2.8), + iso: Some(400), + focal_length: Some(35.0), + lens_model: Some("35mm F2".into()), + gps: Some(Gps { latitude: 43.7904, longitude: -110.6818, altitude: Some(2000.0) }), + ..Default::default() + }; + let back = read_exif(&write_exif(&m)); + assert_eq!(back.make, m.make); + assert_eq!(back.model, m.model); + assert_eq!(back.copyright, m.copyright); + assert_eq!(back.artist, m.artist); + assert_eq!(back.capture_time.map(|d| d.to_exif()), m.capture_time.map(|d| d.to_exif())); + assert_eq!(back.iso, Some(400)); + assert!((back.f_number.unwrap() - 2.8).abs() < 1e-6); + assert!((back.exposure_time.unwrap() - 0.004).abs() < 1e-9); + assert_eq!(back.lens_model, m.lens_model); + let g = back.gps.unwrap(); + assert!((g.latitude - 43.7904).abs() < 1e-5 && (g.longitude + 110.6818).abs() < 1e-5, "{g:?}"); + } + + #[test] + fn iso_above_65534_round_trips() { + for iso in [65_534, 65_535, 102_400, 409_600] { + let back = read_exif(&write_exif(&Metadata { iso: Some(iso), ..Default::default() })); + assert_eq!(back.iso, Some(iso)); + } + } + + #[test] + fn fast_shutter_speeds_round_trip_exactly() { + for den in [8000.0, 12_800.0, 16_000.0, 32_000.0] { + let back = read_exif(&write_exif(&Metadata { exposure_time: Some(1.0 / den), ..Default::default() })); + let got = back.exposure_time.unwrap(); + assert!((1.0 / got - den).abs() < 0.5, "1/{den} s came back as 1/{}", 1.0 / got); + } + } +} diff --git a/crates/metadata/src/focus.rs b/crates/metadata/src/focus.rs new file mode 100644 index 00000000..b956e667 --- /dev/null +++ b/crates/metadata/src/focus.rs @@ -0,0 +1,284 @@ +//! Where the camera focused: the autofocus point a camera records in its maker notes, as a box on the +//! upright photo ([`FocusPoint`]). Sony only for now. +//! +//! **Sony** (the MakerNote IFD: header-less in ARWs, after a `SONY DSC` header in JPEGs). Tag identities and value names come from the public ExifTool +//! tag-name documentation (Sony table); every layout below was confirmed on real ILCE-1 (α1) and +//! ILCE-7SM3 (A7S III) ARWs, compared with ExifTool's output as a black box. No third-party code was read. +//! +//! | Tag | Layout (confirmed) | Use | +//! |---|---|---| +//! | `0x2027` FocusLocation | 4 × int16u: the width and height of the frame it is measured in, then the focus centre x, y in pixels of that frame (the sensor's landscape frame: a portrait shot still says 8640 × 5760) | the point | +//! | `0x2037` FocusFrameSize | 6 bytes: int16u width, int16u height of the frame the camera showed, then 2 bytes that are zero when there is no frame (manual focus) | the box size | +//! | `0x201b` FocusMode | int8u: 0 Manual, 2 AF-S, 3 AF-C, 4 AF-A, 6 DMF, 7 AF-D | manual = no point | +//! | `0x201c` AFAreaModeSetting | int8u: 0 Wide, 1 Center, 3 Flexible Spot, 11 Zone, 12 Expanded Flexible Spot, 13 Custom AF Area | shown | +//! | `0x2021` AFTracking | int8u: 0 Off, 1 Face tracking, 2 Lock On AF | shown | +//! | `0x9402` (enciphered block) byte 0x16, 0x17 | FocusMode (low 7 bits) and the AF area mode actually used: 0 Multi, 1 Center, 2 Spot, 3 Flexible Spot, 11 Zone, 12 Expanded Flexible Spot, 13 Custom AF Area, 14 Tracking, 15 Face Tracking, 20 Animal Eye Tracking, 21 Human Eye Tracking, 255 Manual | shown; read only when its focus mode agrees with `0x201b` | +//! +//! The `0x94xx` blocks are enciphered byte by byte (the documentation calls it "a simple substitution +//! cipher"). On the sample files each plain byte `p < 249` is stored as `p³ mod 249` and bytes 249–255 +//! as themselves (e.g. a stored `0x20` reads as AFAreaMode 20, stored `0x77` as `0x83`, FocusMode AF-C +//! with its top bit set); the map is a bijection on 0..249 (cubing is invertible mod 3 and mod 83), so +//! it is undone by a table of its inverse. +//! +//! **No real point** (nothing is stored, so nothing is drawn and Z zooms as before): +//! - a body the layout hasn't been confirmed on ([`SONY_MODELS`]), a missing or malformed tag, a zero +//! frame size, or a centre outside its frame; +//! - manual focus (`0x201b` = 0, or the block's area mode says Manual): the camera still records where +//! the AF frame was parked, not where anything was in focus (on the samples these are off-centre); +//! - the exact frame centre while the area used was Multi (Wide with nothing tracked): on the samples +//! the wide area reports the centre when it picked no particular spot. A centred Spot, Zone or +//! Flexible Spot frame is a real choice and is kept. + +pub use crate::focus_read::{HEADER_PREFIX, HeaderRead, MAX_HEADER_BYTES, read_header}; +use crate::{Orientation, Rect}; +use craft_tiff::{ByteOrder, Ifd, Tiff, makernote, tags as t}; +use serde::{Deserialize, Serialize}; + +/// Where the camera focused, on the upright photo. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FocusPoint { + /// The focus frame, normalized to the photo's full, EXIF-oriented frame (y down, 0..1 on both + /// axes, clipped to it) — the frame face regions use, so it follows Rotate, flips and crops. + pub rect: Rect, + /// Focus mode as the camera names it (`AF-C`, `AF-S`, `DMF`…). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option, + /// The AF area chosen on the camera (`Wide`, `Zone`, `Flexible Spot`…). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub area: Option, + /// What the camera followed, when it says (`Animal Eye Tracking`, `Face tracking`, `Lock On AF`…). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tracking: Option, +} + +impl FocusPoint { + /// The box's centre (normalized, upright). + pub fn center(&self) -> (f64, f64) { + ((self.rect.x0 + self.rect.x1) / 2.0, (self.rect.y0 + self.rect.y1) / 2.0) + } + /// The words that describe it, in order (mode, area, tracking), for "AF-C · Wide · Animal Eye Tracking". + pub fn words(&self) -> Vec<&str> { + [&self.mode, &self.area, &self.tracking].into_iter().filter_map(|s| s.as_deref()).filter(|s| !s.is_empty()).collect() + } + /// The box clipped to the frame and checked: `None` when it is not a usable box (non-finite, + /// empty, or entirely outside the photo). + pub fn sanitized(mut self) -> Option { + let r = self.rect; + if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) { + return None; + } + let (x0, x1) = (r.x0.min(r.x1).clamp(0.0, 1.0), r.x0.max(r.x1).clamp(0.0, 1.0)); + let (y0, y1) = (r.y0.min(r.y1).clamp(0.0, 1.0), r.y0.max(r.y1).clamp(0.0, 1.0)); + if x1 - x0 <= 0.0 || y1 - y0 <= 0.0 { + return None; + } + self.rect = Rect { x0, y0, x1, y1 }; + Some(self) + } +} + +/// Sony bodies whose focus tags have been checked against real files. Another body may store the same +/// tags differently (or not mean the same by them): it shows no focus point until its files are checked. +pub const SONY_MODELS: &[&str] = &["ILCE-1", "ILCE-7SM3"]; + +/// Whether a camera as the catalog names it (`"SONY ILCE-1"`: the Exif Make, a space, the Model) is +/// one whose files can carry a focus point, so a photo from any other camera is never opened to look. +pub fn camera_supported(camera: &str) -> bool { + let camera = camera.trim(); + camera.get(..4).is_some_and(|m| m.eq_ignore_ascii_case("SONY")) && camera.rsplit(' ').next().is_some_and(|model| SONY_MODELS.contains(&model)) +} + +/// Names what [`read`] decodes (its bodies), so a record of "looked, found nothing" made by this +/// decoder is not trusted by one that knows more bodies. +pub fn decoder_tag() -> String { + format!("sony-v1:{}", SONY_MODELS.join(",")) +} + +const FOCUS_MODE: u16 = 0x201b; +const AF_AREA_SETTING: u16 = 0x201c; +const AF_TRACKING: u16 = 0x2021; +const FOCUS_LOCATION: u16 = 0x2027; +const FOCUS_FRAME_SIZE: u16 = 0x2037; +const TAG_9402: u16 = 0x9402; + +/// Side of the box drawn when the camera gave a point but no frame size, as a fraction of the frame's +/// short side (about the size of the α1's smallest frames). +const DEFAULT_SIDE: f64 = 0.03; + +/// The focus point a file's maker note records, or `None` (see the module notes for when). +/// `data` is the TIFF stream the maker note's offsets refer to (a whole raw, or an Exif block). +pub fn read(data: &[u8], tiff: &Tiff, make: Option<&str>, model: Option<&str>, orientation: Option) -> Option { + let make = make?; + if !make.to_ascii_uppercase().starts_with("SONY") || !SONY_MODELS.contains(&model?.trim()) { + return None; + } + let e = tiff.exif()?.get(t::MAKER_NOTE)?; + let mn = makernote::parse_makernote(data, e.offset, e.count() as u64, tiff.order, make)?; + // ARWs from these bodies carry a header-less IFD; their JPEGs the `SONY DSC` header + if !matches!(mn.kind, makernote::MakerNoteKind::Sony | makernote::MakerNoteKind::Plain) { + return None; + } + sony(&mn.ifd, mn.order, orientation.unwrap_or_default()) +} + +fn byte(ifd: &Ifd, tag: u16) -> Option { + ifd.value(tag)?.get_u64(0).and_then(|v| u8::try_from(v).ok()) +} + +/// Decode the Sony tags of a parsed maker-note IFD. +pub(crate) fn sony(ifd: &Ifd, order: ByteOrder, orientation: Orientation) -> Option { + let loc = ifd.value(FOCUS_LOCATION)?; + let [w, h, x, y] = [0, 1, 2, 3].map(|i| loc.get_u64(i)); + let (w, h, x, y) = (w? as f64, h? as f64, x? as f64, y? as f64); + if w < 1.0 || h < 1.0 || x > w || y > h { + return None; + } + let mode = byte(ifd, FOCUS_MODE); + if mode == Some(0) { + return None; + } + // the enciphered block: its focus mode must agree with the plain one, else its layout is not the one we know + let block = ifd.bytes(TAG_9402).and_then(|b| { + let (m, area) = (decipher(*b.get(0x16)?) & 0x7f, decipher(*b.get(0x17)?)); + (Some(m) == mode).then_some(area) + }); + if block == Some(255) { + return None; + } + let setting = byte(ifd, AF_AREA_SETTING); + let tracking = byte(ifd, AF_TRACKING); + let wide_untracked = match block { + Some(a) => a == 0, + None => setting == Some(0) && tracking.is_none_or(|t| t == 0), + }; + if wide_untracked && x * 2.0 == w && y * 2.0 == h { + return None; + } + // frame size: two int16u in the note's byte order, then a non-zero marker when there is a frame + let size = ifd.bytes(FOCUS_FRAME_SIZE).and_then(|b| { + let (fw, fh) = (order.read_u16(b, 0)? as f64, order.read_u16(b, 2)? as f64); + let shown = b.get(4..6).is_none_or(|m| m.iter().any(|&v| v != 0)); + (shown && fw >= 1.0 && fh >= 1.0).then_some((fw, fh)) + }); + let (fw, fh) = size.unwrap_or_else(|| { + let side = w.min(h) * DEFAULT_SIDE; + (side, side) + }); + let sensor = Rect { x0: (x - fw / 2.0) / w, y0: (y - fh / 2.0) / h, x1: (x + fw / 2.0) / w, y1: (y + fh / 2.0) / h }; + let name = |v: Option, f: fn(u8) -> Option<&'static str>| v.and_then(f).map(str::to_string); + let area = name(setting, area_setting_name); + let used = name(block, area_used_name); + // what it followed: the area mode used when it says more than the setting (Animal Eye Tracking on + // Wide), else the tracking setting when it is on + let tracking = used.filter(|u| Some(u) != area.as_ref() && u != "Multi").or_else(|| name(tracking, tracking_name).filter(|t| t != "Off")); + FocusPoint { rect: orientation.map_norm_rect(sensor), mode: name(mode, focus_mode_name), area, tracking }.sanitized() +} + +/// A minimal little-endian TIFF laid out as an α1 ARW's metadata is (IFD0 with Make `SONY` and Model `ILCE-1` → +/// Exif IFD → a header-less maker note with the focus tags: AF-C or the given FocusMode byte, Wide, Face tracking, +/// Animal Eye Tracking at (3226, 1980) of 8640 × 5760), followed by `samples` bytes standing in for sensor data. For +/// tests of the readers and the backfill in other crates; not a real raw (it has no image). +#[doc(hidden)] +pub fn synthetic_arw(mode: u8, samples: usize) -> Vec { + fn entry(f: &mut Vec, tag: u16, typ: u16, count: u32, value: [u8; 4]) { + f.extend_from_slice(&tag.to_le_bytes()); + f.extend_from_slice(&typ.to_le_bytes()); + f.extend_from_slice(&count.to_le_bytes()); + f.extend_from_slice(&value); + } + let encipher = |p: u8| if p >= 249 { p } else { (u32::from(p).pow(3) % 249) as u8 }; + let mut f = b"II*\0".to_vec(); + f.extend_from_slice(&8u32.to_le_bytes()); + // IFD0 at 8: Make (at 62), Model (at 68), Orientation, the Exif IFD (at 76) + f.extend_from_slice(&4u16.to_le_bytes()); + entry(&mut f, t::MAKE, 2, 5, 62u32.to_le_bytes()); + entry(&mut f, t::MODEL, 2, 7, 68u32.to_le_bytes()); + entry(&mut f, t::ORIENTATION, 3, 1, [1, 0, 0, 0]); + entry(&mut f, t::EXIF_IFD, 4, 1, 76u32.to_le_bytes()); + f.extend_from_slice(&[0; 4]); + f.extend_from_slice(b"SONY\0\0ILCE-1\0\0"); + // the Exif IFD: the maker note at 94, 156 bytes + f.extend_from_slice(&1u16.to_le_bytes()); + entry(&mut f, t::MAKER_NOTE, 7, 156, 94u32.to_le_bytes()); + f.extend_from_slice(&[0; 4]); + // the maker note's IFD, its values from 172 + f.extend_from_slice(&6u16.to_le_bytes()); + entry(&mut f, FOCUS_MODE, 1, 1, [mode, 0, 0, 0]); + entry(&mut f, AF_AREA_SETTING, 1, 1, [0, 0, 0, 0]); + entry(&mut f, AF_TRACKING, 1, 1, [1, 0, 0, 0]); + entry(&mut f, FOCUS_LOCATION, 3, 4, 172u32.to_le_bytes()); + entry(&mut f, FOCUS_FRAME_SIZE, 7, 6, 180u32.to_le_bytes()); + entry(&mut f, TAG_9402, 7, 64, 186u32.to_le_bytes()); + f.extend_from_slice(&[0; 4]); + for v in [8640u16, 5760, 3226, 1980] { + f.extend_from_slice(&v.to_le_bytes()); + } + f.extend_from_slice(&[0xaf, 0, 0xb4, 0, 1, 1]); + let mut block = [0u8; 64]; + block[0x16] = encipher(0x80 | mode); + block[0x17] = encipher(20); + f.extend_from_slice(&block); + f.extend((0..samples).map(|i| (i % 251) as u8)); + f +} + +/// Undo the `0x94xx` byte substitution: the stored byte is `p³ mod 249` for `p < 249`. +fn decipher(b: u8) -> u8 { + if b >= 249 { + return b; + } + (0..249u32).find(|p| (p * p * p) % 249 == u32::from(b)).and_then(|p| u8::try_from(p).ok()).unwrap_or(b) +} + +fn focus_mode_name(v: u8) -> Option<&'static str> { + Some(match v { + 0 => "Manual", + 2 => "AF-S", + 3 => "AF-C", + 4 => "AF-A", + 6 => "DMF", + 7 => "AF-D", + _ => return None, + }) +} + +fn area_setting_name(v: u8) -> Option<&'static str> { + Some(match v { + 0 => "Wide", + 1 | 9 => "Center", + 3 | 4 => "Flexible Spot", + 11 => "Zone", + 12 => "Expanded Flexible Spot", + 13 => "Custom AF Area", + _ => return None, + }) +} + +fn tracking_name(v: u8) -> Option<&'static str> { + Some(match v { + 0 => "Off", + 1 => "Face tracking", + 2 => "Lock On AF", + _ => return None, + }) +} + +fn area_used_name(v: u8) -> Option<&'static str> { + Some(match v { + 0 => "Multi", + 1 => "Center", + 2 => "Spot", + 3 => "Flexible Spot", + 11 => "Zone", + 12 => "Expanded Flexible Spot", + 13 => "Custom AF Area", + 14 => "Tracking", + 15 => "Face Tracking", + 20 => "Animal Eye Tracking", + 21 => "Human Eye Tracking", + _ => return None, + }) +} + +#[cfg(test)] +mod tests; diff --git a/crates/metadata/src/focus/tests.rs b/crates/metadata/src/focus/tests.rs new file mode 100644 index 00000000..148f5ed9 --- /dev/null +++ b/crates/metadata/src/focus/tests.rs @@ -0,0 +1,449 @@ +use super::*; +use craft_tiff::{Entry, IfdBuilder, TiffWriter, Value}; + +fn encipher(p: u8) -> u8 { + if p >= 249 { p } else { ((u32::from(p).pow(3)) % 249) as u8 } +} + +fn entry(tag: u16, value: Value) -> Entry { + Entry { tag, value, offset: 0 } +} + +/// The α1 sample `_DSC6954.ARW`: AF-C, Wide, Face tracking, Animal Eye Tracking, 8640×5760 at (3226, 1980), 175×180. +fn alpha1() -> Ifd { + let mut block = vec![0u8; 0x40]; + block[0x16] = encipher(0x83); + block[0x17] = encipher(20); + Ifd { + entries: vec![ + entry(FOCUS_MODE, Value::Byte(vec![3])), + entry(AF_AREA_SETTING, Value::Byte(vec![0])), + entry(AF_TRACKING, Value::Byte(vec![1])), + entry(FOCUS_LOCATION, Value::Short(vec![8640, 5760, 3226, 1980])), + entry(FOCUS_FRAME_SIZE, Value::Undefined(vec![0xaf, 0, 0xb4, 0, 1, 1])), + entry(TAG_9402, Value::Undefined(block)), + ], + ..Default::default() + } +} + +fn set(ifd: &mut Ifd, tag: u16, value: Value) { + ifd.entries.retain(|e| e.tag != tag); + ifd.entries.push(entry(tag, value)); + // IFD entries are kept sorted by tag (lookups rely on it) + ifd.entries.sort_by_key(|e| e.tag); +} + +#[test] +fn the_cipher_matches_the_bytes_seen_in_files() { + // stored → plain, as read from an α1 file's 0x9402 block next to ExifTool's decoded values + assert_eq!(decipher(0x20), 20); + assert_eq!(decipher(0x77), 0x83); + assert_eq!(decipher(0xff), 0xff); + for p in 0..=255u8 { + assert_eq!(decipher(encipher(p)), p); + } +} + +#[test] +fn reads_the_alpha1_sample() { + let f = sony(&alpha1(), ByteOrder::Little, Orientation::Normal).unwrap(); + assert!((f.rect.x0 - (3226.0 - 87.5) / 8640.0).abs() < 1e-12); + assert!((f.rect.y1 - (1980.0 + 90.0) / 5760.0).abs() < 1e-12); + assert_eq!(f.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); + let (cx, cy) = f.center(); + assert!((cx - 3226.0 / 8640.0).abs() < 1e-12 && (cy - 1980.0 / 5760.0).abs() < 1e-12); +} + +#[test] +fn big_endian_frame_size() { + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_FRAME_SIZE, Value::Undefined(vec![0, 0xaf, 0, 0xb4, 1, 1])); + let f = sony(&ifd, ByteOrder::Big, Orientation::Normal).unwrap(); + assert!(((f.rect.x1 - f.rect.x0) * 8640.0 - 175.0).abs() < 1e-9); +} + +#[test] +fn follows_the_exif_orientation() { + // a portrait shot (Rotate 270 CW, EXIF 8) still records landscape sensor coordinates + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 864, 576])); + let f = sony(&ifd, ByteOrder::Little, Orientation::from_exif(8)).unwrap(); + let (cx, cy) = f.center(); + // left of the sensor (x 0.1) is the bottom of the upright photo; its top (y 0.1) the left + assert!((cx - 0.1).abs() < 1e-9 && (cy - 0.9).abs() < 1e-9, "{cx} {cy}"); + let f = sony(&ifd, ByteOrder::Little, Orientation::from_exif(6)).unwrap(); + let (cx, cy) = f.center(); + assert!((cx - 0.9).abs() < 1e-9 && (cy - 0.1).abs() < 1e-9, "{cx} {cy}"); +} + +#[test] +fn no_real_point() { + // manual focus + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_MODE, Value::Byte(vec![0])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // the block says Manual (its focus mode agreeing) + let mut ifd = alpha1(); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(3); + block[0x17] = encipher(255); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // Multi at the exact centre + let mut ifd = alpha1(); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(3); + block[0x17] = encipher(0); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 4320, 2880])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // …and without the block: Wide, tracking off + ifd.entries.retain(|e| e.tag != TAG_9402); + set(&mut ifd, AF_TRACKING, Value::Byte(vec![0])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // missing location, zero frame, centre outside it + let mut ifd = alpha1(); + ifd.entries.retain(|e| e.tag != FOCUS_LOCATION); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + for loc in [vec![0, 5760, 0, 0], vec![8640, 0, 1, 0], vec![8640, 5760, 9000, 10], vec![8640, 5760, 10, 6000], vec![8640, 5760, 10]] { + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_LOCATION, Value::Short(loc.clone())); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none(), "{loc:?}"); + } +} + +#[test] +fn a_centred_spot_is_a_real_point() { + let mut ifd = alpha1(); + set(&mut ifd, AF_AREA_SETTING, Value::Byte(vec![3])); + set(&mut ifd, AF_TRACKING, Value::Byte(vec![0])); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(2); + block[0x17] = encipher(3); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + set(&mut ifd, FOCUS_MODE, Value::Byte(vec![2])); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 4320, 2880])); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert_eq!(f.words(), vec!["AF-S", "Flexible Spot"]); + let (cx, cy) = f.center(); + assert!((cx - 0.5).abs() < 1e-12 && (cy - 0.5).abs() < 1e-12); +} + +#[test] +fn a_block_that_disagrees_is_ignored() { + // another body's block layout: its "focus mode" is not the plain tag's, so its area isn't trusted + let mut ifd = alpha1(); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(7); + block[0x17] = encipher(255); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert_eq!(f.words(), vec!["AF-C", "Wide", "Face tracking"]); + // a block too short to hold the bytes is ignored the same way + set(&mut ifd, TAG_9402, Value::Undefined(vec![1, 2, 3])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_some()); +} + +#[test] +fn missing_or_garbage_frame_size_gets_a_small_box_and_edges_are_clipped() { + for fs in + [Value::Undefined(vec![0, 0, 0, 0, 0, 0]), Value::Undefined(vec![1]), Value::Short(vec![5]), Value::Undefined(vec![0xaf, 0, 0xb4, 0, 0, 0])] + { + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_FRAME_SIZE, fs); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert!(((f.rect.y1 - f.rect.y0) - DEFAULT_SIDE).abs() < 1e-9); + } + // a frame reaching past the corner is clipped to the photo + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 0, 5760])); + set(&mut ifd, FOCUS_FRAME_SIZE, Value::Undefined(vec![0xff, 0xff, 0xff, 0xff, 1, 1])); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert_eq!(f.rect, Rect { x0: 0.0, y0: 0.0, x1: 1.0, y1: 1.0 }); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert!(f.rect.x0 >= 0.0 && f.rect.y1 <= 1.0); +} + +#[test] +fn wrong_types_and_extreme_values_never_panic() { + let values = [ + Value::Ascii("x".into()), + Value::Byte(vec![]), + Value::SShort(vec![-1, -1, -1, -1]), + Value::Long(vec![u32::MAX; 4]), + Value::Double(vec![f64::NAN; 4]), + Value::Undefined(vec![0xff; 300]), + ]; + for v in &values { + for tag in [FOCUS_MODE, AF_AREA_SETTING, AF_TRACKING, FOCUS_LOCATION, FOCUS_FRAME_SIZE, TAG_9402] { + let mut ifd = alpha1(); + set(&mut ifd, tag, v.clone()); + if let Some(f) = sony(&ifd, ByteOrder::Little, Orientation::Normal) { + assert!(f.rect.x0 >= 0.0 && f.rect.x1 <= 1.0 && f.rect.x0 < f.rect.x1); + } + } + } + assert!(FocusPoint { rect: Rect { x0: f64::NAN, y0: 0.0, x1: 1.0, y1: 1.0 }, mode: None, area: None, tracking: None }.sanitized().is_none()); + assert!(FocusPoint { rect: Rect { x0: 2.0, y0: 2.0, x1: 3.0, y1: 3.0 }, mode: None, area: None, tracking: None }.sanitized().is_none()); +} + +/// A whole TIFF with a `SONY DSC` maker note holding `ifd`'s entries, written for real. +fn sony_file(make: &str, model: &str, orientation: u16, note_entries: &[(u16, Value)]) -> Vec { + sony_file_with(make, model, orientation, note_entries, None) +} + +/// [`sony_file`], with the `0x9402` block's value placed `far` bytes past the end of the TIFF +/// (the gap filled with "sample data") when `far` is given. +fn sony_file_with(make: &str, model: &str, orientation: u16, note_entries: &[(u16, Value)], far: Option) -> Vec { + let order = ByteOrder::Little; + // the note: 12-byte header, then an IFD whose out-of-line values are offset from the TIFF header, + // which we only know after writing; write once to learn where the note lands, then rebuild + let build = |note_at: u64, end: u64| -> (Vec, Vec) { + let mut note = b"SONY DSC \0\0\0".to_vec(); + let ifd_len = 2 + note_entries.len() * 12 + 4; + let mut tail: Vec = Vec::new(); + let mut outside: Vec = Vec::new(); + order.put_u16(&mut note, note_entries.len() as u16); + for (tag, v) in note_entries { + let raw = craft_tiff::writer::encode(order, v); + order.put_u16(&mut note, *tag); + order.put_u16(&mut note, v.field_type() as u16); + order.put_u32(&mut note, v.count() as u32); + if raw.len() <= 4 { + let mut r = raw.clone(); + r.resize(4, 0); + note.extend(r); + } else if let (Some(gap), TAG_9402) = (far, *tag) { + order.put_u32(&mut note, (end + gap as u64) as u32); + outside = raw; + } else { + order.put_u32(&mut note, (note_at + 12 + ifd_len as u64 + tail.len() as u64) as u32); + tail.extend(raw); + if tail.len() % 2 == 1 { + tail.push(0); + } + } + } + order.put_u32(&mut note, 0); + note.extend(tail); + let mut exif = IfdBuilder::new(); + exif.set(t::MAKER_NOTE, Value::Undefined(note)); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii(make.into())); + ifd0.set(t::MODEL, Value::Ascii(model.into())); + ifd0.set(t::ORIENTATION, Value::Short(vec![orientation])); + ifd0.set_child(t::EXIF_IFD, exif); + (TiffWriter { order, bigtiff: false }.write(&[ifd0]).unwrap(), outside) + }; + let (first, _) = build(0, 0); + let at = Tiff::parse(&first).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset; + let (mut file, outside) = build(at, first.len() as u64); + assert_eq!(file.len(), first.len()); + assert_eq!(Tiff::parse(&file).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset, at); + if let Some(gap) = far { + file.extend((0..gap).map(|i| (i * 13 % 241) as u8)); + file.extend(outside); + } + file +} + +fn alpha1_entries() -> Vec<(u16, Value)> { + alpha1().entries.into_iter().map(|e| (e.tag, e.value)).collect() +} + +#[test] +fn read_exif_finds_it_through_the_maker_note_and_orientation() { + let file = sony_file("SONY", "ILCE-1", 8, &alpha1_entries()); + let m = crate::read_exif(&file); + let f = m.focus.expect("focus point"); + assert_eq!(f.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); + // EXIF 8: sensor x becomes 1 - upright y + let (cx, cy) = f.center(); + assert!((cy - (1.0 - 3226.0 / 8640.0)).abs() < 1e-9 && (cx - 1980.0 / 5760.0).abs() < 1e-9, "{cx} {cy}"); + // an unconfirmed body or another maker: nothing + assert!(crate::read_exif(&sony_file("SONY", "ILCE-7M4", 1, &alpha1_entries())).focus.is_none()); + assert!(crate::read_exif(&sony_file("Canon", "ILCE-1", 1, &alpha1_entries())).focus.is_none()); +} + +#[test] +fn truncated_and_garbage_maker_notes_never_panic() { + let file = sony_file("SONY", "ILCE-1", 1, &alpha1_entries()); + let note_at = Tiff::parse(&file).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset as usize; + for cut in (0..file.len()).step_by(7) { + let _ = crate::read_exif(&file[..cut]); + } + let mut seed = 0x2545_f491_u32; + for _ in 0..300 { + let mut f = file.clone(); + for _ in 0..8 { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let i = note_at + (seed as usize % (file.len() - note_at)); + f[i] = (seed >> 8) as u8; + } + if let Some(fp) = crate::read_exif(&f).focus { + assert!(fp.rect.x0 >= 0.0 && fp.rect.x1 <= 1.0 && fp.rect.y0 >= 0.0 && fp.rect.y1 <= 1.0); + } + } +} + +/// A whole-file read and a header-only read of the same file agree, and the header read reads a +/// small part of it (the sample data after the metadata is never touched). +#[test] +fn header_read_matches_the_whole_file_and_reads_little() { + let read = |file: &[u8]| { + let mut asked = 0u64; + let r = read_header(file.len() as u64, &mut |off, buf: &mut [u8]| { + asked += buf.len() as u64; + let s = off as usize; + buf.copy_from_slice(&file[s..s + buf.len()]); + Ok(()) + }) + .unwrap(); + assert_eq!(r.bytes_read, asked); + r + }; + // a raw: the metadata, then 8 MB of "samples" + let mut file = sony_file("SONY", "ILCE-1", 8, &alpha1_entries()); + let meta_len = file.len(); + file.extend((0..8_000_000u32).map(|i| (i * 31 % 251) as u8)); + let whole = crate::extract(&file).focus; + assert!(whole.is_some()); + let r = read(&file); + assert_eq!(r.focus, whole); + assert!(r.bytes_read <= HEADER_PREFIX && r.bytes_read < file.len() as u64 / 100, "{} of {}", r.bytes_read, file.len()); + assert!(meta_len < HEADER_PREFIX as usize); + // the area-mode block placed 3 MB further on: found in a further round, still a small part + let mut far = sony_file_with("SONY", "ILCE-1", 1, &alpha1_entries(), Some(3_000_000)); + far.extend((0..4_000_000u32).map(|i| (i * 7 % 253) as u8)); + let whole = crate::extract(&far).focus; + assert!(whole.is_some()); + let r = read(&far); + assert_eq!(r.focus, whole); + assert!(r.bytes_read < 64 * 1024, "{}", r.bytes_read); + // a JPEG: the Exif segment after another segment + let exif = sony_file("SONY", "ILCE-1", 6, &alpha1_entries()); + let mut jpeg = vec![0xff, 0xd8, 0xff, 0xe0, 0, 16]; + jpeg.extend_from_slice(b"JFIF\0\x01\x01\0\0\x01\0\x01\0\0"); + jpeg.extend_from_slice(&[0xff, 0xe1]); + jpeg.extend_from_slice(&((exif.len() + 8) as u16).to_be_bytes()); + jpeg.extend_from_slice(b"Exif\0\0"); + jpeg.extend_from_slice(&exif); + jpeg.extend_from_slice(&[0xff, 0xda, 0, 2]); + jpeg.extend(std::iter::repeat_n(0x55u8, 3_000_000)); + let whole = crate::extract(&jpeg).focus; + assert!(whole.is_some()); + let r = read(&jpeg); + assert_eq!(r.focus, whole); + assert!(r.bytes_read <= HEADER_PREFIX); + // unsupported bodies agree too (no point either way) + let other = sony_file("SONY", "ILCE-7M4", 1, &alpha1_entries()); + assert_eq!(read(&other).focus, None); +} + +/// Every truncation and corruption of a file gives the header read the whole-file read's answer +/// (or an error when the file is shorter than it said), never a panic. +#[test] +fn header_read_of_damaged_files_agrees_or_fails_cleanly() { + let file = sony_file("SONY", "ILCE-1", 1, &alpha1_entries()); + for cut in (0..file.len()).step_by(5) { + let part = &file[..cut]; + let r = read_header(part.len() as u64, &mut |off, buf: &mut [u8]| { + let s = off as usize; + buf.copy_from_slice(part.get(s..s + buf.len()).ok_or_else(|| std::io::Error::from(std::io::ErrorKind::UnexpectedEof))?); + Ok(()) + }) + .unwrap(); + assert_eq!(r.focus, crate::extract(part).focus, "cut at {cut}"); + } + let mut seed = 0x9e37_79b9_u32; + for _ in 0..400 { + let mut f = file.clone(); + for _ in 0..6 { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let i = seed as usize % f.len(); + f[i] = (seed >> 8) as u8; + } + let r = read_header(f.len() as u64, &mut |off, buf: &mut [u8]| { + let s = off as usize; + buf.copy_from_slice(&f[s..s + buf.len()]); + Ok(()) + }) + .unwrap(); + assert_eq!(r.focus, crate::extract(&f).focus); + } + // a reader told the file is longer than it is (replaced since it was measured): an error + let r = read_header(file.len() as u64 + 100_000, &mut |off, buf: &mut [u8]| { + let s = off as usize; + buf.copy_from_slice(file.get(s..s + buf.len()).ok_or_else(|| std::io::Error::from(std::io::ErrorKind::UnexpectedEof))?); + Ok(()) + }); + assert!(r.is_err()); +} + +#[test] +fn the_synthetic_arw_is_laid_out_as_described() { + let f = synthetic_arw(3, 10); + assert_eq!(f.len(), 260); + let p = crate::extract(&f).focus.expect("a point"); + assert_eq!(p.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); + assert!(crate::extract(&synthetic_arw(0, 0)).focus.is_none(), "manual focus"); +} + +#[test] +fn cameras_that_can_have_a_point() { + assert!(camera_supported("SONY ILCE-1") && camera_supported("Sony ILCE-7SM3 ")); + for c in ["SONY ILCE-7M4", "Canon EOS R5", "", "SONY", "ILCE-1", "SON"] { + assert!(!camera_supported(c), "{c}"); + } +} + +/// Opt-in: decode real α1 / A7S III files and compare with the values ExifTool (run as a black box) +/// reports for them. `LIGHTCRAFT_FOCUS_SAMPLES=` is the photo folder the paths below are relative +/// to; skipped without it. No user file is part of the repository. +#[test] +fn real_sony_files_match_exiftool() { + let Ok(dir) = std::env::var("LIGHTCRAFT_FOCUS_SAMPLES") else { return }; + let dir = std::path::Path::new(&dir); + type Want = Option<([f64; 4], [f64; 2], &'static [&'static str])>; + // (file, EXIF orientation, ExifTool's FocusLocation and FocusFrameSize, the words we show; None = no point) + let cases: [(&str, u16, Want); 8] = [ + // α1, AF-C, Wide, Face tracking, AFAreaMode Animal Eye Tracking + ("2023/11-november/17th/_DSC6954.ARW", 1, Some(([8640.0, 5760.0, 3226.0, 1980.0], [175.0, 180.0], &["AF-C", "Wide", "Animal Eye Tracking"]))), + ("2023/11-november/17th/_DSC6894.ARW", 1, Some(([8640.0, 5760.0, 4725.0, 1860.0], [135.0, 132.0], &["AF-C", "Wide", "Animal Eye Tracking"]))), + // α1, manual focus (the location 4441 3060 is where the frame was parked) + ("2023/11-november/15th/_DSC6613.ARW", 1, None), + // α1 portrait, AF-S, Wide, Multi at the exact centre 4320 2880 + ("2022/01-january/9th/_DSC6079.ARW", 8, None), + // α1 APS-C crop mode portrait, AF-C, Wide, Lock On AF, AFAreaMode Tracking + ("2022/12-december/16th/_DSC1575.ARW", 8, Some(([5616.0, 3744.0, 4071.0, 1848.0], [280.0, 280.0], &["AF-C", "Wide", "Tracking"]))), + // A7S III portrait, AF-A, Wide, Face tracking, Animal Eye Tracking + ("2020/10-october/31st/DSC00065.ARW", 8, Some(([4240.0, 2832.0, 1192.0, 1675.0], [278.0, 277.0], &["AF-A", "Wide", "Animal Eye Tracking"]))), + // A7S III upside down, AF-C, a centred Flexible Spot: a real point + ("2021/11-november/7th/DSC05975.ARW", 3, Some(([4240.0, 2832.0, 2120.0, 1416.0], [251.0, 224.0], &["AF-C", "Flexible Spot"]))), + // A7S III, manual focus + ("2021/06-june/12th/DSC06806.ARW", 1, None), + ]; + for (name, exif_orientation, want) in cases { + let bytes = std::fs::read(dir.join(name)).unwrap_or_else(|e| panic!("{name}: {e}")); + let got = crate::extract(&bytes).focus; + match (want, got) { + (None, None) => {} + (Some((loc, size, words)), Some(f)) => { + let [w, h, x, y] = loc; + let sensor = + Rect { x0: (x - size[0] / 2.0) / w, y0: (y - size[1] / 2.0) / h, x1: (x + size[0] / 2.0) / w, y1: (y + size[1] / 2.0) / h }; + let r = Orientation::from_exif(exif_orientation).map_norm_rect(sensor); + for (a, b) in [(f.rect.x0, r.x0), (f.rect.y0, r.y0), (f.rect.x1, r.x1), (f.rect.y1, r.y1)] { + assert!((a - b).abs() < 1e-9, "{name}: {:?} vs {r:?}", f.rect); + } + assert_eq!(f.words(), words, "{name}"); + } + (w, g) => panic!("{name}: want {w:?}, got {g:?}"), + } + } +} diff --git a/crates/metadata/src/focus_read.rs b/crates/metadata/src/focus_read.rs new file mode 100644 index 00000000..14547b05 --- /dev/null +++ b/crates/metadata/src/focus_read.rs @@ -0,0 +1,472 @@ +//! Reading a file's focus point without reading the file: [`read_header`] asks for the few byte +//! ranges [`crate::focus::read`] looks at (a TIFF raw's IFD0, its Exif IFD, the maker-note IFD and +//! the values of the focus tags; a JPEG's Exif segment) and decodes them exactly as a whole-file +//! read would. On the α1 and the A7S III all of it sits in the first 16 KB of a 50–120 MB ARW, so a +//! library of them can be backfilled from a slow disk by reading one small block per photo. +//! +//! The ranges are found by walking the structure as the readers do (the IFD tables and the value +//! offsets they hold), round by round: each round asks only for what the bytes loaded so far point +//! at. A TIFF is decoded from a buffer of the file's length in which only the loaded ranges hold the +//! file's bytes (the rest stays zero and its pages are never touched), so every offset and bound the +//! reader checks is the one it checks on the whole file. Rounds, bytes and the buffer are bounded: +//! a file that wants more is reported as having no focus point. + +use std::io; + +use crate::focus::FocusPoint; +use craft_tiff::{ByteOrder, FieldType, Tiff, tags as t}; + +/// Bytes read first: the α1's and A7S III's focus data ends before 16 KB, so one read covers it. +pub const HEADER_PREFIX: u64 = 32 * 1024; +/// Later reads are widened to this granularity (fewer, larger reads on a spinning disk). +const ALIGN: u64 = 4 * 1024; +/// The most rounds of reads one file gets (a real file needs one; the walk takes at most nine). +const MAX_ROUNDS: usize = 12; +/// The most bytes read from one file: past this the layout is not one the decoder knows. +pub const MAX_HEADER_BYTES: u64 = 2 << 20; +/// The largest TIFF given a buffer of its length (its untouched pages are never written, but the +/// allocation must succeed: the engine's planned preview reads use the same bound). Raws are far below it. +const MAX_SPARSE_FILE: u64 = 1 << 30; +/// JPEG segments walked before giving up. +const MAX_SEGMENTS: usize = 1024; +/// Entries read from one IFD (the TIFF reader's limit). +const MAX_ENTRIES: u64 = 8192; + +/// The focus tags whose values [`crate::focus`] reads from a Sony maker note. +const FOCUS_TAGS: [u16; 6] = [0x201b, 0x201c, 0x2021, 0x2027, 0x2037, 0x9402]; + +/// What [`read_header`] found, and what it cost. +#[derive(Clone, Debug, PartialEq)] +pub struct HeaderRead { + /// The focus point a whole-file read gives ([`crate::extract`]`(file).focus`). + pub focus: Option, + /// Bytes read from the file. + pub bytes_read: u64, +} + +/// The focus point of a file of `len` bytes, reading only what it needs through `read_at(offset, +/// buf)`, which fills `buf` with the file's bytes at `offset` (an error — a file that shrank or +/// went away — is returned). A malformed or unknown file is `Ok` with no point. +pub fn read_header(len: u64, read_at: &mut dyn FnMut(u64, &mut [u8]) -> io::Result<()>) -> io::Result { + let mut reader = Reader { len, read_at, bytes: 0 }; + let head = reader.fetch(0, HEADER_PREFIX.min(len))?; + let focus = if head.starts_with(&[0xff, 0xd8]) { + jpeg(&mut reader, &head)? + } else if matches!(Tiff::sniff(&head), Some((_, false))) { + tiff(&mut reader, head)? + } else { + None + }; + Ok(HeaderRead { focus, bytes_read: reader.bytes }) +} + +struct Reader<'a> { + len: u64, + read_at: &'a mut dyn FnMut(u64, &mut [u8]) -> io::Result<()>, + bytes: u64, +} + +impl Reader<'_> { + /// `[start, end)` of the file (clipped to it), counted against [`MAX_HEADER_BYTES`]. + fn fetch(&mut self, start: u64, end: u64) -> io::Result> { + let end = end.min(self.len); + let n = end.saturating_sub(start); + if self.bytes.saturating_add(n) > MAX_HEADER_BYTES { + return Err(io::Error::other(OverBudget)); + } + let mut buf = vec![0u8; usize::try_from(n).map_err(|_| io::Error::other("range too large"))?]; + if n > 0 { + (self.read_at)(start, &mut buf)?; + } + self.bytes = self.bytes.saturating_add(n); + Ok(buf) + } +} + +/// A file whose focus data would take more than [`MAX_HEADER_BYTES`] to read. +#[derive(Debug)] +struct OverBudget; + +impl std::fmt::Display for OverBudget { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("focus data larger than a header") + } +} + +impl std::error::Error for OverBudget {} + +fn over_budget(e: &io::Error) -> bool { + e.get_ref().is_some_and(|i| i.is::()) +} + +/// An over-budget read is "no focus point here", not a failure to report. +fn budget_is_none(r: io::Result>) -> io::Result> { + match r { + Err(e) if over_budget(&e) => Ok(None), + r => r, + } +} + +/// A JPEG's first `Exif` APP1 segment, found as [`crate::jpeg_segments`] finds it. +fn jpeg(r: &mut Reader, head: &[u8]) -> io::Result> { + budget_is_none(jpeg_inner(r, head)) +} + +fn jpeg_inner(r: &mut Reader, head: &[u8]) -> io::Result> { + let len = r.len; + let at = |r: &mut Reader, start: u64, n: u64| -> io::Result> { + let local = usize::try_from(start).ok().zip(usize::try_from(start.saturating_add(n)).ok()).and_then(|(a, b)| head.get(a..b)); + match local { + Some(s) => Ok(s.to_vec()), + None => r.fetch(start, start.saturating_add(n)), + } + }; + let mut i = 2u64; + for _ in 0..MAX_SEGMENTS { + if i.saturating_add(4) > len { + break; + } + let h = at(r, i, 4)?; + let (Some(&ff), Some(&marker), Some(&a), Some(&b)) = (h.first(), h.get(1), h.get(2), h.get(3)) else { break }; + if ff != 0xff { + break; + } + if marker == 0xff { + i += 1; + continue; + } + if marker == 0xd8 || (0xd0..=0xd7).contains(&marker) || marker == 0x01 { + i += 2; + continue; + } + if marker == 0xd9 || marker == 0xda { + break; + } + let seg = u64::from(u16::from_be_bytes([a, b])); + if seg < 2 { + break; + } + let end = i.saturating_add(2).saturating_add(seg).min(len); + if marker == 0xe1 { + let payload = at(r, i + 4, end.saturating_sub(i + 4))?; + if payload.starts_with(b"Exif\0") { + return Ok(crate::read_exif(payload.get(6..).unwrap_or(&[])).focus); + } + } + i = end; + } + Ok(None) +} + +/// A TIFF raw, decoded from a buffer of its length holding only the ranges [`plan`] asked for. +fn tiff(r: &mut Reader, head: Vec) -> io::Result> { + if r.len > MAX_SPARSE_FILE { + return Ok(None); + } + let Ok(len) = usize::try_from(r.len) else { return Ok(None) }; + let mut buf = vec![0u8; len]; + let Some(dst) = buf.get_mut(..head.len()) else { return Ok(None) }; + dst.copy_from_slice(&head); + let mut loaded = Loaded::default(); + loaded.insert(0, head.len() as u64); + for _ in 0..MAX_ROUNDS { + match plan(&buf, &loaded) { + Step::Ready => { + // The decoder's allocation budget follows the bytes actually fetched, not the logical + // file length: only the loaded ranges are data, and an unrelated entry that claims a + // huge value must be cut by what leaving it cost, not by what the buffer claims to hold. + // The same 8 × / + 1 MiB shape the general TIFF reader uses, over `reader.bytes` instead. + let opts = craft_tiff::ParseOptions { value_budget: Some(r.bytes.saturating_mul(8).saturating_add(1 << 20)), ..Default::default() }; + return Ok(crate::try_read_exif_with(&buf, &opts).ok().and_then(|m| m.focus)); + } + Step::Nothing => return Ok(None), + Step::Need(ranges) => { + for (a, b) in ranges { + // widen to the read granularity, but never re-read what is loaded + let (wa, wb) = ((a / ALIGN) * ALIGN, b.div_ceil(ALIGN).saturating_mul(ALIGN).min(r.len)); + for (s, e) in loaded.gaps(wa, wb) { + let bytes = match r.fetch(s, e) { + Err(e) if over_budget(&e) => return Ok(None), + other => other?, + }; + let (Ok(su), Ok(eu)) = (usize::try_from(s), usize::try_from(e)) else { return Ok(None) }; + let Some(dst) = buf.get_mut(su..eu) else { return Ok(None) }; + dst.copy_from_slice(&bytes); + loaded.insert(s, e); + } + } + } + } + } + Ok(None) +} + +/// Sorted, disjoint, non-touching byte ranges. +#[derive(Default)] +struct Loaded(Vec<(u64, u64)>); + +impl Loaded { + fn insert(&mut self, start: u64, end: u64) { + if start >= end { + return; + } + let (mut s, mut e) = (start, end); + let mut out = Vec::with_capacity(self.0.len() + 1); + for &(a, b) in &self.0 { + if b < s || a > e { + out.push((a, b)); + } else { + s = s.min(a); + e = e.max(b); + } + } + out.push((s, e)); + out.sort_unstable(); + self.0 = out; + } + + fn contains(&self, start: u64, end: u64) -> bool { + start >= end || self.0.iter().any(|&(a, b)| a <= start && end <= b) + } + + /// The parts of `[start, end)` not loaded. + fn gaps(&self, start: u64, end: u64) -> Vec<(u64, u64)> { + let mut out = Vec::new(); + let mut at = start; + for &(a, b) in &self.0 { + if b <= at || a >= end { + continue; + } + if a > at { + out.push((at, a.min(end))); + } + at = at.max(b); + if at >= end { + break; + } + } + if at < end { + out.push((at, end)); + } + out + } +} + +enum Step { + /// Load these and ask again. + Need(Vec<(u64, u64)>), + /// Everything the decoder reads is loaded. + Ready, + /// The file has nothing the decoder could read (no Exif, no maker note, a structure it rejects). + Nothing, +} + +/// One IFD entry as the TIFF reader sees it: where its value is (absolute), if it fits the file. +struct RawEntry { + tag: u16, + typ: FieldType, + start: u64, + end: u64, +} + +struct Want<'a> { + buf: &'a [u8], + order: ByteOrder, + loaded: &'a Loaded, + need: Vec<(u64, u64)>, +} + +impl Want<'_> { + fn len(&self) -> u64 { + self.buf.len() as u64 + } + + /// Whether `[start, end)` (clipped to the file) is loaded; asks for it if not. + fn want(&mut self, start: u64, end: u64) -> bool { + let end = end.min(self.len()); + if self.loaded.contains(start, end) { + return true; + } + self.need.push((start, end)); + false + } + + /// The entries of the IFD at `off` (values relative to 0), once its table is loaded; `Some(empty)` + /// for one the reader would reject. + fn table(&mut self, off: u64) -> Option> { + let len = self.len(); + if off == 0 || off >= len { + return Some(Vec::new()); + } + if !self.want(off, off.saturating_add(2)) { + return None; + } + let pos = usize::try_from(off).ok()?; + let n = u64::from(self.order.read_u16(self.buf, pos)?); + let table = off + 2; + let n_read = n.min(len.saturating_sub(table) / 12).min(MAX_ENTRIES); + let end = table + n_read * 12 + if n_read == n { 4 } else { 0 }; + if !self.want(off, end) { + return None; + } + let mut out = Vec::new(); + for i in 0..n_read { + let Ok(p) = usize::try_from(table + i * 12) else { continue }; + let (Some(tag), Some(typ), Some(count)) = ( + self.order.read_u16(self.buf, p), + self.order.read_u16(self.buf, p + 2).and_then(FieldType::from_u16), + self.order.read_u32(self.buf, p + 4), + ) else { + continue; + }; + let Some(total) = u64::from(count).checked_mul(typ.size() as u64) else { continue }; + let field = p as u64 + 8; + let start = if total <= 4 { + field + } else { + match self.order.read_u32(self.buf, p + 8) { + Some(v) => u64::from(v), + None => continue, + } + }; + let Some(end) = start.checked_add(total).filter(|&e| e <= len) else { continue }; + out.push(RawEntry { tag, typ, start, end }); + } + Some(out) + } + + /// The first unsigned value of an entry (loaded). + fn first_value(&self, e: &RawEntry) -> Option { + let pos = usize::try_from(e.start).ok()?; + match e.typ.size() { + 1 => self.buf.get(pos).map(|&b| u64::from(b)), + 2 => self.order.read_u16(self.buf, pos).map(u64::from), + 4 => self.order.read_u32(self.buf, pos).map(u64::from), + _ => self.order.read_u64(self.buf, pos), + } + } + + fn step(self) -> Step { + if self.need.is_empty() { Step::Ready } else { Step::Need(self.need) } + } +} + +/// What of a classic TIFF the focus decoder still needs, given what is `loaded`: IFD0 (Make, Model, +/// the Exif pointer), the Exif IFD (the maker-note entry), the maker note's header and IFD as +/// `craft_tiff::makernote` locates it, and the focus tags' values. +fn plan(buf: &[u8], loaded: &Loaded) -> Step { + let Some((order, false)) = Tiff::sniff(buf) else { return Step::Nothing }; + let mut w = Want { buf, order, loaded, need: Vec::new() }; + let Some(ifd0) = order.read_u32(buf, 4).map(u64::from) else { return Step::Nothing }; + let Some(e0) = w.table(ifd0) else { return w.step() }; + let mut exif = None; + for e in &e0 { + if matches!(e.tag, t::MAKE | t::MODEL | t::UNIQUE_CAMERA_MODEL | t::ORIENTATION) { + w.want(e.start, e.end); + } + if e.tag == t::EXIF_IFD && exif.is_none() && w.want(e.start, e.end) { + exif = w.first_value(e); + } + } + if !w.need.is_empty() { + return w.step(); + } + let Some(exif) = exif.filter(|&o| o != 0) else { return Step::Nothing }; + let Some(ee) = w.table(exif) else { return w.step() }; + let Some(note) = ee.iter().find(|e| e.tag == t::MAKER_NOTE) else { return Step::Nothing }; + let head_end = note.start.saturating_add(12).min(note.end); + if !w.want(note.start, head_end) { + return w.step(); + } + let head = usize::try_from(note.start).ok().zip(usize::try_from(head_end).ok()).and_then(|(a, b)| buf.get(a..b)).unwrap_or(&[]); + let sony = head.starts_with(b"SONY") || head.starts_with(b"VHAB \0"); + // another maker's header: `focus::read` only decodes Sony and header-less notes + let foreign: [&[u8]; 13] = [ + b"Nikon\0", + b"FUJIFILM", + b"GENERALE", + b"OLYMPUS\0", + b"OM SYSTEM\0", + b"OLYMP\0", + b"EPSON\0", + b"MINOL\0", + b"CAMER\0", + b"SANYO\0", + b"Panasonic\0", + b"AOC\0", + b"PENTAX \0", + ]; + if !sony && foreign.iter().any(|p| head.starts_with(p)) { + return Step::Nothing; + } + let mut entries = None; + if sony { + match w.table(note.start.saturating_add(12)) { + None => return w.step(), + Some(e) if !e.is_empty() => entries = Some(e), + Some(_) => {} + } + } + let entries = match entries { + Some(e) => e, + None => match w.table(note.start) { + Some(e) => e, + None => return w.step(), + }, + }; + for e in entries.iter().filter(|e| FOCUS_TAGS.contains(&e.tag)) { + w.want(e.start, e.end); + } + w.step() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A reader over `file` that counts what it is asked for. + fn read(file: &[u8]) -> (io::Result, u64) { + let mut asked = 0u64; + let r = read_header(file.len() as u64, &mut |off, buf: &mut [u8]| { + asked += buf.len() as u64; + let start = usize::try_from(off).map_err(io::Error::other)?; + let src = file.get(start..start + buf.len()).ok_or_else(|| io::Error::from(io::ErrorKind::UnexpectedEof))?; + buf.copy_from_slice(src); + Ok(()) + }); + (r, asked) + } + + #[test] + fn loaded_ranges() { + let mut l = Loaded::default(); + l.insert(10, 20); + l.insert(30, 40); + assert_eq!(l.gaps(0, 50), vec![(0, 10), (20, 30), (40, 50)]); + l.insert(20, 30); + assert_eq!(l.0, vec![(10, 40)]); + assert!(l.contains(12, 38) && !l.contains(5, 12)); + assert!(l.gaps(15, 35).is_empty()); + } + + #[test] + fn garbage_and_empty_files_are_no_point() { + for file in [vec![], vec![0xff, 0xd8], b"II*\0\x08\0\0\0".to_vec(), b"MM\0*\xff\xff\xff\xff".to_vec(), vec![0x42; 100_000]] { + let (r, _) = read(&file); + assert_eq!(r.unwrap().focus, None); + } + // a JPEG of nothing but fill bytes and tiny segments stops at its bounds + let mut j = vec![0xff, 0xd8]; + for _ in 0..5000 { + j.extend_from_slice(&[0xff, 0xe2, 0, 2]); + } + assert_eq!(read(&j).0.unwrap().focus, None); + } + + #[test] + fn a_file_that_shrinks_mid_read_is_an_error() { + // the reader is told the file is longer than it is (replaced between the stat and the read) + let r = read_header(1 << 20, &mut |_, _| Err(io::Error::from(io::ErrorKind::UnexpectedEof))); + assert!(r.is_err()); + } +} diff --git a/crates/metadata/src/geom.rs b/crates/metadata/src/geom.rs new file mode 100644 index 00000000..804b7d64 --- /dev/null +++ b/crates/metadata/src/geom.rs @@ -0,0 +1,180 @@ +//! The geometry metadata is stated in: a point, an axis-aligned rectangle, and the eight Exif orientations. + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Point { + pub x: f64, + pub y: f64, +} + +impl Point { + pub const fn new(x: f64, y: f64) -> Self { + Self { x, y } + } +} + +/// Axis-aligned rectangle `[x0, x1) × [y0, y1)`. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +pub struct Rect { + pub x0: f64, + pub y0: f64, + pub x1: f64, + pub y1: f64, +} + +impl Rect { + pub const UNIT: Rect = Rect { x0: 0.0, y0: 0.0, x1: 1.0, y1: 1.0 }; + pub const fn new(x0: f64, y0: f64, x1: f64, y1: f64) -> Self { + Self { x0, y0, x1, y1 } + } + pub fn from_xywh(x: f64, y: f64, w: f64, h: f64) -> Self { + Self::new(x, y, x + w, y + h) + } + pub fn from_center(c: Point, w: f64, h: f64) -> Self { + Self::new(c.x - w / 2.0, c.y - h / 2.0, c.x + w / 2.0, c.y + h / 2.0) + } + pub fn from_points(a: Point, b: Point) -> Self { + Self::new(a.x.min(b.x), a.y.min(b.y), a.x.max(b.x), a.y.max(b.y)) + } + pub fn width(&self) -> f64 { + self.x1 - self.x0 + } + pub fn height(&self) -> f64 { + self.y1 - self.y0 + } + pub fn area(&self) -> f64 { + self.width().max(0.0) * self.height().max(0.0) + } + pub fn center(&self) -> Point { + Point::new((self.x0 + self.x1) / 2.0, (self.y0 + self.y1) / 2.0) + } + pub fn aspect(&self) -> f64 { + if self.height() != 0.0 { self.width() / self.height() } else { 0.0 } + } + pub fn contains(&self, p: Point) -> bool { + p.x >= self.x0 && p.x < self.x1 && p.y >= self.y0 && p.y < self.y1 + } + pub fn is_empty(&self) -> bool { + self.x1 <= self.x0 || self.y1 <= self.y0 + } + pub fn intersect(&self, o: &Rect) -> Rect { + Rect::new(self.x0.max(o.x0), self.y0.max(o.y0), self.x1.min(o.x1), self.y1.min(o.y1)) + } +} + +/// EXIF orientation (1..=8) as a transform of the stored pixel grid. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Orientation { + #[default] + Normal, + FlipH, + Rotate180, + FlipV, + Transpose, + Rotate90, + Transverse, + Rotate270, +} + +impl Orientation { + pub fn from_exif(v: u16) -> Orientation { + match v { + 2 => Orientation::FlipH, + 3 => Orientation::Rotate180, + 4 => Orientation::FlipV, + 5 => Orientation::Transpose, + 6 => Orientation::Rotate90, + 7 => Orientation::Transverse, + 8 => Orientation::Rotate270, + _ => Orientation::Normal, + } + } + pub fn to_exif(self) -> u16 { + match self { + Orientation::Normal => 1, + Orientation::FlipH => 2, + Orientation::Rotate180 => 3, + Orientation::FlipV => 4, + Orientation::Transpose => 5, + Orientation::Rotate90 => 6, + Orientation::Transverse => 7, + Orientation::Rotate270 => 8, + } + } + /// Whether width and height swap. + pub fn swaps_axes(self) -> bool { + matches!(self, Orientation::Transpose | Orientation::Rotate90 | Orientation::Transverse | Orientation::Rotate270) + } + /// Decompose into (clockwise quarter turns applied after an optional horizontal flip). + pub fn to_parts(self) -> (bool, u8) { + match self { + Orientation::Normal => (false, 0), + Orientation::Rotate90 => (false, 1), + Orientation::Rotate180 => (false, 2), + Orientation::Rotate270 => (false, 3), + Orientation::FlipH => (true, 0), + Orientation::Transverse => (true, 1), + Orientation::FlipV => (true, 2), + Orientation::Transpose => (true, 3), + } + } + pub fn from_parts(flip: bool, quarter_turns_cw: u8) -> Orientation { + match (flip, quarter_turns_cw % 4) { + (false, 0) => Orientation::Normal, + (false, 1) => Orientation::Rotate90, + (false, 2) => Orientation::Rotate180, + (false, _) => Orientation::Rotate270, + (true, 0) => Orientation::FlipH, + (true, 1) => Orientation::Transverse, + (true, 2) => Orientation::FlipV, + (true, _) => Orientation::Transpose, + } + } + /// Rotate the (already oriented) image a further quarter turn clockwise (`cw`) or counter-clockwise. + pub fn rotated(self, cw: bool) -> Orientation { + let (f, t) = self.to_parts(); + Orientation::from_parts(f, if cw { t + 1 } else { t + 3 }) + } + /// Flip the displayed image horizontally. + pub fn flipped_h(self) -> Orientation { + let (f, t) = self.to_parts(); + // flipH ∘ rot(t) = rot(-t) ∘ flipH + Orientation::from_parts(!f, (4 - t) % 4) + } + /// The orientation that undoes this one (`o.inverse().map(o.map(p)) == p`). Flips and half turns + /// are their own inverses; quarter turns swap. + pub fn inverse(self) -> Orientation { + match self { + Orientation::Rotate90 => Orientation::Rotate270, + Orientation::Rotate270 => Orientation::Rotate90, + o => o, + } + } + /// Map a rectangle in normalized (0..1) coordinates of the stored image to normalized coordinates + /// of the oriented one (e.g. a face region given on the upright photo → the frame after the user's + /// Rotate Left/Right). + pub fn map_norm_rect(self, r: Rect) -> Rect { + let (ax, ay) = self.map(r.x0, r.y0, 1.0, 1.0); + let (bx, by) = self.map(r.x1, r.y1, 1.0, 1.0); + Rect::from_points(Point::new(ax, ay), Point::new(bx, by)) + } + /// Map a point in stored pixel space (w×h stored dims) to oriented space. + pub fn map(self, x: f64, y: f64, w: f64, h: f64) -> (f64, f64) { + let (flip, t) = self.to_parts(); + let (mut x, y2, mut w2, mut h2) = (x, y, w, h); + if flip { + x = w2 - x; + } + let mut y = y2; + for _ in 0..t { + // rotate 90° cw: (x, y) -> (h - y, x) + let nx = h2 - y; + let ny = x; + x = nx; + y = ny; + std::mem::swap(&mut w2, &mut h2); + } + (x, y) + } +} diff --git a/crates/metadata/src/gpx.rs b/crates/metadata/src/gpx.rs new file mode 100644 index 00000000..691a2b7e --- /dev/null +++ b/crates/metadata/src/gpx.rs @@ -0,0 +1,491 @@ +//! GPS track logs in GPX 1.0 / 1.1 (the open GPS Exchange Format, ): +//! the timed track points of every ``, and the position at any moment for geotagging photos. +//! +//! What we use from the format: `` → `` → `` with optional `` +//! (metres) and `"#; + assert!(matches!(parse_gpx(wrong_close), Err(GpxError::Xml(_)))); + assert!( + parse_gpx("") + .unwrap() + .points + .is_empty() + ); + let deep = "".to_string() + &"".repeat(100); + assert_eq!(parse_gpx(&deep), Err(GpxError::TooComplex)); + } +} diff --git a/crates/metadata/src/iptc.rs b/crates/metadata/src/iptc.rs new file mode 100644 index 00000000..a398c6fc --- /dev/null +++ b/crates/metadata/src/iptc.rs @@ -0,0 +1,151 @@ +//! 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_end_matches('\0').trim().to_string(), + Err(_) => b.iter().map(|&c| c as char).collect::().trim_end_matches('\0').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 seen_keywords = std::collections::HashSet::new(); + 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() && seen_keywords.insert(k.clone()) { + 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); + } + + #[test] + fn distinct_keywords_are_kept_once_each_without_rescanning() { + let mut b = Vec::new(); + for i in 0..50_000u32 { + ds(&mut b, 2, 25, format!("k{i}").as_bytes()); + ds(&mut b, 2, 25, b"same"); + } + let m = parse_iptc(&b); + assert_eq!(m.keywords.len(), 50_001); + assert_eq!(m.keywords.iter().filter(|k| *k == "same").count(), 1); + } + + 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 non_utf8_text_drops_trailing_nul() { + let mut v = Vec::new(); + ds(&mut v, 2, 5, &[0x43, 0xe9, 0x00]); + ds(&mut v, 2, 25, &[0x6b, 0xe9, 0x00]); + let m = parse_iptc(&v); + assert_eq!(m.title.as_deref(), Some("Cé")); + assert_eq!(m.keywords, vec!["ké".to_string()]); + let mut u = Vec::new(); + ds(&mut u, 1, 90, b"\x1b%G"); + ds(&mut u, 2, 5, &[0x43, 0xff, 0x00]); + assert_eq!(parse_iptc(&u).title.as_deref(), Some("C\u{fffd}")); + } + + #[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/metadata/src/lib.rs b/crates/metadata/src/lib.rs new file mode 100644 index 00000000..e485e4a8 --- /dev/null +++ b/crates/metadata/src/lib.rs @@ -0,0 +1,606 @@ +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +//! Photo metadata: EXIF, IPTC-IIM, XMP packets and GPX track logs, read into one [`Metadata`] model. +//! +//! One module per format: +//! - EXIF: [`read_exif`] reads a TIFF-structured Exif block, [`from_tiff`] an already parsed stream, +//! [`write_exif`] writes one. Maker notes supply the lens and, in [`focus`], where the camera focused. +//! [`tags`] lists every tag by name. +//! - IPTC-IIM: [`parse_iptc`] reads record 2 datasets. +//! - XMP: [`parse_xmp`] and [`write_xmp`] read and write packets; [`merge_xmp`] updates a packet +//! another program wrote without losing what it does not understand. +//! - GPX: [`parse_gpx`] reads track logs for geotagging by capture time. +//! - Containers: [`embedded`], [`jpeg_segments`], [`png_chunks`] and [`webp_chunks`] locate the Exif, +//! XMP, ICC and IPTC blocks of JPEG, PNG, WebP, TIFF-based, Fujifilm RAF and Canon CR3 ([`cr3`]) files. +//! [`extract`] does all of the above for a whole file. +//! +//! HEIF, PSD and MRW files are read by their own crates. Put the blocks they return into an +//! [`Embedded`] and call [`from_embedded`]. + +mod container; +pub mod cr3; +mod datetime; +mod exif; +pub mod focus; +mod focus_read; +mod geom; +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, try_read_exif_with, write_exif}; +pub use focus::FocusPoint; +pub use geom::{Orientation, Point, Rect}; +pub use gpx::{GpxError, Match, TrackPoint, Tracklog, parse_gpx}; +pub use iptc::parse_iptc; +pub use tags::{TagRow, file_tag_rows, tag_rows}; +pub use xmp::{CRS_NS, XmpData, XmpError, XmpNamespace, XmpValue, XmpWrite, parse_xmp, parse_xmp_with, write_xmp, write_xmp_with}; +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: [`write_xmp`] does not write regions. +#[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), +} + +/// What a photo catalogue 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 + ); + self.fill_missing_descriptive(other); + } + + /// Fill descriptive metadata separately from the camera and image properties. + fn fill_missing_descriptive(&mut self, other: &Metadata) { + fill!( + self, + other, + 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 understood from a whole file (JPEG, PNG, WebP, TIFF-based incl. DNG and most raws, RAF, CR3). +/// Precedence: EXIF, then IPTC fills gaps, then XMP overrides user fields and fills the rest. +pub fn extract(bytes: &[u8]) -> Metadata { + from_embedded(&embedded(bytes)) +} + +/// [`extract`] for blocks that are already located, for example by a HEIF or PSD reader. Best effort: +/// a block that cannot be read counts as absent. A caller that must tell "no location" from +/// "unreadable" uses [`try_read_exif`] and [`parse_xmp`] on the blocks first. +pub fn from_embedded(e: &Embedded) -> Metadata { + let mut m = e.exif.as_deref().map(read_exif).unwrap_or_default(); + let xmp = e.xmp.as_deref().and_then(|xmp| parse_xmp(xmp).ok()); + // the size the Exif states: a size only the XMP states may be the upright one, so it can't tell how the photo is stored + let stored = m.width.zip(m.height); + // and the orientation the Exif states: the photo is displayed by that alone, so an orientation only the XMP + // states ([`Metadata::fill_missing`] reports it below) can't tell how the photo is stored either + let exif_orientation = e.exif.as_deref().and_then(exif::ifd0_orientation); + if let Some(i) = e.iptc.as_deref() { + m.fill_missing(&parse_iptc(i)); + } + if let Some(xmp) = xmp { + m.overlay_user_fields(&xmp.metadata); + m.fill_missing(&xmp.metadata); + // boxes another app drew on the photo as stored go where the upright photo has them + if let (Some(drawn_on), Some(orientation), Some(stored)) = (xmp.regions_drawn_on(), exif_orientation, stored) { + upright_regions(&mut m.regions, drawn_on, orientation, stored); + } + } + m +} + +/// Turn regions drawn on a photo as it is stored onto the upright photo. MWG gives each box as a fraction of +/// `AppliedToDimensions` (`drawn_on`); for a photo whose Exif `orientation` turns it a quarter turn, some apps give the +/// stored, sideways size there and boxes on the stored pixels to match (Lightroom says so per box with +/// `mwg-rs:Rotation` instead, which [`parse_xmp`] reads). When `drawn_on` has the shape of the photo as stored +/// (`stored`, before `orientation`) rather than the upright one, every box is turned with `orientation`. Returns +/// whether they were. A square photo or `drawn_on` looks the same both ways, and a half turn or a mirror doesn't +/// change the shape: their boxes are left as they are. +pub fn upright_regions(regions: &mut [Region], drawn_on: (f64, f64), orientation: Orientation, stored: (u32, u32)) -> bool { + let ((dw, dh), (sw, sh)) = (drawn_on, stored); + if regions.is_empty() || !orientation.swaps_axes() || sw == sh || dw == dh || (dw > dh) != (sw > sh) { + return false; + } + for r in regions.iter_mut() { + r.rect = orientation.map_norm_rect(r.rect); + } + true +} + +/// 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()), title: Some("Original".into()), rating: Some(1), ..Default::default() }; + let b = Metadata { + make: Some("B".into()), + model: Some("M".into()), + title: Some("Replacement".into()), + caption: Some("Caption".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.title.as_deref(), Some("Original")); + assert_eq!(a.caption.as_deref(), Some("Caption")); + assert_eq!(a.keywords, vec!["k".to_string()]); + 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")); + } + + /// An MWG regions packet with one face, its box centred at (0.25, 0.4) and 0.1 × 0.15 of `AppliedToDimensions` + /// `w` × `h`; `extra` goes on the region (e.g. Lightroom's `mwg-rs:Rotation`). + fn regions_packet(w: u32, h: u32, extra: &str) -> String { + format!( + r#" + + + + + + + + + + "# + ) + } + + fn near(r: Rect, b: [f64; 4]) -> bool { + [r.x0 - b[0], r.y0 - b[1], r.x1 - b[2], r.y1 - b[3]].iter().all(|d| d.abs() < 1e-9) + } + + /// The face's box as drawn: on the photo as stored, or on the upright one by apps that draw there. + const DRAWN: [f64; 4] = [0.2, 0.325, 0.3, 0.475]; + /// The same box on the photo turned upright by Exif orientation 6 (a quarter turn clockwise). + const TURNED_6: [f64; 4] = [0.525, 0.2, 0.675, 0.3]; + + #[test] + fn boxes_drawn_on_the_stored_photo_turn_with_its_orientation() { + let region = || Region { + rect: Rect { x0: DRAWN[0], y0: DRAWN[1], x1: DRAWN[2], y1: DRAWN[3] }, + kind: RegionKind::Face, + name: None, + description: None, + }; + let turned = |o: Orientation, drawn_on: (f64, f64), stored: (u32, u32)| { + let mut regions = [region()]; + let did = upright_regions(&mut regions, drawn_on, o, stored); + (did, regions[0].rect) + }; + // drawn on the 6000 × 4000 photo as stored: every quarter turn and its mirror image land elsewhere + for (o, want) in [ + (Orientation::Rotate90, TURNED_6), + (Orientation::Rotate270, [0.325, 0.7, 0.475, 0.8]), + (Orientation::Transpose, [0.325, 0.2, 0.475, 0.3]), + (Orientation::Transverse, [0.525, 0.7, 0.675, 0.8]), + ] { + let (did, r) = turned(o, (6000.0, 4000.0), (6000, 4000)); + assert!(did && near(r, want), "{o:?}: {r:?}"); + // a smaller copy of the photo as stored is the same frame + assert!(near(turned(o, (1500.0, 1000.0), (6000, 4000)).1, want), "{o:?} on a smaller size"); + } + // drawn on the upright photo (MWG's own frame), or where the shape can't tell the two apart: as they are + for (o, drawn_on, stored) in [ + (Orientation::Rotate90, (4000.0, 6000.0), (6000, 4000)), + (Orientation::Rotate90, (4000.0, 4000.0), (6000, 4000)), + (Orientation::Rotate90, (4000.0, 4000.0), (4000, 4000)), + (Orientation::Rotate180, (6000.0, 4000.0), (6000, 4000)), + (Orientation::FlipH, (6000.0, 4000.0), (6000, 4000)), + (Orientation::Normal, (6000.0, 4000.0), (6000, 4000)), + ] { + let (did, r) = turned(o, drawn_on, stored); + assert!(!did && near(r, DRAWN), "{o:?} drawn on {drawn_on:?} of {stored:?}: {r:?}"); + } + } + + /// An app that doesn't say how the photo was turned (no `mwg-rs:Rotation`) drew the faces of a photo + /// with Exif orientation 6 on its stored pixels, with the stored 6000 × 4000 as `AppliedToDimensions`. The boxes + /// land on the faces of the upright photo, not where the stored photo had them. + #[test] + fn regions_drawn_on_the_stored_photo_are_read_onto_the_upright_one() { + let jpeg = |packet: &str| { + let mut j = vec![0xff, 0xd8]; + let mut seg = |payload: Vec| { + j.extend([0xff, 0xe1]); + j.extend(u16::try_from(payload.len() + 2).unwrap().to_be_bytes()); + j.extend(payload); + }; + // orientation 6, 6000 × 4000 as stored + seg([b"Exif\0\0".as_slice(), &crate::exif::tests::sample_exif(craft_tiff::ByteOrder::Little)].concat()); + seg([b"http://ns.adobe.com/xap/1.0/\0".as_slice(), packet.as_bytes()].concat()); + j.extend([0xff, 0xd9]); + j + }; + let read = |packet: &str| { + let m = extract(&jpeg(packet)); + assert_eq!((m.orientation, m.width, m.height), (Some(Orientation::Rotate90), Some(6000), Some(4000))); + assert_eq!(from_embedded(&embedded(&jpeg(packet))).regions, m.regions); + assert_eq!(m.regions.len(), 1); + m.regions[0].rect + }; + let stored = read(®ions_packet(6000, 4000, "")); + assert!(near(stored, TURNED_6), "drawn on the photo as stored: {stored:?}"); + let upright = read(®ions_packet(4000, 6000, "")); + assert!(near(upright, DRAWN), "drawn on the upright photo, MWG's way: {upright:?}"); + // Lightroom's boxes say how the photo was turned: turned once, by that + let lightroom = read(®ions_packet(6000, 4000, r#" mwg-rs:Rotation="-1.57080""#)); + assert!(near(lightroom, TURNED_6), "Lightroom's −π/2: {lightroom:?}"); + } + + /// A size only the XMP states can't tell how the photo is stored: `tiff:ImageWidth` and `tiff:ImageLength` of the + /// upright photo have the shape of boxes drawn on it upright, and those must not be turned. + #[test] + fn a_size_only_the_xmp_states_turns_no_regions() { + // orientation 6, no pixel size + let exif = write_exif(&Metadata { orientation: Some(Orientation::Rotate90), ..Default::default() }); + let exif = strip_exif_header(&exif); + let packet = regions_packet(4000, 6000, "") + .replace(r#"rdf:about="""#, r#"rdf:about="" xmlns:tiff="http://ns.adobe.com/tiff/1.0/" tiff:ImageWidth="4000" tiff:ImageLength="6000""#); + let mut j = vec![0xff, 0xd8]; + let mut seg = |payload: Vec| { + j.extend([0xff, 0xe1]); + j.extend(u16::try_from(payload.len() + 2).unwrap().to_be_bytes()); + j.extend(payload); + }; + seg([b"Exif\0\0".as_slice(), exif].concat()); + seg([b"http://ns.adobe.com/xap/1.0/\0".as_slice(), packet.as_bytes()].concat()); + j.extend([0xff, 0xd9]); + let m = extract(&j); + assert_eq!((m.orientation, m.width, m.height), (Some(Orientation::Rotate90), Some(4000), Some(6000))); + assert_eq!(m.regions.len(), 1); + assert!(near(m.regions[0].rect, DRAWN), "{:?}", m.regions[0].rect); + assert_eq!(from_embedded(&embedded(&j)).regions, m.regions); + // a TIFF-based file: the whole file is the Exif block, with the XMP in its first IFD + let mut ifd0 = craft_tiff::IfdBuilder::new(); + ifd0.set(craft_tiff::tags::ORIENTATION, craft_tiff::Value::Short(vec![6])); + ifd0.set(craft_tiff::tags::XMP, craft_tiff::Value::Byte(packet.into_bytes())); + let m = extract(&craft_tiff::TiffWriter::default().write(&[ifd0]).unwrap()); + assert_eq!((m.orientation, m.width, m.height), (Some(Orientation::Rotate90), Some(4000), Some(6000))); + assert!(near(m.regions[0].rect, DRAWN), "TIFF: {:?}", m.regions[0].rect); + } + + /// A photo whose Exif has a size but no orientation, and whose XMP says `tiff:Orientation=6`: a viewer + /// displays it by the Exif alone, so it stays as stored, and boxes drawn on that stored frame are right as + /// written. Turning them by an orientation only the XMP states put them where the photo isn't. + #[test] + fn an_orientation_only_the_xmp_states_turns_no_regions() { + // 6000 × 4000 in the Exif, and no orientation + let size_ifd = || { + let mut sub = craft_tiff::IfdBuilder::new(); + sub.set(craft_tiff::tags::PIXEL_X_DIMENSION, craft_tiff::Value::Long(vec![6000])); + sub.set(craft_tiff::tags::PIXEL_Y_DIMENSION, craft_tiff::Value::Long(vec![4000])); + sub + }; + let mut exif_ifd0 = craft_tiff::IfdBuilder::new(); + exif_ifd0.set_child(craft_tiff::tags::EXIF_IFD, size_ifd()); + let exif = craft_tiff::TiffWriter::new(craft_tiff::ByteOrder::Little, false).write(&[exif_ifd0]).unwrap(); + let packet = regions_packet(6000, 4000, "") + .replace(r#"rdf:about="""#, r#"rdf:about="" xmlns:tiff="http://ns.adobe.com/tiff/1.0/" tiff:Orientation="6""#); + let mut j = vec![0xff, 0xd8]; + let mut seg = |payload: Vec| { + j.extend([0xff, 0xe1]); + j.extend(u16::try_from(payload.len() + 2).unwrap().to_be_bytes()); + j.extend(payload); + }; + seg([b"Exif\0\0".as_slice(), &exif].concat()); + seg([b"http://ns.adobe.com/xap/1.0/\0".as_slice(), packet.as_bytes()].concat()); + j.extend([0xff, 0xd9]); + let m = extract(&j); + assert_eq!((m.width, m.height), (Some(6000), Some(4000))); + assert_eq!(m.orientation, Some(Orientation::Rotate90), "the XMP's orientation is still reported"); + assert_eq!(m.regions.len(), 1); + assert!(near(m.regions[0].rect, DRAWN), "turned by an orientation the Exif doesn't state: {:?}", m.regions[0].rect); + assert_eq!(from_embedded(&embedded(&j)).regions, m.regions); + // a TIFF-based file: the first IFD has no orientation, its XMP (tag 700) has + let mut ifd0 = craft_tiff::IfdBuilder::new(); + ifd0.set(craft_tiff::tags::XMP, craft_tiff::Value::Byte(packet.into_bytes())); + ifd0.set_child(craft_tiff::tags::EXIF_IFD, size_ifd()); + let m = extract(&craft_tiff::TiffWriter::default().write(&[ifd0]).unwrap()); + assert_eq!(m.orientation, Some(Orientation::Rotate90)); + assert!(near(m.regions[0].rect, DRAWN), "TIFF: {:?}", m.regions[0].rect); + } +} diff --git a/crates/metadata/src/tags.rs b/crates/metadata/src/tags.rs new file mode 100644 index 00000000..6e593092 --- /dev/null +++ b/crates/metadata/src/tags.rs @@ -0,0 +1,529 @@ +//! Every EXIF / TIFF / GPS tag of a file as readable `(group, name, value)` rows (the Info +//! panel's "All Metadata"). Names follow the public TIFF 6.0 / EXIF 2.3 / DNG specifications; +//! common enumerations are spelled out, everything else is shown as stored. Maker notes and +//! binary blobs are summarised by size; of a maker note only the lens name is listed (the +//! Panasonic one, as Panasonic writes no Exif LensModel). + +use craft_tiff::{Ifd, Tiff, Value}; + +/// One metadata row. +#[derive(Clone, Debug, PartialEq)] +pub struct TagRow { + /// `TIFF`, `EXIF`, `GPS`, `Interop`, or a maker note's (`Panasonic`). + pub group: &'static str, + pub tag: u16, + pub name: String, + pub value: String, +} + +const NAMES: &[(u16, &str)] = &[ + // TIFF / IFD0 + (0x00fe, "Subfile Type"), + (0x0100, "Image Width"), + (0x0101, "Image Height"), + (0x0102, "Bits Per Sample"), + (0x0103, "Compression"), + (0x0106, "Photometric Interpretation"), + (0x010e, "Image Description"), + (0x010f, "Make"), + (0x0110, "Model"), + (0x0112, "Orientation"), + (0x0115, "Samples Per Pixel"), + (0x0116, "Rows Per Strip"), + (0x0214, "Reference Black/White"), + (0x9216, "TIFF/EP Standard ID"), + (0x828d, "CFA Repeat Pattern Dim"), + (0x828e, "CFA Pattern"), + (0xa302, "CFA Pattern"), + (0x9217, "Sensing Method"), + (0xc4a5, "Print Image Matching"), + (0xa500, "Gamma"), + (0x011a, "X Resolution"), + (0x011b, "Y Resolution"), + (0x011c, "Planar Configuration"), + (0x0128, "Resolution Unit"), + (0x0131, "Software"), + (0x0132, "Date/Time Modified"), + (0x013b, "Artist"), + (0x013e, "White Point"), + (0x013f, "Primary Chromaticities"), + (0x0213, "YCbCr Positioning"), + (0x8298, "Copyright"), + (0xc612, "DNG Version"), + (0xc614, "Unique Camera Model"), + (0xc62f, "Camera Serial Number"), + // EXIF + (0x829a, "Exposure Time"), + (0x829d, "F-Number"), + (0x8822, "Exposure Program"), + (0x8824, "Spectral Sensitivity"), + (0x8827, "ISO Speed"), + (0x8830, "Sensitivity Type"), + (0x8832, "Recommended Exposure Index"), + (0x9000, "Exif Version"), + (0x9003, "Date/Time Original"), + (0x9004, "Date/Time Digitized"), + (0x9010, "Offset Time"), + (0x9011, "Offset Time Original"), + (0x9012, "Offset Time Digitized"), + (0x9101, "Components Configuration"), + (0x9102, "Compressed Bits Per Pixel"), + (0x9201, "Shutter Speed Value"), + (0x9202, "Aperture Value"), + (0x9203, "Brightness Value"), + (0x9204, "Exposure Compensation"), + (0x9205, "Max Aperture Value"), + (0x9206, "Subject Distance"), + (0x9207, "Metering Mode"), + (0x9208, "Light Source"), + (0x9209, "Flash"), + (0x920a, "Focal Length"), + (0x9214, "Subject Area"), + (0x927c, "Maker Note"), + (0x9286, "User Comment"), + (0x9290, "Sub-second Time"), + (0x9291, "Sub-second Time Original"), + (0x9292, "Sub-second Time Digitized"), + (0xa000, "FlashPix Version"), + (0xa001, "Color Space"), + (0xa002, "Pixel X Dimension"), + (0xa003, "Pixel Y Dimension"), + (0xa20e, "Focal Plane X Resolution"), + (0xa20f, "Focal Plane Y Resolution"), + (0xa210, "Focal Plane Resolution Unit"), + (0xa215, "Exposure Index"), + (0xa217, "Sensing Method"), + (0xa300, "File Source"), + (0xa301, "Scene Type"), + (0xa401, "Custom Rendered"), + (0xa402, "Exposure Mode"), + (0xa403, "White Balance"), + (0xa404, "Digital Zoom Ratio"), + (0xa405, "Focal Length in 35mm Film"), + (0xa406, "Scene Capture Type"), + (0xa407, "Gain Control"), + (0xa408, "Contrast"), + (0xa409, "Saturation"), + (0xa40a, "Sharpness"), + (0xa40c, "Subject Distance Range"), + (0xa420, "Image Unique ID"), + (0xa430, "Camera Owner Name"), + (0xa431, "Body Serial Number"), + (0xa432, "Lens Specification"), + (0xa433, "Lens Make"), + (0xa434, "Lens Model"), + (0xa435, "Lens Serial Number"), + // GPS + (0x0000, "GPS Version"), + (0x0001, "GPS Latitude Ref"), + (0x0002, "GPS Latitude"), + (0x0003, "GPS Longitude Ref"), + (0x0004, "GPS Longitude"), + (0x0005, "GPS Altitude Ref"), + (0x0006, "GPS Altitude"), + (0x0007, "GPS Time Stamp"), + (0x000c, "GPS Speed Ref"), + (0x000d, "GPS Speed"), + (0x0010, "GPS Img Direction Ref"), + (0x0011, "GPS Img Direction"), + (0x0012, "GPS Map Datum"), + (0x001d, "GPS Date Stamp"), +]; + +/// GPS tags reuse low numbers, so they are looked up separately. +fn name(group: &str, tag: u16) -> String { + let gps = group == "GPS"; + NAMES + .iter() + .find(|(t, n)| *t == tag && (n.starts_with("GPS") == gps || (gps && tag > 0x1f))) + .map(|(_, n)| n.to_string()) + .unwrap_or_else(|| format!("Tag 0x{tag:04X}")) +} + +fn rational(n: f64, d: f64) -> String { + if d == 0.0 { + return "—".into(); + } + let v = n / d; + if (v - v.round()).abs() < 1e-9 { + let r = v.round(); + if r == 0.0 { "0".to_string() } else { format!("{}", r) } + } else { + let s = format!("{:.4}", v); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" { "0".to_string() } else { s.to_string() } + } +} + +fn list(v: &[T]) -> String { + list_with(v, T::to_string) +} + +/// The first 16 values of `v` written by `f`, and how many there are when that's not all of them. +fn list_with(v: &[T], f: impl Fn(&T) -> String) -> String { + let shown: Vec = v.iter().take(16).map(f).collect(); + if v.len() > 16 { format!("{} … ({} values)", shown.join(", "), v.len()) } else { shown.join(", ") } +} + +/// Spelled-out enumerations for the commonest tags. +fn enumerated(group: &str, tag: u16, v: i64) -> Option<&'static str> { + if group == "GPS" { + return None; + } + Some(match (tag, v) { + (0x0112, 1) => "Horizontal (normal)", + (0x0112, 3) => "Rotated 180°", + (0x0112, 6) => "Rotated 90° CW", + (0x0112, 8) => "Rotated 90° CCW", + (0x0112, 2) => "Mirrored horizontally", + (0x0112, 4) => "Mirrored vertically", + (0x0103, 1) => "Uncompressed", + (0x0103, 6) => "JPEG (old-style)", + (0x0103, 7) => "JPEG", + (0x0103, 8) => "Deflate", + (0x0103, 32773) => "PackBits", + (0x0103, 34892) => "Lossy JPEG", + (0x0103, 34713) => "Nikon NEF Compressed", + (0x0103, 32767) => "Sony ARW Compressed", + (0x0103, 52546) => "JPEG XL", + (0x0106, 0) => "WhiteIsZero", + (0x0106, 1) => "BlackIsZero", + (0x0106, 2) => "RGB", + (0x0106, 6) => "YCbCr", + (0x0106, 32803) => "Color Filter Array", + (0x0106, 34892) => "Linear Raw", + (0x011c, 1) => "Chunky", + (0x011c, 2) => "Planar", + (0x0128, 2) => "inches", + (0x0128, 3) => "centimetres", + (0x8822, 1) => "Manual", + (0x8822, 2) => "Program", + (0x8822, 3) => "Aperture priority", + (0x8822, 4) => "Shutter priority", + (0x9207, 2) => "Center-weighted average", + (0x9207, 3) => "Spot", + (0x9207, 5) => "Pattern", + (0xa001, 1) => "sRGB", + (0xa001, 0xffff) => "Uncalibrated", + (0xa402, 0) => "Auto", + (0xa402, 1) => "Manual", + (0xa402, 2) => "Auto bracket", + (0xa403, 0) => "Auto", + (0xa403, 1) => "Manual", + (0xa406, 0) => "Standard", + (0xa406, 1) => "Landscape", + (0xa406, 2) => "Portrait", + (0xa406, 3) => "Night", + (0x9209, 0) => "No flash", + (0x9209, 1) => "Fired", + (0x9209, 5) => "Fired, return not detected", + (0x9209, 7) => "Fired, return detected", + (0x9209, 9) => "On, fired", + (0x9209, 16) => "Off, did not fire", + (0x9209, 24) => "Auto, did not fire", + (0x9209, 25) => "Auto, fired", + (0x9209, 32) => "No flash function", + (0x9208, 0) => "Unknown", + (0x9208, 1) => "Daylight", + (0x9208, 2) => "Fluorescent", + (0x9208, 3) => "Tungsten", + (0x9208, 4) => "Flash", + _ => return None, + }) +} + +fn value_text(group: &str, tag: u16, v: &Value) -> String { + let single = |x: i64| enumerated(group, tag, x).map(str::to_string).unwrap_or_else(|| x.to_string()); + match v { + Value::Ascii(s) => s.trim().to_string(), + Value::Short(a) if a.len() == 1 => single(a[0] as i64), + Value::Long(a) if a.len() == 1 => single(a[0] as i64), + Value::Short(a) => list(a), + Value::Long(a) => list(a), + Value::SShort(a) => list(a), + Value::SLong(a) => list(a), + Value::Long8(a) => list(a), + Value::SLong8(a) => list(a), + Value::Float(a) => list(a), + Value::Double(a) => list(a), + Value::Rational(a) if tag == 0x829a && group != "GPS" && a.len() == 1 && a[0].1 != 0 => { + // exposure time as a fraction of a second + let (n, d) = a[0]; + let s = n as f64 / d as f64; + if s >= 1.0 || n == 0 { format!("{} s", rational(n as f64, d as f64)) } else { format!("1/{} s", (1.0 / s).round()) } + } + Value::Rational(a) if tag == 0x829d && group != "GPS" && a.len() == 1 => format!("f/{}", rational(a[0].0 as f64, a[0].1 as f64)), + Value::Rational(a) if tag == 0x920a && group != "GPS" && a.len() == 1 => format!("{} mm", rational(a[0].0 as f64, a[0].1 as f64)), + Value::Rational(a) => list_with(a, |(n, d)| rational(*n as f64, *d as f64)), + Value::SRational(a) => list_with(a, |(n, d)| rational(*n as f64, *d as f64)), + Value::Byte(b) | Value::Undefined(b) => { + // version tags and short printable values read as text + let printable = b.len() <= 64 && b.iter().all(|c| c.is_ascii_graphic() || *c == b' ' || *c == 0); + if printable && !b.is_empty() { + String::from_utf8_lossy(b).trim_end_matches('\0').trim().to_string() + } else if b.len() <= 8 { + list(b) + } else { + format!("({} bytes)", b.len()) + } + } + Value::SByte(a) => list(a), + Value::Ifd(a) => format!("(IFD at {})", list(a)), + Value::Ifd8(a) => format!("(IFD at {})", list(a)), + } +} + +fn rows_of(group: &'static str, ifd: &Ifd, out: &mut Vec) { + for e in &ifd.entries { + // pointers to other IFDs and image data are structure, not metadata + if matches!(e.tag, 0x8769 | 0x8825 | 0xa005 | 0x014a | 0x0111 | 0x0117 | 0x0144 | 0x0145 | 0x0201 | 0x0202 | 0x02bc | 0x83bb | 0x8773) + && group != "GPS" + { + continue; + } + out.push(TagRow { group, tag: e.tag, name: name(group, e.tag), value: value_text(group, e.tag, &e.value) }); + } +} + +/// The IFD holding a raw file's sensor data when it is not IFD0 (a colour-filter-array or linear-raw +/// image in a sub-IFD). IFD0 of such files describes a thumbnail, so its Compression is not the raw's. +fn raw_image_ifd(ifd0: &Ifd) -> Option<&Ifd> { + ifd0.sub_ifds.iter().find(|s| matches!(s.u32(0x0106), Some(32803 | 34892))) +} + +/// Nikon maker note `NEFCompression` (0x0093) spelled out, per the public Nikon tag documentation. +fn nef_compression(v: u32) -> Option<&'static str> { + Some(match v { + 1 => "Lossy (type 1)", + 2 => "Uncompressed", + 3 => "Lossless", + 4 => "Lossy (type 2)", + 5 => "Striped packed 12 bits", + 6 => "Uncompressed (reduced to 12 bit)", + 7 => "Unpacked 12 bits", + 8 => "Small", + 9 => "Packed 12 bits", + 10 => "Packed 14 bits", + 13 => "High Efficiency", + 14 => "High Efficiency*", + _ => return None, + }) +} + +/// Nikon maker note NEFCompression of a raw file, spelled out; `None` for other files or values. +fn nikon_nef_compression(tiff: &Tiff, bytes: &[u8]) -> Option<&'static str> { + let ifd0 = tiff.ifds.first()?; + let make = ifd0.string(0x010f)?; + if !make.to_ascii_uppercase().starts_with("NIKON") { + return None; + } + let e = ifd0.exif.as_ref()?.get(0x927c)?; + let mn = craft_tiff::makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make)?; + nef_compression(mn.ifd.u32(0x0093)?) +} + +/// The Panasonic maker note's lens name (LensType, `0x0051`) as a row, the name `photo.inspect` shows as the lens. +fn panasonic_lens_row(data: &[u8], tiff: &Tiff, out: &mut Vec) { + if let Some(lens) = crate::exif::panasonic_maker_note_lens(data, tiff) { + out.push(TagRow { group: "Panasonic", tag: 0x0051, name: "Lens Type".into(), value: lens.trim().to_string() }); + } +} + +/// Every tag of a parsed TIFF structure (a TIFF-based raw, a TIFF, or an EXIF block): IFD0, +/// then the EXIF, GPS and interoperability IFDs. A raw file's Compression row is the sensor +/// image's, not IFD0's thumbnail's. +pub fn tag_rows(tiff: &Tiff) -> Vec { + let mut out = Vec::new(); + if let Some(ifd0) = tiff.ifds.first() { + rows_of("TIFF", ifd0, &mut out); + if let Some(c) = raw_image_ifd(ifd0).and_then(|r| r.value(0x0103)) + && let Some(row) = out.iter_mut().find(|r| r.tag == 0x0103) + { + // lossless JPEG (DNG, CR2) on the sensor image; plain "JPEG" would read as lossy + row.value = if c.get_u64(0) == Some(7) { "Lossless JPEG".to_string() } else { value_text("TIFF", 0x0103, c) }; + } + if let Some(e) = &ifd0.exif { + rows_of("EXIF", e, &mut out); + if let Some(i) = &e.interop { + rows_of("Interop", i, &mut out); + } + } + if let Some(g) = &ifd0.gps { + rows_of("GPS", g, &mut out); + } + } + out +} + +/// [`tag_rows`] of a file's bytes: a TIFF-based file directly, else its embedded EXIF block +/// (JPEG, PNG, WebP, HEIF, PSD/PSB…). Empty when there is none. +pub fn file_tag_rows(bytes: &[u8]) -> Vec { + if Tiff::sniff(bytes).is_some() + && let Ok(t) = Tiff::parse(bytes) + { + let mut rows = tag_rows(&t); + if let Some(words) = nikon_nef_compression(&t, bytes) + && let Some(row) = rows.iter_mut().find(|r| r.group == "TIFF" && r.tag == 0x0103) + { + row.value = words.to_string(); + } + panasonic_lens_row(bytes, &t, &mut rows); + return rows; + } + let Some(exif) = crate::embedded(bytes).exif else { return Vec::new() }; + let data = crate::strip_exif_header(&exif); + let Ok(t) = Tiff::parse(data) else { return Vec::new() }; + let mut rows = tag_rows(&t); + panasonic_lens_row(data, &t, &mut rows); + rows +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rational_has_no_trailing_dot() { + assert_eq!(rational(1.0, 100000.0), "0"); + assert_eq!(rational(-1.0, 100000.0), "0"); + assert_eq!(rational(-1.0, 1e10), "0"); + assert_eq!(rational(1000001.0, 100000.0), "10"); + assert_eq!(rational(1.0, 3.0), "0.3333"); + assert_eq!(rational(5.0, 2.0), "2.5"); + } + + /// Rational lists were joined whole, unlike every other list: a tag of millions of + /// fractions became a string as long in the tag panel. + #[test] + fn rational_lists_show_sixteen_values_like_other_lists() { + let first16 = (1..=16).map(|i| i.to_string()).collect::>().join(", "); + let many: Vec<(u32, u32)> = (1..=1000).map(|i| (i, 1)).collect(); + assert_eq!(value_text("EXIF", 0x9999, &Value::Rational(many)), format!("{first16} … (1000 values)")); + let signed: Vec<(i32, i32)> = (1..=1000).map(|i| (-i, 1)).collect(); + assert!(value_text("EXIF", 0x9999, &Value::SRational(signed)).ends_with("-16 … (1000 values)")); + assert_eq!(value_text("EXIF", 0x9999, &Value::Rational(vec![(1, 2), (3, 4)])), "0.5, 0.75"); + } + + #[test] + fn rows_from_written_exif() { + let m = crate::Metadata { + make: Some("Maker".into()), + model: Some("Model X".into()), + exposure_time: Some(1.0 / 250.0), + f_number: Some(2.8), + iso: Some(400), + focal_length: Some(35.0), + gps: Some(crate::Gps { latitude: 51.5, longitude: -0.12, altitude: Some(20.0) }), + ..Default::default() + }; + let exif = crate::write_exif(&m); + let t = Tiff::parse(crate::strip_exif_header(&exif)).unwrap(); + let rows = tag_rows(&t); + let get = |n: &str| rows.iter().find(|r| r.name == n).map(|r| r.value.clone()); + assert_eq!(get("Make").as_deref(), Some("Maker")); + assert_eq!(get("Exposure Time").as_deref(), Some("1/250 s")); + assert_eq!(get("F-Number").as_deref(), Some("f/2.8")); + assert_eq!(get("Focal Length").as_deref(), Some("35 mm")); + assert_eq!(get("ISO Speed").as_deref(), Some("400")); + assert!(rows.iter().any(|r| r.group == "GPS" && r.name == "GPS Latitude"), "{rows:?}"); + assert!(!rows.iter().any(|r| r.tag == 0x8769), "IFD pointers are left out"); + } + + fn entry(tag: u16, ty: u16, count: u32, val: u32) -> Vec { + let mut e = Vec::new(); + e.extend(tag.to_le_bytes()); + e.extend(ty.to_le_bytes()); + e.extend(count.to_le_bytes()); + e.extend(val.to_le_bytes()); + e + } + + fn ifd(entries: &[Vec]) -> Vec { + let mut b = (entries.len() as u16).to_le_bytes().to_vec(); + entries.iter().for_each(|e| b.extend(e)); + b.extend(0u32.to_le_bytes()); + b + } + + /// A little-endian raw with a thumbnail IFD0 (`Compression` 1), a CFA sub-IFD (`Compression` + /// 34713) and, when `nef` is set, a Nikon maker note whose NEFCompression is that value. + fn synthetic_raw(nef: Option) -> Vec { + let make = b"NIKON CORPORATION"; + let mut note = b"NikonII*".to_vec(); + note.extend(ifd(&[entry(0x0093, 3, 1, nef.unwrap_or(0) as u32)])); + let ifd0_len = 2 + 4 * 12 + 4; + let sub_at = 8 + ifd0_len; + let sub = ifd(&[entry(0x0103, 3, 1, 34713), entry(0x0106, 3, 1, 32803)]); + let exif_at = sub_at + sub.len(); + let exif_len = 2 + 12 + 4; + let make_at = exif_at + exif_len; + let note_at = make_at + make.len(); + let mut b = b"II*".to_vec(); + b.extend(ifd(&[ + entry(0x0103, 3, 1, 1), + entry(0x010f, 2, make.len() as u32, make_at as u32), + entry(0x014a, 4, 1, sub_at as u32), + entry(0x8769, 4, 1, exif_at as u32), + ])); + b.extend(sub); + b.extend(ifd(&[entry(0x927c, 7, note.len() as u32, if nef.is_some() { note_at as u32 } else { 0 })])); + b.extend(make); + b.extend(note); + b + } + + fn compression(rows: &[TagRow]) -> Vec<&str> { + rows.iter().filter(|r| r.group == "TIFF" && r.tag == 0x0103).map(|r| r.value.as_str()).collect() + } + + #[test] + fn raw_compression_is_the_sensor_images_not_the_thumbnails() { + let b = synthetic_raw(None); + assert_eq!(compression(&file_tag_rows(&b)), ["Nikon NEF Compressed"]); + } + + #[test] + fn nef_compression_is_spelled_out_from_the_maker_note() { + for (v, words) in [(3, "Lossless"), (10, "Packed 14 bits"), (2, "Uncompressed"), (14, "High Efficiency*")] { + let b = synthetic_raw(Some(v)); + assert_eq!(compression(&file_tag_rows(&b)), [words]); + } + } + + #[test] + fn dng_raw_sub_ifd_jpeg_is_lossless_but_a_plain_jpeg_tiff_is_not() { + let b = synthetic_raw(None); + // switch the sensor sub-IFD's Compression from 34713 to 7 + let pos = b.windows(12).position(|w| w == entry(0x0103, 3, 1, 34713).as_slice()).unwrap(); + let mut dng = b.clone(); + dng[pos + 8] = 7; + dng[pos + 9] = 0; + assert_eq!(compression(&file_tag_rows(&dng)), ["Lossless JPEG"]); + let plain = [b"II*".to_vec(), ifd(&[entry(0x0103, 3, 1, 7)])].concat(); + assert_eq!(compression(&file_tag_rows(&plain)), ["JPEG"]); + } + + #[test] + fn plain_tiff_keeps_its_own_compression() { + let b = [b"II*".to_vec(), ifd(&[entry(0x0103, 3, 1, 8)])].concat(); + assert_eq!(compression(&file_tag_rows(&b)), ["Deflate"]); + } + + #[test] + fn panasonic_lens_is_listed_from_the_maker_note() { + let lens = |rows: &[TagRow]| rows.iter().filter(|r| r.group == "Panasonic" && r.tag == 0x0051).map(|r| r.value.clone()).collect::>(); + // a RW2: the note is only in the JpgFromRaw's Exif + let rw2 = crate::exif::tests::panasonic_rw2("LUMIX S 85/F1.8"); + assert_eq!(lens(&file_tag_rows(&rw2)), ["LUMIX S 85/F1.8"]); + // a JPEG: the note is in its own Exif + let exif = crate::exif::tests::panasonic_exif(craft_tiff::ByteOrder::Big, Some("LUMIX G 25/F1.7"), None); + let mut jpg = vec![0xff, 0xd8, 0xff, 0xe1]; + jpg.extend_from_slice(&((exif.len() + 8) as u16).to_be_bytes()); + jpg.extend(b"Exif\0\0"); + jpg.extend(exif); + jpg.extend_from_slice(&[0xff, 0xda, 0, 2, 0xff, 0xd9]); + assert_eq!(lens(&file_tag_rows(&jpg)), ["LUMIX G 25/F1.7"]); + // no Panasonic note, no row + assert!(lens(&file_tag_rows(&synthetic_raw(Some(3)))).is_empty()); + } +} diff --git a/crates/metadata/src/xmp.rs b/crates/metadata/src/xmp.rs new file mode 100644 index 00000000..e2af65fb --- /dev/null +++ b/crates/metadata/src/xmp.rs @@ -0,0 +1,813 @@ +//! 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 +//! any simple properties of one further namespace the caller names ([`XmpWrite`]). + +use crate::geom::{Point, Rect}; +use crate::{DateTime, Flash, Gps, Metadata, Orientation, Region, RegionKind, parse_number}; +use quick_xml::escape::{escape, partial_escape}; +use quick_xml::events::Event; +use std::collections::BTreeMap; + +/// A namespace beyond the standard ones, such as a program's own: properties in it are reported +/// under `prefix`, whatever prefix the packet binds the URI to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct XmpNamespace<'a> { + pub prefix: &'a str, + pub uri: &'a str, +} + +/// What [`write_xmp_with`] writes beside the metadata. +#[derive(Clone, Copy, Debug)] +pub struct XmpWrite<'a> { + /// The `x:xmptk` attribute: the name of the writing program. + pub toolkit: &'a str, + /// The namespace of `properties`. Without one, `properties` are not written. + pub namespace: Option>, + /// Simple properties in `namespace`, as `(local name, text)`. A name that is not ASCII letters, + /// digits and `_` is left out. + pub properties: &'a [(&'a str, &'a str)], +} + +impl Default for XmpWrite<'_> { + fn default() -> Self { + Self { toolkit: "craft-metadata", namespace: None, properties: &[] } + } +} + +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/"), + // Read-only: develop settings written by other raw developers. + ("crs", CRS_NS), + // Read-only: face/pet/focus regions written by Lightroom, digiKam, Picasa and others (MWG Region + // Guidelines v2.0). Canonicalising these three lets a packet use any prefix for them. + ("mwg-rs", "http://www.metadataworkinggroup.com/schemas/regions/"), + ("stArea", "http://ns.adobe.com/xmp/sType/Area#"), + ("stDim", "http://ns.adobe.com/xap/1.0/sType/Dimensions#"), +]; + +/// The camera-raw-settings namespace URI (prefix `crs`), read for interchange only. +pub const CRS_NS: &str = "http://ns.adobe.com/camera-raw-settings/1.0/"; + +/// 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, + /// Every property found, keyed `prefix:name` (struct fields as `prefix:name/prefix:field`); arrays keep + /// item order, language alternatives put `x-default` first. Foreign namespaces that reuse a recognized + /// prefix use `Q{hex-encoded namespace URI}local` instead, so they cannot impersonate known properties. + pub properties: BTreeMap>, + /// Every top-level property with its full structure (arrays of structs such as local + /// corrections, which [`XmpData::properties`] flattens). + pub values: BTreeMap, +} + +impl XmpData { + /// The pixel size the packet's regions were drawn on (`mwg-rs:AppliedToDimensions`), unless one of them says how + /// the photo was turned (`mwg-rs:Rotation`: Lightroom's boxes, which the reader already turns upright). It tells + /// boxes drawn on the photo as stored from boxes drawn on it upright: see [`crate::upright_regions`]. + pub fn regions_drawn_on(&self) -> Option<(f64, f64)> { + let regions = self.values.get("mwg-rs:Regions")?; + let turned = regions.field("mwg-rs:RegionList").is_some_and(|l| l.items().iter().any(|r| r.field("mwg-rs:Rotation").is_some())); + if turned { None } else { applied_to(regions) } + } +} + +/// A structured XMP value. +#[derive(Clone, Debug, PartialEq)] +pub enum XmpValue { + Text(String), + Array(Vec), + Struct(BTreeMap), +} + +impl XmpValue { + /// A struct field (`prefix:name`). + pub fn field(&self, k: &str) -> Option<&XmpValue> { + match self { + XmpValue::Struct(m) => m.get(k), + _ => None, + } + } + pub fn text(&self) -> Option<&str> { + match self { + XmpValue::Text(s) => Some(s.trim()), + XmpValue::Array(a) => a.first().and_then(XmpValue::text), + XmpValue::Struct(_) => None, + } + } + /// Array items (a lone value is a one-item array). + pub fn items(&self) -> &[XmpValue] { + match self { + XmpValue::Array(a) => a, + other => std::slice::from_ref(other), + } + } +} + +fn node_struct(n: &Node) -> XmpValue { + let mut m = BTreeMap::new(); + for (k, v) in &n.attrs { + if !is_rdf_meta_attr(k) { + m.insert(k.clone(), XmpValue::Text(v.clone())); + } + } + for c in &n.children { + m.insert(c.name.clone(), node_value(c)); + } + XmpValue::Struct(m) +} + +fn node_value(n: &Node) -> XmpValue { + if let Some(arr) = n.children.iter().find(|c| matches!(c.name.as_str(), "rdf:Seq" | "rdf:Bag" | "rdf:Alt")) { + return XmpValue::Array(arr.children.iter().filter(|li| li.name == "rdf:li").map(node_value).collect()); + } + if let Some(d) = n.children.iter().find(|c| c.name == "rdf:Description") { + return node_struct(d); + } + if !n.children.is_empty() || n.attrs.iter().any(|(k, _)| !is_rdf_meta_attr(k)) { + return node_struct(n); + } + XmpValue::Text(n.attr("rdf:resource").map(str::to_string).unwrap_or_else(|| n.text.trim().to_string())) +} + +#[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; +// Expanding a long namespace URI on many short names must not amplify a small packet without bound. +pub(crate) const MAX_OPAQUE_NAME_BYTES: usize = 8 * 1024 * 1024; + +/// Most text a converter's flattened property keys may add up to. Keys repeat their whole ancestor +/// path (`parent/field`), so a document with a long nested name and a wide child fan-out otherwise +/// retains its prefix once per property: node limits alone cannot bound that duplication. +const MAX_KEY_BYTES: usize = 1 << 20; + +/// Names canonicalised by namespace URI (see [`NAMESPACES`]); conflicting foreign prefixes are opaque. +pub(crate) fn canonical_name( + qname: &str, + scopes: &[Vec<(String, String)>], + extra: &[XmpNamespace], + opaque_budget: &mut usize, +) -> Result { + let known = || NAMESPACES.iter().copied().chain(extra.iter().map(|n| (n.prefix, n.uri))); + let (prefix, local) = qname.split_once(':').unwrap_or(("", qname)); + if prefix == "xml" || prefix == "xmlns" { + return Ok(qname.to_string()); + } + let uri = scopes.iter().rev().flat_map(|s| s.iter().rev()).find(|(p, _)| p == prefix).map(|(_, u)| u.as_str()); + Ok(match uri { + Some(u) => match known().find(|(_, nu)| *nu == u) { + Some((p, _)) => format!("{p}:{local}"), + None if known().any(|(p, _)| p == prefix) => { + // A foreign `app:settings` must never match the caller's `app:*` ownership rule (nor may a + // foreign `xmp:Rating` become a rating). Braces cannot occur in an XML QName; + // hex keeps URI slashes out of the flattened property's `/` path separators. + let size = + u.len().checked_mul(2).and_then(|n| n.checked_add(3)).and_then(|n| n.checked_add(local.len())).ok_or(XmpError::TooComplex)?; + *opaque_budget = opaque_budget.checked_sub(size).ok_or(XmpError::TooComplex)?; + let mut key = String::with_capacity(size); + key.push_str("Q{"); + for byte in u.bytes() { + for nibble in [byte >> 4, byte & 15] { + key.push(char::from(if nibble < 10 { b'0' + nibble } else { b'a' + (nibble - 10) })); + } + } + key.push('}'); + key.push_str(local); + key + } + 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) if h.bytes().all(|b| b.is_ascii_hexdigit()) => u32::from_str_radix(h, 16).ok()?, + Some(_) => return None, + None if n.bytes().all(|b| b.is_ascii_digit()) => n.parse().ok()?, + None => return None, + }; + char::from_u32(v).filter(|&c| is_xml_char(c))? + } + }) +} + +/// The XML 1.0 `Char` production (the surrogate range can't occur in a `char`). +fn is_xml_char(c: char) -> bool { + matches!(c, '\t' | '\n' | '\r') || (c >= '\u{20}' && c != '\u{FFFE}' && c != '\u{FFFF}') +} + +/// Drop the characters XML 1.0 forbids, which strict parsers (Adobe XMP Toolkit, expat, libxml2) reject the +/// whole document for. +fn xml_safe(s: &str) -> String { + s.chars().filter(|&c| is_xml_char(c)).collect() +} + +/// Index of the `;` that ends a reference starting at `rest` (its `&`). The scan stops at the first +/// byte that cannot appear in a name or a numeric reference, so zero-padded references such as +/// `a` are found and each run of name bytes is read once, not once per `&`. +fn entity_end(rest: &str) -> Option { + let n = rest.bytes().skip(1).take_while(|b| b.is_ascii_alphanumeric() || *b == b'#').count(); + (rest.as_bytes().get(n + 1) == Some(&b';')).then_some(n + 1) +} + +pub(crate) 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 entity_end(rest).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, extra: &[XmpNamespace]) -> 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; + let mut opaque_budget = MAX_OPAQUE_NAME_BYTES; + 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_name(&String::from_utf8_lossy(e.name().as_ref()), &scopes, extra, &mut opaque_budget)?; + let attrs = raw_attrs.into_iter().map(|(k, v)| Ok((canonical_name(&k, &scopes, extra, &mut opaque_budget)?, 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 Some(n) = stack.pop() else { return Err(XmpError::Xml("end tag without an open element".into())) }; + 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 Some(n) = stack.pop() else { return Err(XmpError::Xml("unterminated element without a node".into())) }; + let Some(parent) = stack.last_mut() else { return Err(XmpError::Xml("unterminated element without a parent".into())) }; + parent.children.push(n); + } + stack.pop().ok_or_else(|| XmpError::Xml("xmp packet without a root element".into())) +} + +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>, budget: &mut usize) { + for (k, v) in &d.attrs { + // the budget is checked before the path is built, not after + if is_rdf_meta_attr(k) || *budget < prefix.len().saturating_add(k.len()) { + continue; + } + let key = format!("{prefix}{k}"); + if take_key_bytes(&key, budget) { + props.entry(key).or_default().push(v.clone()); + } + } + for c in &d.children { + collect_property(c, prefix, props, budget); + } +} + +/// Charge one flattened key's length to the key budget; `false` when the budget is spent (the +/// property is then left out, like other elements a limit has cut). +fn take_key_bytes(key: &str, budget: &mut usize) -> bool { + let n = key.len(); + if *budget < n { + return false; + } + *budget -= n; + true +} + +fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap>, budget: &mut usize) { + // a path that can't fit the budget isn't built (a long ancestor path shared by many children + // would otherwise be copied for each of them after the budget is spent) + if *budget < prefix.len().saturating_add(p.name.len()) { + return; + } + 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); + } + if take_key_bytes(&key, budget) { + props.entry(key).or_default().extend(items.into_iter().map(|(_, v)| v)); + } + return; + } + let struct_prefix = format!("{key}/"); + // a struct's path is charged like a key before its fields copy it: that work too is bounded + // by the budget, kept fields or not + let is_struct = p.attr("rdf:parseType") == Some("Resource") || p.children.iter().any(|c| c.name == "rdf:Description"); + if is_struct && !take_key_bytes(&key, budget) { + return; + } + if p.attr("rdf:parseType") == Some("Resource") { + for c in &p.children { + collect_property(c, &struct_prefix, props, budget); + } + return; + } + if let Some(d) = p.children.iter().find(|c| c.name == "rdf:Description") { + collect_description(d, &struct_prefix, props, budget); + 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 { + let full = format!("{struct_prefix}{k}"); + if take_key_bytes(&full, budget) { + props.entry(full).or_default().push(v.clone()); + } + } + return; + } + let v = p.attr("rdf:resource").map(str::to_string).unwrap_or_else(|| p.text.trim().to_string()); + if take_key_bytes(&key, budget) { + 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) +} + +/// `mwg-rs:Regions` → every region in its `RegionList`, converted to the full-image normalized frame. +/// Malformed or out-of-range entries are skipped rather than rejecting the whole list: one bad region +/// (from Lightroom, digiKam, Picasa, or a hand-edited file) shouldn't hide the rest. +fn parse_regions(regions: &XmpValue) -> Vec { + // `AppliedToDimensions` documents the pixel size regions were authored against. Here it's only consulted + // for the rare region whose own Area is in pixel units instead of MWG's normalized default below. + let px_dims = applied_to(regions); + let Some(list) = regions.field("mwg-rs:RegionList") else { return Vec::new() }; + list.items().iter().filter_map(|item| parse_one_region(item, px_dims)).collect() +} + +/// `mwg-rs:AppliedToDimensions` of a `mwg-rs:Regions` struct: (width, height) in pixels, both positive. +fn applied_to(regions: &XmpValue) -> Option<(f64, f64)> { + regions.field("mwg-rs:AppliedToDimensions").and_then(|d| { + let w = d.field("stDim:w").and_then(XmpValue::text).and_then(parse_number)?; + let h = d.field("stDim:h").and_then(XmpValue::text).and_then(parse_number)?; + (w > 0.0 && h > 0.0).then_some((w, h)) + }) +} + +fn parse_one_region(item: &XmpValue, px_dims: Option<(f64, f64)>) -> Option { + let area = item.field("mwg-rs:Area")?; + let num_attr = |k: &str| area.field(k).and_then(XmpValue::text).and_then(parse_number); + // stArea:x/y are the region's CENTER, not its top-left corner (MWG Region Guidelines §Area). + let (cx, cy, w, h) = (num_attr("stArea:x")?, num_attr("stArea:y")?, num_attr("stArea:w")?, num_attr("stArea:h")?); + let finite_and_sane = [cx, cy, w, h].iter().all(|v| v.is_finite() && v.abs() <= 1.0e6); + if !finite_and_sane || w <= 0.0 || h <= 0.0 { + return None; + } + // `stArea:unit` is "normalized" (a fraction of AppliedToDimensions) in every writer we know of, and + // that needs no cross-referencing at all. Only a region that explicitly says "pixel" gets divided + // down, and only if AppliedToDimensions is there to divide by; otherwise it's dropped rather than + // stored as a nonsense fraction. + let is_pixels = area.field("stArea:unit").and_then(XmpValue::text).is_some_and(|u| u.eq_ignore_ascii_case("pixel")); + let (cx, cy, w, h) = match (is_pixels, px_dims) { + (false, _) => (cx, cy, w, h), + (true, Some((dw, dh))) => (cx / dw, cy / dh, w / dw, h / dh), + (true, None) => return None, + }; + // Lightroom Classic stores the box of a photo with Exif orientation 3 / 6 / 8 in the sensor's own + // frame and writes the orientation as the region's `mwg-rs:Rotation` (π for 3, −π/2 for 6, +π/2 for + // 8; 0 for 1) while `AppliedToDimensions` names the upright size. Turn it into the upright frame. + // MWG has no way to say "mirrored", so orientations 2/4/5/7 can't be told apart from their unmirrored + // twins here; boxes on mirrored photos are taken as written . + let rotation = item.field("mwg-rs:Rotation").and_then(XmpValue::text).and_then(parse_number).filter(|r| r.is_finite()); + let (quarter, half) = (std::f64::consts::FRAC_PI_2, std::f64::consts::PI); + let (cx, cy, w, h) = match rotation { + Some(r) if (r + quarter).abs() < 0.05 => (1.0 - cy, cx, h, w), + Some(r) if (r - quarter).abs() < 0.05 => (cy, 1.0 - cx, h, w), + Some(r) if (r.abs() - half).abs() < 0.05 => (1.0 - cx, 1.0 - cy, w, h), + _ => (cx, cy, w, h), + }; + // Keep only the part of the box inside the photo: a region hanging off an edge (common for faces + // cut by the frame) is clipped, and one entirely outside it (hostile or stale) is dropped, so every + // stored rect is within 0..1 and non-empty. + let rect = Rect::from_center(Point::new(cx, cy), w, h).intersect(&Rect::UNIT); + if rect.is_empty() { + return None; + } + let name = item.field("mwg-rs:Name").and_then(XmpValue::text).map(str::to_string).filter(|s| !s.is_empty()); + let description = item.field("mwg-rs:Description").and_then(XmpValue::text).map(str::to_string).filter(|s| !s.is_empty()); + let kind = match item.field("mwg-rs:Type").and_then(XmpValue::text).unwrap_or("") { + t if t.eq_ignore_ascii_case("face") => RegionKind::Face, + t if t.eq_ignore_ascii_case("pet") => RegionKind::Pet, + t if t.eq_ignore_ascii_case("focus") => RegionKind::Focus, + t if t.eq_ignore_ascii_case("barcode") => RegionKind::BarCode, + t => RegionKind::Other(t.to_string()), + }; + Some(Region { rect, kind, name, description }) +} + +fn fmt_gps_coord(v: f64, pos: char, neg: char) -> String { + let a = v.abs(); + let mut d = a.trunc(); + let mut m = ((a - d) * 60.0 * 1e6).round() / 1e6; + if m >= 60.0 { + d += 1.0; + m = 0.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 { + parse_xmp_with(s, &[]) +} + +/// [`parse_xmp`] that also knows the namespaces in `extra` by their URI. +pub fn parse_xmp_with(s: &str, extra: &[XmpNamespace]) -> Result { + let s = s.trim_start_matches('\u{feff}'); + let tree = parse_tree(s, extra)?; + let mut descs = Vec::new(); + find_descriptions(&tree, &mut descs); + let mut props = BTreeMap::new(); + let mut values = BTreeMap::new(); + // One budget for the whole document: several descriptions cannot each spend a fresh one. + let mut key_budget = MAX_KEY_BYTES; + for d in descs { + collect_description(d, "", &mut props, &mut key_budget); + if let XmpValue::Struct(m) = node_struct(d) { + values.extend(m); + } + } + 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").filter(|v| *v >= 0.0).or_else(|| num("exif:PixelXDimension").filter(|v| *v >= 0.0)).map(|v| v as u32), + height: num("tiff:ImageLength").filter(|v| *v >= 0.0).or_else(|| num("exif:PixelYDimension").filter(|v| *v >= 0.0)).map(|v| v as u32), + title: first("dc:title"), + caption: first("dc:description"), + alt_text: first("Iptc4xmpCore:AltTextAccessibility"), + extended_description: first("Iptc4xmpCore:ExtDescrAccessibility"), + sublocation: first("Iptc4xmpCore:Location"), + city: first("photoshop:City"), + state: first("photoshop:State"), + country: first("photoshop:Country"), + copyright: first("dc:rights"), + copyright_marked: first("xmpRights:Marked").and_then(|s| match s.to_ascii_lowercase().as_str() { + "true" => Some(true), + "false" => Some(false), + _ => None, + }), + usage_terms: first("xmpRights:UsageTerms"), + copyright_url: first("xmpRights:WebStatement"), + 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"), num("exifEX:PhotographicSensitivity")].into_iter().flatten().find(|v| *v > 0.0).map(|v| 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))) + && (-90.0..=90.0).contains(&lat) + && (-180.0..=180.0).contains(&lon) + { + 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 }); + } + m.regions = values.get("mwg-rs:Regions").map(parse_regions).unwrap_or_default(); + Ok(XmpData { metadata: m, properties: props, values }) +} + +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) = craft_tiff::writer::rational(v); + format!("{n}/{d}") +} + +fn sfrac(v: f64) -> String { + let (n, d) = craft_tiff::writer::srational(v); + format!("{n}/{d}") +} + +/// Serialise the interchange subset of `meta` as a complete XMP packet. +pub fn write_xmp(meta: &Metadata) -> String { + write_xmp_with(meta, &XmpWrite::default()) +} + +/// Like [`write_xmp`], with the writing program's name and its own simple properties. +pub fn write_xmp_with(meta: &Metadata, with: &XmpWrite) -> 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()); + push("Iptc4xmpCore:Location", meta.sublocation.clone()); + push("photoshop:City", meta.city.clone()); + push("photoshop:State", meta.state.clone()); + push("photoshop:Country", meta.country.clone()); + push("xmpRights:Marked", meta.copyright_marked.map(|m| if m { "True" } else { "False" }.to_string())); + push("xmpRights:WebStatement", meta.copyright_url.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())); + } + } + + let mut x = String::new(); + x.push_str("\n"); + x.push_str(&format!("\n", escape(xml_safe(with.toolkit)))); + x.push_str(" \n"); + x.push_str(" \n"); + for (k, v) in &simple { + x.push_str(&format!(" <{k}>{}\n", escape(xml_safe(v)))); + } + for (k, v) in with.properties.iter().filter(|_| own.is_some()) { + let p = own.map_or("", |n| n.0); + if !k.is_empty() && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { + // element content: quotes needn't be escaped, which keeps JSON readable + x.push_str(&format!(" <{p}:{k}>{}\n", partial_escape(xml_safe(v)))); + } + } + 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(xml_safe(it)))); + } else { + x.push_str(&format!(" {}\n", escape(xml_safe(it)))); + } + } + 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, "Iptc4xmpCore:AltTextAccessibility", "Alt", &one(&meta.alt_text), true); + array(&mut x, "Iptc4xmpCore:ExtDescrAccessibility", "Alt", &one(&meta.extended_description), true); + array(&mut x, "dc:rights", "Alt", &one(&meta.copyright), true); + array(&mut x, "xmpRights:UsageTerms", "Alt", &one(&meta.usage_terms), 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; diff --git a/crates/metadata/src/xmp/tests.rs b/crates/metadata/src/xmp/tests.rs new file mode 100644 index 00000000..7d7656fb --- /dev/null +++ b/crates/metadata/src/xmp/tests.rs @@ -0,0 +1,631 @@ +use super::*; + +/// A program's own namespace, as an application would pass it. +const LC: XmpNamespace = XmpNamespace { prefix: "lc", uri: "http://ns.example.com/lc/1.0/" }; + +fn write_lc(meta: &Metadata, properties: &[(&str, &str)]) -> String { + write_xmp_with(meta, &XmpWrite { toolkit: "Editor", namespace: Some(LC), properties }) +} + +fn parse_xmp(s: &str) -> Result { + parse_xmp_with(s, &[LC]) +} + +fn settings(d: &XmpData) -> Option<&str> { + d.properties.get("lc:settings").and_then(|v| v.first()).map(String::as_str) +} + +fn full() -> Metadata { + Metadata { + // write_xmp doesn't emit regions yet, so the + // roundtrip fixture must leave this empty or `roundtrip_all_fields` can't round-trip it. + regions: Vec::new(), + focus: None, + make: Some("Maker & Sons".into()), + model: Some("X <1>".into()), + serial_number: Some("SN1".into()), + software: Some("Editor".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()), + copyright_marked: Some(true), + usage_terms: Some("Editorial use only; no ".into()), + copyright_url: Some("https://example.com/licence?a=1&b=2".into()), + title: Some("Tïtle".into()), + caption: Some("Line one\nline two".into()), + alt_text: Some("A tree by the sea".into()), + extended_description: Some("Long ".into()), + sublocation: Some("Champ de Mars".into()), + city: Some("Paris".into()), + state: Some("Île-de-France".into()), + country: Some("France".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_lc(&m, &[("settings", json)]); + let d = parse_xmp(&x).unwrap(); + assert_eq!(settings(&d), 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()); + let d = parse_xmp(&x).unwrap(); + assert_eq!(d.metadata, Metadata::default()); + assert_eq!(settings(&d), None); +} + +#[test] +fn foreign_namespace_cannot_impersonate_known_metadata() { + for body in [ + r#"xmp:Rating="2" lc:settings="foreign settings"/>"#, + r#">2foreign settings"#, + ] { + let packet = format!( + r#""# + ); + let d = parse_xmp(&packet).unwrap(); + assert_eq!(d.metadata.rating, None, "foreign rating: {packet}"); + assert_eq!(settings(&d), None, "foreign settings: {packet}"); + assert!(!d.properties.contains_key("xmp:Rating")); + assert!(!d.values.contains_key("lc:settings")); + assert!(d.properties.values().any(|v| v == &["foreign settings"])); + assert!(d.properties.keys().all(|key| !key.contains('/')), "opaque names must not create struct paths"); + } +} + +#[test] +fn foreign_namespace_names_are_distinct_and_scoped() { + let packet = r#" + + 1 + 2 + 4 + "#; + let d = parse_xmp(packet).unwrap(); + assert_eq!(d.metadata.rating, Some(4), "a local rebinding must not escape its scope"); + assert_eq!(d.properties.len(), 3, "different foreign URIs must not collide"); + assert_eq!(d.values.len(), 3); + assert_eq!(d.properties["xmp:Rating"], vec!["4"]); + assert!(d.properties.values().any(|v| v == &["1"])); + assert!(d.properties.values().any(|v| v == &["2"])); +} + +#[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()]); +} + +/// Copyright status, usage terms and info URL (XMP Rights Management schema): written as +/// `xmpRights:Marked` (Boolean), `xmpRights:UsageTerms` (Lang Alt) and `xmpRights:WebStatement` (URI). +#[test] +fn rights_management_fields() { + let m = Metadata { copyright_marked: Some(false), usage_terms: Some("CC0".into()), ..Default::default() }; + let x = write_xmp(&m); + assert!(x.contains("False"), "{x}"); + assert!(x.contains("\n \n CC0"), "{x}"); + assert!(!x.contains("WebStatement"), "unset fields are left out"); + assert_eq!(parse_xmp(&x).unwrap().metadata, m); + // attribute form, other prefixes; anything but True / False is "unknown" + let read = |marked: &str| { + let x = format!( + r#" + + "# + ); + parse_xmp(&x).unwrap().metadata + }; + assert_eq!(read("True").copyright_marked, Some(true)); + assert_eq!(read("false").copyright_marked, Some(false)); + assert_eq!(read("").copyright_marked, None); + assert_eq!(read("maybe").copyright_marked, None); + assert_eq!(read("True").copyright_url.as_deref(), Some("https://example.org/c")); +} + +/// A zero or negative `exif:ISOSpeedRatings` is "no ISO" (as in the EXIF reader), not `Some(0)`, and +/// must not hide a valid `exifEX:PhotographicSensitivity`. +#[test] +fn non_positive_iso_is_ignored() { + let read = |attrs: &str| { + let x = format!( + r#" + + "# + ); + parse_xmp(&x).unwrap().metadata.iso + }; + assert_eq!(read(r#"exif:ISOSpeedRatings="0" exifEX:PhotographicSensitivity="400""#), Some(400)); + assert_eq!(read(r#"exif:ISOSpeedRatings="-100""#), None); + assert_eq!(read(r#"exif:ISOSpeedRatings="0""#), None); + assert_eq!(read(r#"exif:ISOSpeedRatings="200""#), Some(200)); +} + +#[test] +fn negative_xmp_pixel_width_is_ignored() { + let read = |attrs: &str| { + let x = format!( + r#" + + "# + ); + parse_xmp(&x).unwrap().metadata.width + }; + + assert_eq!(read(r#"tiff:ImageWidth="-1""#), None); + assert_eq!(read(r#"tiff:ImageWidth="-1" exif:PixelXDimension="640""#), Some(640)); + assert_eq!(read(r#"exif:PixelXDimension="-1""#), None); +} + +#[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_lc(&full(), &[("settings", "{}")]); + for n in (0..x.len()).step_by(7) { + if x.is_char_boundary(n) { + let _ = parse_xmp(&x[..n]); + } + } +} + +#[test] +fn lc_extras_and_crs_prefix() { + let x = write_lc(&Metadata::default(), &[("settings", "{\"a\":1}"), ("flag", "pick"), ("bad name", "x")]); + assert!(!x.contains("xmlns:crs"), "we never write crs:"); + let d = parse_xmp(&x).unwrap(); + assert_eq!(settings(&d), Some("{\"a\":1}")); + assert_eq!(d.properties["lc:flag"], vec!["pick".to_string()]); + assert!(!x.contains("bad name")); + let foreign = r#""#; + assert_eq!(parse_xmp(foreign).unwrap().properties["crs:Exposure2012"], vec!["+0.5".to_string()]); +} + +#[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"); + assert_eq!(fmt_gps_coord(10.9999999999, 'N', 'S'), "11,0.000000N"); + assert_eq!(fmt_gps_coord(-0.9999999999, 'E', 'W'), "1,0.000000W"); +} + +/// Like the EXIF and GPX readers, XMP drops a position outside latitude ±90° / longitude ±180°. +#[test] +fn xmp_gps_out_of_range_rejected() { + let gps = |lat: &str, lon: &str| { + let x = format!( + r#""# + ); + parse_xmp(&x).unwrap().metadata.gps + }; + assert_eq!(gps("95,0N", "200,30E"), None); + assert_eq!(gps("95,0N", "10,0E"), None); + assert_eq!(gps("10,0S", "200,30W"), None); + assert_eq!(gps("90,0,1N", "0,0E"), None); + assert_eq!(gps("90,0N", "180,0E"), Some(Gps { latitude: 90.0, longitude: 180.0, altitude: None })); + assert_eq!(gps("90,0S", "180,0W"), Some(Gps { latitude: -90.0, longitude: -180.0, altitude: None })); +} + +/// MWG Region Guidelines v2.0, element form (`rdf:parseType="Resource"`, self-closing `Area`) — how +/// Lightroom typically writes it. One named face, one untyped/unnamed region (still usable), one +/// region with a description. `AppliedToDimensions` is present but irrelevant: normalized areas +/// (the default, and the only unit real files use) don't need it. +#[test] +fn mwg_regions_element_form() { + let x = r#" + + + + + + + + Jane Doe + Face + + + + + + + Pet + Rex + Good boy + + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + assert_eq!(regions.len(), 3, "{regions:?}"); + + assert_eq!(regions[0].kind, RegionKind::Face); + assert_eq!(regions[0].name.as_deref(), Some("Jane Doe")); + assert_eq!(regions[0].description, None); + let r = regions[0].rect; + assert!((r.x0 - 0.4).abs() < 1e-9 && (r.x1 - 0.6).abs() < 1e-9, "{r:?}"); + assert!((r.y0 - 0.25).abs() < 1e-9 && (r.y1 - 0.55).abs() < 1e-9, "{r:?}"); + + // No mwg-rs:Type or mwg-rs:Name at all: still a usable region, kind falls back to `Other("")`. + assert_eq!(regions[1].kind, RegionKind::Other(String::new())); + assert_eq!(regions[1].name, None); + + assert_eq!(regions[2].kind, RegionKind::Pet); + assert_eq!(regions[2].name.as_deref(), Some("Rex")); + assert_eq!(regions[2].description.as_deref(), Some("Good boy")); +} + +/// Lightroom Classic writes a rotated photo's (Exif orientation 3 / 6 / 8) box in the sensor's frame +/// and records the orientation as `mwg-rs:Rotation` (±π / −π/2 / +π/2); the reader returns the +/// upright frame. +#[test] +fn mwg_regions_quarter_turn_rotation_is_mapped_to_the_upright_frame() { + let x = r#" + + + + + + + + + + + + + + + + + + + + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + assert_eq!(regions.len(), 5, "{regions:?}"); + let near = |r: Rect, b: [f64; 4]| [r.x0 - b[0], r.y0 - b[1], r.x1 - b[2], r.y1 - b[3]].iter().all(|d| d.abs() < 1e-9); + assert!(near(regions[0].rect, [0.45, 0.2, 0.75, 0.4]), "−π/2 (orientation 6): {:?}", regions[0].rect); + assert!(near(regions[1].rect, [0.25, 0.6, 0.55, 0.8]), "+π/2 (orientation 8): {:?}", regions[1].rect); + assert!(near(regions[2].rect, [0.2, 0.25, 0.4, 0.55]), "0 is untouched: {:?}", regions[2].rect); + assert!(near(regions[3].rect, [0.6, 0.45, 0.8, 0.75]), "π (orientation 3): {:?}", regions[3].rect); + assert!(near(regions[4].rect, [0.6, 0.45, 0.8, 0.75]), "−π (orientation 3): {:?}", regions[4].rect); +} + +/// The size a packet's boxes were drawn on is its `AppliedToDimensions`, unless a box says how the photo was turned +/// (Lightroom's `mwg-rs:Rotation`: those are upright already) or there is no size to go by. +#[test] +fn regions_drawn_on_is_the_applied_to_size_unless_a_box_says_how_it_was_turned() { + let packet = |dims: &str, rotation: &str| { + format!( + r#" + + {dims} + + + + + + + + + + "# + ) + }; + let dims = r#""#; + let drawn_on = |x: &str| parse_xmp(x).unwrap().regions_drawn_on(); + assert_eq!(drawn_on(&packet(dims, "")), Some((6000.0, 4000.0))); + assert_eq!(drawn_on(&packet(dims, r#" mwg-rs:Rotation="-1.57080""#)), None, "one box turned by Lightroom"); + assert_eq!(drawn_on(&packet("", "")), None, "no AppliedToDimensions"); + assert_eq!(drawn_on(r#""#), None); +} + +/// A second, differently-shaped encoding: each region is its own `rdf:Description` (rather than an +/// `rdf:li rdf:parseType="Resource"`), `mwg-rs:Type` as an attribute instead of an element, and an +/// unrecognised `mwg-rs:Type` value kept verbatim. +#[test] +fn mwg_regions_attribute_form_and_unknown_type() { + let x = r#" + + + + + + + + + + + + + + + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + assert_eq!(regions.len(), 2, "{regions:?}"); + assert_eq!(regions[0].kind, RegionKind::BarCode); + assert_eq!(regions[1].kind, RegionKind::Other("Sticker".to_string())); +} + +/// Hostile/malformed regions are dropped individually, never panic, and never poison the rest of +/// the list: missing Area, non-finite or absurd numbers, zero/negative size, and a pixel-unit area +/// with no `AppliedToDimensions` to convert it (so it can't be stored as a nonsense fraction). +#[test] +fn mwg_regions_hostile_inputs_are_dropped_not_panicking() { + let x = r#" + + + + + No Area + + + + + + Face + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + assert_eq!(regions.len(), 1, "only the last, well-formed region should survive: {regions:?}"); + assert_eq!(regions[0].kind, RegionKind::Face); +} + +/// Regions are clipped to the photo frame, and ones that miss the photo entirely are dropped: a +/// hostile sidecar can't park a box at x=1e5 (or a 1e5-wide one) that the loupe or the People view +/// would then draw or crop far outside the image. +#[test] +fn mwg_regions_are_clipped_to_the_frame_and_off_photo_ones_dropped() { + let x = r#" + + + + + Far right + Far left + Touching edge + Huge + Corner + + + + "#; + let regions = parse_xmp(x).unwrap().metadata.regions; + let names: Vec<_> = regions.iter().map(|r| r.name.as_deref().unwrap_or("")).collect(); + assert_eq!(names, ["Huge", "Corner"], "{regions:?}"); + for r in ®ions { + let q = r.rect; + assert!(q.x0 >= 0.0 && q.y0 >= 0.0 && q.x1 <= 1.0 && q.y1 <= 1.0 && !q.is_empty(), "{q:?}"); + } + assert_eq!(regions[0].rect, Rect::UNIT); + let c = regions[1].rect; + assert!((c.x0 - 0.85).abs() < 1e-9 && c.x1 == 1.0 && c.y0 == 0.0 && (c.y1 - 0.15).abs() < 1e-9, "{c:?}"); +} + +/// `extract()`'s precedence (EXIF/IPTC never carry regions; XMP is read and filled in like keywords). +#[test] +fn regions_merge_like_keywords() { + let with_region = Region { rect: Rect::from_center(Point::new(0.5, 0.5), 0.2, 0.2), kind: RegionKind::Face, name: None, description: None }; + let mut a = Metadata::default(); + let b = Metadata { regions: vec![with_region.clone()], ..Default::default() }; + a.fill_missing(&b); + assert_eq!(a.regions, vec![with_region.clone()]); + + let mut a = Metadata::default(); + a.overlay_user_fields(&b); + assert_eq!(a.regions, vec![with_region]); +} + +#[test] +fn writer_drops_characters_xml_1_0_forbids() { + let bad = "a\u{1}b\u{0}c\u{FFFE}d\u{FFFF}e\u{1F}\tf\ng\rh"; + let m = Metadata { make: Some(bad.into()), caption: Some(bad.into()), keywords: vec![bad.into()], ..Default::default() }; + let x = write_lc(&m, &[("settings", bad)]); + let illegal: Vec = + x.chars().filter(|&c| (c < '\u{20}' && !matches!(c, '\t' | '\n' | '\r')) || c == '\u{FFFE}' || c == '\u{FFFF}').collect(); + assert!(illegal.is_empty(), "illegal XML chars in output: {illegal:?}"); + assert!(x.contains(">abcde\tf\ng\rh<"), "allowed characters must survive: {x}"); +} + +#[test] +fn numeric_references_to_forbidden_characters_are_not_decoded() { + assert_eq!(entity("#1"), None); + assert_eq!(entity("#0"), None); + assert_eq!(entity("#xFFFE"), None); + assert_eq!(entity("#x41"), Some('A')); + assert_eq!(entity("#9"), Some('\t')); +} + +/// Flattened keys repeat their whole ancestor path, so a long nested name with a wide child +/// fan-out would otherwise retain its prefix once per leaf: the cumulative key budget stops it +/// (a 65 KB document with 3000 leaves must not retain ~2.4 MB of key text). +#[test] +fn flattened_keys_stay_within_the_cumulative_budget() { + let level = "n".repeat(200); + let mut doc = String::from( + "", + ); + for _ in 0..4 { + doc.push_str(&format!("")); + } + // each leaf has a name of its own, so each one adds a key + for i in 0..3000 { + doc.push_str(&format!("")); + } + doc.push_str(""); + for _ in 0..4 { + doc.push_str(&format!("")); + } + doc.push_str(""); + let d = parse_xmp(&doc).unwrap(); + let total: usize = d.properties.keys().map(String::len).sum(); + assert!(total <= MAX_KEY_BYTES, "flattened-key text {total} is over the {MAX_KEY_BYTES} budget"); + assert!(d.properties.len() < 3000, "keys stop being added once the budget is spent"); + assert!(!d.properties.is_empty(), "properties parsed before the budget are kept"); +} + +#[test] +fn zero_padded_numeric_references_decode() { + assert_eq!(unescape_attr("Canon A A"), "Canon A A"); + assert_eq!(unescape_attr("&abc&#xyz;&;"), "&abc&#xyz;&;"); +} + +#[test] +fn entity_rejects_signed_numeric_references() { + assert_eq!(entity("#+65"), None); + assert_eq!(entity("#x+41"), None); + assert_eq!(entity("#65"), Some('A')); + assert_eq!(entity("#x41"), Some('A')); +} + +#[test] +fn malformed_capture_time_falls_back_and_keeps_raw_property() { + let packet = r#""#; + let parsed = parse_xmp(packet).unwrap(); + assert_eq!( + parsed.metadata.capture_time.map(|d| (d.year, d.month, d.day, d.hour, d.minute, d.second, d.offset_minutes)), + Some((2026, 1, 2, 10, 0, 0, Some(0))) + ); + assert_eq!(parsed.properties["exif:DateTimeOriginal"], vec!["2026-01-01T".to_string()]); +} + +#[test] +fn malformed_capture_time_without_fallback_is_not_projected() { + let packet = r#""#; + let parsed = parse_xmp(packet).unwrap(); + assert_eq!(parsed.metadata.capture_time, None); + assert_eq!(parsed.properties["exif:DateTimeOriginal"], vec!["2026-01-01T".to_string()]); +} + +#[test] +fn many_ampersands_before_one_semicolon_are_read_once() { + // 200 000 `&` then a `;`: each `&` once searched the whole rest of the attribute for the `;` + let hostile = format!("{};&", "&".repeat(200_000)); + assert_eq!(unescape_attr(&hostile), format!("{};&", "&".repeat(200_000))); + assert_eq!(unescape_attr("<a> 􏿿 &unknownreference;"), " \u{10FFFF} &unknownreference;"); +} + +/// Children that add no key (empty structs) still had their whole ancestor path copied, each, +/// after the budget was spent: a 250 KB path under 100 000 such children was 25 GB of copying. +#[test] +fn paths_are_not_built_past_the_key_budget() { + let level = "n".repeat(60_000); + let mut doc = String::from( + "", + ); + for _ in 0..4 { + doc.push_str(&format!("")); + } + for _ in 0..100_000 { + doc.push_str(""); + } + for _ in 0..4 { + doc.push_str(&format!("")); + } + doc.push_str(""); + let t = std::time::Instant::now(); + let _ = parse_xmp(&doc); + assert!(t.elapsed().as_secs() < 10, "took {:?}", t.elapsed()); +} + +/// The caller's namespace is declared where the read-only `crs` sits in the table, is known to the +/// reader only when passed, and is canonicalised by its URI whatever prefix a packet binds it to. +#[test] +fn the_callers_namespace_is_written_and_read_only_when_passed() { + let x = write_lc(&Metadata::default(), &[("flag", "pick")]); + assert!(x.contains("x:xmptk=\"Editor\"")); + assert!(x.contains( + "xmlns:Iptc4xmpCore=\"http://iptc.org/std/Iptc4xmpCore/1.0/xmlns/\"\n xmlns:lc=\"http://ns.example.com/lc/1.0/\"\n xmlns:mwg-rs=" + )); + assert!(!x.contains("xmlns:crs")); + let renamed = x.replace("xmlns:lc=", "xmlns:zz=").replace("lc:flag", "zz:flag"); + assert_eq!(parse_xmp_with(&renamed, &[LC]).unwrap().properties["lc:flag"], vec!["pick".to_string()]); + assert!(!crate::parse_xmp(&renamed).unwrap().properties.contains_key("lc:flag")); + + let plain = write_xmp(&Metadata::default()); + assert!(plain.contains("x:xmptk=\"craft-metadata\"") && !plain.contains("xmlns:lc") && !plain.contains("xmlns:crs")); + // properties without a namespace to put them in are not written + let orphan = write_xmp_with(&Metadata::default(), &XmpWrite { properties: &[("flag", "pick")], ..Default::default() }); + assert_eq!(orphan, plain); +} diff --git a/crates/metadata/src/xmp_merge.rs b/crates/metadata/src/xmp_merge.rs new file mode 100644 index 00000000..b52e81c2 --- /dev/null +++ b/crates/metadata/src/xmp_merge.rs @@ -0,0 +1,505 @@ +//! Merging a freshly written XMP packet into an existing one (a sidecar another application +//! wrote, e.g. with its develop settings and edit history), keeping everything the writer does not +//! own byte for byte. +//! +//! The existing packet is scanned once (namespace-resolved, with byte positions). Only top-level +//! properties of the `rdf:Description` elements directly under `rdf:RDF` are considered, in +//! attribute form (`xmp:Rating="3"`) and element form (`…`): +//! - properties the writer owns ([`MergeRules::owned`]) are removed, and written from the fresh +//! packet when it has them (so a cleared field is cleared in the file too); +//! - properties owned only when stated ([`MergeRules::owned_if_present`]) are replaced when the +//! fresh packet has them and kept otherwise; +//! - any other property of the fresh packet (e.g. `xmp:CreatorTool`) is not written: the file +//! keeps describing itself as its creator left it; +//! - everything else — other namespaces (e.g. `crs:`), `xmpMM:History`, unknown properties, +//! comments, the packet wrapper and padding — is copied unchanged. +//! +//! The written properties go into one new `rdf:Description` (declaring its own namespaces, so +//! the existing prefix bindings never matter) just before ``. A description left empty +//! by the removals is dropped, so repeated saves don't accumulate. The result is checked by +//! parsing it again: every written property must read back as in the fresh packet. + +use std::collections::{BTreeSet, HashSet}; + +use quick_xml::events::Event; + +use crate::xmp::{MAX_OPAQUE_NAME_BYTES, XmpError, XmpNamespace, canonical_name, parse_xmp_with, unescape_attr}; + +/// Which properties [`merge_xmp`] takes from the fresh packet. Names are canonical +/// `prefix:name` (prefixes as [`crate::parse_xmp`] reports them); `prefix:*` is a whole namespace. +#[derive(Clone, Copy, Debug, Default)] +pub struct MergeRules<'a> { + /// Always replaced: removed from the existing packet, written from the fresh one if stated. + pub owned: &'a [&'a str], + /// Replaced only when the fresh packet states them; otherwise the existing value stays. + pub owned_if_present: &'a [&'a str], + /// Namespaces beyond the standard ones that the names above use, as for [`crate::parse_xmp_with`]. + pub namespaces: &'a [XmpNamespace<'a>], +} + +fn listed(list: &[&str], name: &str) -> bool { + list.iter().any(|p| match p.strip_suffix('*') { + Some(ns) => name.starts_with(ns), + None => *p == name, + }) +} + +#[derive(Debug)] +struct Attr { + raw: String, + name: String, + /// The value as written (still escaped). + value: String, +} + +#[derive(Debug)] +struct Prop { + name: String, + start: usize, + end: usize, +} + +#[derive(Debug, Default)] +struct Desc { + tag_start: usize, + tag_end: usize, + /// The element name as written (`rdf:Description`, or another prefix for the RDF namespace). + raw_name: String, + empty: bool, + attrs: Vec, + props: Vec, + /// The end of `` (`tag_end` when self-closing). + end: usize, +} + +#[derive(Debug, Default)] +struct Scan { + descs: Vec, + /// Where the first `` starts. + rdf_close: Option, +} + +enum Kind { + Other, + Rdf, + Desc(usize), + Prop { desc: usize, start: usize, name: String }, +} + +fn is_meta_attr(name: &str) -> bool { + name.starts_with("xmlns") + || matches!(name, "rdf:about" | "rdf:ID" | "rdf:nodeID" | "xml:lang" | "rdf:parseType" | "rdf:resource" | "rdf:datatype") +} + +fn pos(r: &quick_xml::Reader<&[u8]>) -> usize { + usize::try_from(r.buffer_position()).unwrap_or(usize::MAX) +} + +/// Locate the descriptions, their top-level properties and `` in a packet. +fn scan(s: &str, extra: &[XmpNamespace]) -> Result { + let mut reader = quick_xml::Reader::from_reader(s.as_bytes()); + reader.config_mut().check_end_names = true; + let mut out = Scan::default(); + let mut stack: Vec = Vec::new(); + let mut scopes: Vec> = vec![vec![("xml".into(), "http://www.w3.org/XML/1998/namespace".into())]]; + let mut count = 0usize; + let mut opaque_budget = MAX_OPAQUE_NAME_BYTES; + loop { + let before = pos(&reader); + let ev = reader.read_event().map_err(|e| XmpError::Xml(e.to_string()))?; + let after = pos(&reader); + match ev { + Event::Start(ref e) | Event::Empty(ref e) => { + count += 1; + if count > 200_000 || stack.len() > 64 { + return Err(XmpError::TooComplex); + } + let empty = matches!(ev, Event::Empty(_)); + let mut scope = Vec::new(); + let mut raw_attrs = Vec::new(); + // repeated names are refused with a set, in linear time (quick-xml's own check is quadratic) + let mut seen = HashSet::new(); + for a in e.attributes().with_checks(false) { + let a = a.map_err(|e| XmpError::Xml(e.to_string()))?; + let k = String::from_utf8_lossy(a.key.as_ref()).into_owned(); + if !seen.insert(k.clone()) { + return Err(XmpError::Xml(format!("repeated attribute {k}"))); + } + let v = String::from_utf8_lossy(&a.value).into_owned(); + if k == "xmlns" { + scope.push((String::new(), unescape_attr(&v))); + } else if let Some(p) = k.strip_prefix("xmlns:") { + scope.push((p.to_string(), unescape_attr(&v))); + } + raw_attrs.push((k, v)); + } + scopes.push(scope); + let raw_name = String::from_utf8_lossy(e.name().as_ref()).into_owned(); + let name = canonical_name(&raw_name, &scopes, extra, &mut opaque_budget)?; + let kind = match stack.last() { + Some(Kind::Rdf) if name == "rdf:Description" => { + let attrs = raw_attrs + .into_iter() + .map(|(raw, value)| Ok(Attr { name: canonical_name(&raw, &scopes, extra, &mut opaque_budget)?, raw, value })) + .collect::>()?; + out.descs.push(Desc { tag_start: before, tag_end: after, raw_name, empty, attrs, props: Vec::new(), end: after }); + Kind::Desc(out.descs.len() - 1) + } + Some(Kind::Desc(i)) => Kind::Prop { desc: *i, start: before, name }, + _ if name == "rdf:RDF" && out.rdf_close.is_none() => Kind::Rdf, + _ => Kind::Other, + }; + if empty { + scopes.pop(); + if let Kind::Prop { desc, start, name } = kind + && let Some(d) = out.descs.get_mut(desc) + { + d.props.push(Prop { name, start, end: after }); + } + } else { + stack.push(kind); + } + } + Event::End(_) => { + scopes.pop(); + match stack.pop() { + Some(Kind::Prop { desc, start, name }) => { + if let Some(d) = out.descs.get_mut(desc) { + d.props.push(Prop { name, start, end: after }); + } + } + Some(Kind::Desc(i)) => { + if let Some(d) = out.descs.get_mut(i) { + d.end = after; + } + } + Some(Kind::Rdf) => out.rdf_close = out.rdf_close.or(Some(before)), + Some(Kind::Other) => {} + None => return Err(XmpError::Xml("unbalanced end tag".into())), + } + } + Event::Eof => break, + _ => {} + } + } + if !stack.is_empty() { + return Err(XmpError::Xml("unclosed element".into())); + } + Ok(out) +} + +/// `start` moved back over the indentation and line break before it (so a removed property +/// takes its line with it). +fn line_start(s: &str, start: usize) -> usize { + let b = s.as_bytes(); + let mut i = start; + while i > 0 && matches!(b.get(i - 1), Some(b' ' | b'\t')) { + i -= 1; + } + if i > 0 && b.get(i - 1) == Some(&b'\n') { + i -= 1; + if i > 0 && b.get(i - 1) == Some(&b'\r') { + i -= 1; + } + } + i +} + +/// Merge `fresh` (a packet as [`crate::write_xmp_with`] writes it) into `existing` following +/// `rules` (see the module docs). Fails — and the caller must not overwrite `existing` blindly — +/// when `existing` isn't a well-formed XMP packet. +pub fn merge_xmp(existing: &str, fresh: &str, rules: MergeRules) -> Result { + let (bom, old) = match existing.strip_prefix('\u{feff}') { + Some(rest) => ("\u{feff}", rest), + None => ("", existing), + }; + parse_xmp_with(old, rules.namespaces)?; + let ex = scan(old, rules.namespaces)?; + let rdf_close = ex.rdf_close.ok_or_else(|| XmpError::Xml("no rdf:RDF element".into()))?; + let new = scan(fresh, rules.namespaces)?; + let fresh_desc = new.descs.first().ok_or_else(|| XmpError::Xml("the new packet has no rdf:Description".into()))?; + let fresh_names: BTreeSet<&str> = fresh_desc.props.iter().map(|p| p.name.as_str()).collect(); + let remove = |name: &str| listed(rules.owned, name) || (listed(rules.owned_if_present, name) && fresh_names.contains(name)); + let write = |name: &str| listed(rules.owned, name) || listed(rules.owned_if_present, name); + + // edits on `old`: (start, end, replacement), applied back to front + let mut edits: Vec<(usize, usize, String)> = Vec::new(); + for d in &ex.descs { + let gone_attrs: Vec<&Attr> = d.attrs.iter().filter(|a| !is_meta_attr(&a.name) && remove(&a.name)).collect(); + let gone_props: Vec<&Prop> = d.props.iter().filter(|p| remove(&p.name)).collect(); + if gone_attrs.is_empty() && gone_props.is_empty() { + continue; + } + let attrs_left = d.attrs.iter().any(|a| !is_meta_attr(&a.name) && !remove(&a.name)); + if !attrs_left && gone_props.len() == d.props.len() { + // nothing of its own left: drop the whole description + edits.push((line_start(old, d.tag_start), d.end, String::new())); + continue; + } + if !gone_attrs.is_empty() { + let mut tag = format!("<{}", d.raw_name); + for a in d.attrs.iter().filter(|a| is_meta_attr(&a.name) || !remove(&a.name)) { + tag.push_str(&format!("\n {}=\"{}\"", a.raw, a.value.replace('"', """))); + } + tag.push_str(if d.empty { "/>" } else { ">" }); + edits.push((d.tag_start, d.tag_end, tag)); + } + for p in gone_props { + edits.push((line_start(old, p.start), p.end, String::new())); + } + } + // the new description: its own namespace declarations, the properties to write + let mut block = String::from("\n '); + let mut written = 0; + for p in fresh_desc.props.iter().filter(|p| write(&p.name)) { + let text = fresh.get(p.start..p.end).ok_or_else(|| XmpError::Xml("bad property span".into()))?; + block.push_str("\n "); + block.push_str(text); + written += 1; + } + block.push_str("\n "); + if written > 0 { + let at = old.get(..rdf_close).map_or(rdf_close, |h| h.trim_end().len()); + edits.push((at, at, block)); + } + edits.sort_by_key(|e| (e.0, e.1)); + // overlapping edits can't happen (properties sit inside their description, which is either + // dropped whole or edited piecewise), but never splice blindly + let mut out = String::with_capacity(old.len() + 4096); + out.push_str(bom); + let mut at = 0; + for (s, e, r) in &edits { + if *s < at { + return Err(XmpError::Xml("overlapping edits".into())); + } + out.push_str(old.get(at..*s).ok_or_else(|| XmpError::Xml("bad span".into()))?); + out.push_str(r); + at = *e; + } + out.push_str(old.get(at..).ok_or_else(|| XmpError::Xml("bad span".into()))?); + + // read back: everything written must come out as written + let merged = parse_xmp_with(&out, rules.namespaces)?; + let want = parse_xmp_with(fresh, rules.namespaces)?; + for (k, v) in &want.properties { + let top = k.split('/').next().unwrap_or(k); + if write(top) && merged.properties.get(k) != Some(v) { + return Err(XmpError::Xml(format!("{k} did not survive the merge"))); + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::xmp::parse_xmp_with; + use crate::{Metadata, XmpData, XmpWrite, write_xmp_with}; + + /// A program's own namespace, as an application would pass it. + const LC: XmpNamespace = XmpNamespace { prefix: "lc", uri: "http://ns.example.com/lc/1.0/" }; + + fn write_lc(meta: &Metadata, properties: &[(&str, &str)]) -> String { + write_xmp_with(meta, &XmpWrite { toolkit: "Editor", namespace: Some(LC), properties }) + } + + fn parse_xmp(s: &str) -> Result { + parse_xmp_with(s, &[LC]) + } + + const RULES: MergeRules = MergeRules { + owned: &["xmp:Rating", "xmp:Label", "dc:subject", "dc:title", "lc:*"], + owned_if_present: &["exif:DateTimeOriginal"], + namespaces: &[LC], + }; + + /// A sidecar as another raw developer writes it: attribute-form simple properties, develop + /// settings, a structured history, an unknown namespace. + const FOREIGN: &str = " + + + + + + + + + + + 0, 0 + 255, 255 + + + + + old + + + + + +"; + + /// With the packet's whitespace padding and trailer. + fn foreign() -> String { + format!("{FOREIGN}{}\n", " ".repeat(100)) + } + + fn fresh(rating: i8, keywords: &[&str]) -> String { + let m = crate::Metadata { + software: Some("Editor".into()), + rating: Some(rating), + keywords: keywords.iter().map(|k| k.to_string()).collect(), + ..Default::default() + }; + write_lc(&m, &[("settings", "{\"a\":1}"), ("flag", "pick")]) + } + + #[test] + fn keeps_foreign_properties_and_replaces_owned_ones() { + let out = merge_xmp(&foreign(), &fresh(4, &["new", "two"]), RULES).unwrap(); + let d = parse_xmp(&out).unwrap(); + let p = |k: &str| d.properties.get(k).cloned().unwrap_or_default(); + assert_eq!(p("xmp:Rating"), vec!["4"]); + assert_eq!(p("dc:subject"), vec!["new", "two"]); + assert_eq!(p("lc:settings"), vec!["{\"a\":1}"]); + assert_eq!(p("lc:flag"), vec!["pick"]); + assert_eq!(p("xmp:CreatorTool"), vec!["Other App"], "not ours: kept"); + assert_eq!(p("crs:Exposure2012"), vec!["+0.50"]); + assert_eq!(p("foo:Thing").len(), 1); + // untouched parts are byte for byte the same + for part in [ + " \n \n \n \n ", + " \n \n 0, 0", + "crs:Contrast2012=\"-12\"", + "foo:Thing=\"a & b\"", + "\n", + &format!("\n{}\n", " ".repeat(100)), + ] { + assert!(out.contains(part), "lost {part:?} in\n{out}"); + } + assert!(!out.contains("xmp:Rating=\"2\"")); + assert!(!out.contains("old")); + // saving again changes only our part, and doesn't pile up descriptions + let again = merge_xmp(&out, &fresh(1, &[]), RULES).unwrap(); + let d = parse_xmp(&again).unwrap(); + assert_eq!(d.properties.get("xmp:Rating"), Some(&vec!["1".to_string()])); + assert!(!d.properties.contains_key("dc:subject"), "a cleared field is cleared"); + assert_eq!(again.matches("\ + "; + let out = merge_xmp(ex, &fresh(3, &[]), RULES).unwrap(); + let d = parse_xmp(&out).unwrap(); + assert_eq!(d.properties.get("xmp:Rating"), Some(&vec!["3".to_string()])); + assert_eq!(d.properties.get("exif:DateTimeOriginal"), Some(&vec!["2020-01-01T00:00:00".to_string()]), "fresh has none: kept"); + let mut m = crate::Metadata { rating: Some(3), ..Default::default() }; + m.capture_time = crate::DateTime::parse_iso("2024-05-06T07:08:09"); + let out = merge_xmp(ex, &write_lc(&m, &[]), RULES).unwrap(); + let d = parse_xmp(&out).unwrap(); + assert_eq!(d.properties.get("exif:DateTimeOriginal"), Some(&vec!["2024-05-06T07:08:09".to_string()])); + } + + #[test] + fn preserves_foreign_properties_using_known_prefixes() { + for property in [r#"lc:Secret="keep-me"/>"#, r#">keep-me"#] { + let desc = format!(r#"{desc}"#); + let out = merge_xmp(&old, &fresh(4, &[]), RULES).unwrap(); + assert!(out.contains(&desc), "lost foreign description: {out}"); + assert_eq!(parse_xmp(&out).unwrap().metadata.rating, Some(4)); + assert_eq!(merge_xmp(&out, &fresh(4, &[]), RULES).unwrap(), out); + } + } + + #[test] + fn namespace_aliases_remain_distinct_during_merge() { + // The alias URI includes a character reference: scanning and parsing must resolve it identically. + let foreign = r#""#; + let old = format!( + r#"{foreign} + + "# + ); + let out = merge_xmp(&old, &fresh(5, &[]), RULES).unwrap(); + assert!(out.contains(foreign), "lost foreign rating: {out}"); + assert!(!out.contains("a:Rating="), "genuine alias must be replaced: {out}"); + assert_eq!(parse_xmp(&out).unwrap().metadata.rating, Some(5)); + assert_eq!(merge_xmp(&out, &fresh(5, &[]), RULES).unwrap(), out); + } + + #[test] + fn opaque_namespace_expansion_is_bounded() { + // A roughly 40 KiB packet must not expand the same long URI into every property name indefinitely. + let packet = format!( + r#"{}"#, + "a".repeat(1024), + "".repeat(5000) + ); + assert_eq!(parse_xmp(&packet).unwrap_err(), XmpError::TooComplex); + assert!(matches!(scan(&packet, &[LC]), Err(XmpError::TooComplex))); + } + + #[test] + fn many_attributes_scan_in_linear_time() { + // quick-xml's duplicate-name check compares each attribute with all earlier ones: 200 000 attributes on + // one description took over 20 s to scan, about 30 ms with the set + let n = 200_000; + let mut s = String::from( + "\ + "); + let t = std::time::Instant::now(); + let found = scan(&s, &[LC]).unwrap(); + let took = t.elapsed(); + assert_eq!(found.descs[0].attrs.len(), n + 2, "every attribute is read"); + assert!(took < std::time::Duration::from_secs(5), "{n} attributes took {took:?}"); + } + + #[test] + fn refuses_what_it_cannot_read() { + for bad in ["", "not xml at all", "", ""] { + assert!(merge_xmp(bad, &fresh(1, &[]), RULES).is_err(), "{bad:?}"); + } + } + + #[test] + fn refuses_a_repeated_attribute() { + // not well-formed XML (strict readers such as expat refuse it): no merge, so the caller keeps a backup. + // An owned name, a foreign one and one on a nested element (a merge would keep both copies of the last two). + for (once, twice) in [ + ("xmp:Rating=\"2\"", "xmp:Rating=\"2\" xmp:Rating=\"3\""), + ("foo:Thing=\"a & b\"", "foo:Thing=\"a & b\" foo:Thing=\"c\""), + ("stEvt:action=\"saved\"", "stEvt:action=\"saved\" stEvt:action=\"saved\""), + ] { + let ex = foreign().replace(once, twice); + assert!(merge_xmp(&ex, &fresh(1, &[]), RULES).is_err(), "{twice}"); + } + } +} diff --git a/crates/metadata/tests/it/main.rs b/crates/metadata/tests/it/main.rs new file mode 100644 index 00000000..f56fd47f --- /dev/null +++ b/crates/metadata/tests/it/main.rs @@ -0,0 +1,3 @@ +//! The integration tests, one module per file, linked into one test binary. + +mod robust; diff --git a/crates/metadata/tests/it/robust.rs b/crates/metadata/tests/it/robust.rs new file mode 100644 index 00000000..ce2b8f40 --- /dev/null +++ b/crates/metadata/tests/it/robust.rs @@ -0,0 +1,70 @@ +use craft_metadata::{MergeRules, Metadata, XmpNamespace, XmpWrite, extract, merge_xmp, parse_iptc, parse_xmp_with, read_exif, write_xmp_with}; + +const LC: XmpNamespace = XmpNamespace { prefix: "lc", uri: "http://ns.example.com/lc/1.0/" }; + +fn write_xmp(meta: &Metadata, settings: Option<&str>) -> String { + let properties: Vec<(&str, &str)> = settings.map(|s| ("settings", s)).into_iter().collect(); + write_xmp_with(meta, &XmpWrite { toolkit: "Editor", namespace: Some(LC), properties: &properties }) +} + +fn parse_xmp(s: &str) -> Result { + parse_xmp_with(s, &[LC]) +} + +fn settings(d: &craft_metadata::XmpData) -> Option { + d.properties.get("lc:settings").and_then(|v| v.first()).cloned() +} +use proptest::prelude::*; + +proptest! { + #![proptest_config(ProptestConfig { cases: 1500, .. ProptestConfig::default() })] + + /// Random bytes behind each container's magic: reading them yields whatever can be read and + /// never panics. + #[test] + fn random_containers_never_panic(mut data in proptest::collection::vec(any::(), 0..600), kind in 0usize..7) { + let heads: [&[u8]; 7] = [b"\xff\xd8\xff\xe1", b"\x89PNG\r\n\x1a\n", b"RIFF\0\0\0\0WEBP", b"II*\0", b"MM\0*", b"\0\0\0\x18ftypcrx ", b"\0\0\0\x18ftypheic"]; + 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); + } + + /// Merging into arbitrary (also damaged) packets never panics, and a merge that succeeds + /// reads back with the written values. + #[test] + fn merge_never_panics(cut in 0usize..900, junk in ".{0,20}", rating in 0i8..=5) { + let base = write_xmp(&Metadata { title: Some("t".into()), rating: Some(1), ..Default::default() }, Some("{}")); + let mut s: String = base.chars().take(cut).collect(); + s.push_str(&junk); + s.extend(base.chars().skip(cut)); + let fresh = write_xmp(&Metadata { rating: Some(rating), ..Default::default() }, Some("{\"x\":1}")); + let rules = MergeRules { owned: &["xmp:Rating", "dc:title", "lc:*"], owned_if_present: &[], namespaces: &[LC] }; + if let Ok(out) = merge_xmp(&s, &fresh, rules) { + let d = parse_xmp(&out).unwrap(); + prop_assert_eq!(d.metadata.rating, Some(rating)); + prop_assert_eq!(settings(&d), Some("{\"x\":1}".to_string())); + } + } + + #[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!(settings(&d), Some(title.clone())); + prop_assert_eq!(d.metadata.title, Some(title)); + prop_assert_eq!(d.metadata.keywords, kw); + prop_assert_eq!(d.metadata.rating, Some(rating)); + } +}