From dca2a2cd8e214b0c24d7fe0f222a803f63155d16 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 19:22:42 -0500 Subject: [PATCH] craft-metadata: EXIF, IPTC-IIM, XMP and GPX from LightCraft (#949) lightcraft-meta at 5b61ce1e, as one crate with a module per format: exif (read, write, maker-note lens, focus point, tag listing), iptc, xmp (parse, write, merge), gpx, and the JPEG, PNG, WebP, TIFF, RAF and CR3 containers. They share Metadata, DateTime, Gps and Orientation. Changed in the move: - TIFF comes from craft-tiff; Point, Rect and Orientation are the crate's own. - The app namespace is a parameter: parse_xmp_with, write_xmp_with and MergeRules::namespaces take it, and XmpData no longer has lc_settings. - The HEIF, PSD and MRW readers stay out, since craft-heif, craft-psd and craft-raw read those containers; from_embedded takes the blocks they find. --- Cargo.lock | 12 + Cargo.toml | 1 + README.md | 4 + crates/metadata/Cargo.toml | 21 + crates/metadata/README.md | 61 +++ crates/metadata/src/container.rs | 450 ++++++++++++++++ crates/metadata/src/cr3.rs | 742 ++++++++++++++++++++++++++ crates/metadata/src/datetime.rs | 320 ++++++++++++ crates/metadata/src/exif.rs | 759 +++++++++++++++++++++++++++ crates/metadata/src/focus.rs | 284 ++++++++++ crates/metadata/src/focus/tests.rs | 449 ++++++++++++++++ crates/metadata/src/focus_read.rs | 472 +++++++++++++++++ crates/metadata/src/geom.rs | 180 +++++++ crates/metadata/src/gpx.rs | 491 +++++++++++++++++ crates/metadata/src/iptc.rs | 151 ++++++ crates/metadata/src/lib.rs | 606 +++++++++++++++++++++ crates/metadata/src/tags.rs | 529 +++++++++++++++++++ crates/metadata/src/xmp.rs | 813 +++++++++++++++++++++++++++++ crates/metadata/src/xmp/tests.rs | 631 ++++++++++++++++++++++ crates/metadata/src/xmp_merge.rs | 505 ++++++++++++++++++ crates/metadata/tests/it/main.rs | 3 + crates/metadata/tests/it/robust.rs | 70 +++ 22 files changed, 7554 insertions(+) create mode 100644 crates/metadata/Cargo.toml create mode 100644 crates/metadata/README.md create mode 100644 crates/metadata/src/container.rs create mode 100644 crates/metadata/src/cr3.rs create mode 100644 crates/metadata/src/datetime.rs create mode 100644 crates/metadata/src/exif.rs create mode 100644 crates/metadata/src/focus.rs create mode 100644 crates/metadata/src/focus/tests.rs create mode 100644 crates/metadata/src/focus_read.rs create mode 100644 crates/metadata/src/geom.rs create mode 100644 crates/metadata/src/gpx.rs create mode 100644 crates/metadata/src/iptc.rs create mode 100644 crates/metadata/src/lib.rs create mode 100644 crates/metadata/src/tags.rs create mode 100644 crates/metadata/src/xmp.rs create mode 100644 crates/metadata/src/xmp/tests.rs create mode 100644 crates/metadata/src/xmp_merge.rs create mode 100644 crates/metadata/tests/it/main.rs create mode 100644 crates/metadata/tests/it/robust.rs 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)); + } +}