M3.1: lightcraft-meta EXIF/XMP/IPTC

Metadata model; EXIF from TIFF/raw, JPEG APP1, PNG eXIf, WebP EXIF; XMP
read (namespace-canonicalised, arrays, structs) and write (dc/xmp/
photoshop/exif/exifEX/tiff/lr + lc:settings JSON); IPTC-IIM record 2;
container walkers for JPEG (incl. ICC and extended XMP), PNG, WebP.
Adds quick-xml and miniz_oxide to workspace deps.

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