mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-30 13:07:43 -06:00
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:
co-authored by
Claude Opus 5.5
parent
44d97a4104
commit
cbe9d8989a
Generated
+17
@@ -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"
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 & more é</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");
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user