mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-30 04:57:22 -06:00
Local corrections stored as crs: structures (MaskGroupBasedCorrections and the older gradient / circular gradient / paint containers) become our masks: linear and radial gradients, brushes (dab runs → strokes), Subject / Sky, luminance and depth ranges, with add / subtract / intersect and invert, and all local sliders. lightcraft-meta's XmpData gains `values`, a structured view of every property (arrays of structs survive); .lrtemplate Lua tables convert into the same shape. Applying a preset adds its masks to the photo's own (once; Amount scales them); sidecar masks replace them and radial masks are fitted to the photo's aspect. Unsupported components (colour range, other AI selections) are reported as "Mask: <kind>". Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
724 lines
30 KiB
Rust
724 lines
30 KiB
Rust
//! 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, partial_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),
|
||
// Read-only: develop settings written by other raw developers (interchange; see docs/xmp-interop.md).
|
||
("crs", CRS_NS),
|
||
];
|
||
|
||
/// The camera-raw-settings namespace URI (prefix `crs`), read for interchange only.
|
||
pub const CRS_NS: &str = "http://ns.adobe.com/camera-raw-settings/1.0/";
|
||
|
||
/// XMP read failure.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum XmpError {
|
||
Xml(String),
|
||
/// Nesting / size limits exceeded.
|
||
TooComplex,
|
||
}
|
||
|
||
impl std::fmt::Display for XmpError {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
match self {
|
||
XmpError::Xml(e) => write!(f, "XMP is not well-formed XML: {e}"),
|
||
XmpError::TooComplex => write!(f, "XMP packet too deeply nested or too large"),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl std::error::Error for XmpError {}
|
||
|
||
/// Result of [`parse_xmp`].
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub struct XmpData {
|
||
pub metadata: Metadata,
|
||
/// 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>>,
|
||
/// Every top-level property with its full structure (arrays of structs such as local
|
||
/// corrections, which [`XmpData::properties`] flattens).
|
||
pub values: BTreeMap<String, XmpValue>,
|
||
}
|
||
|
||
/// A structured XMP value.
|
||
#[derive(Clone, Debug, PartialEq)]
|
||
pub enum XmpValue {
|
||
Text(String),
|
||
Array(Vec<XmpValue>),
|
||
Struct(BTreeMap<String, XmpValue>),
|
||
}
|
||
|
||
impl XmpValue {
|
||
/// A struct field (`prefix:name`).
|
||
pub fn field(&self, k: &str) -> Option<&XmpValue> {
|
||
match self {
|
||
XmpValue::Struct(m) => m.get(k),
|
||
_ => None,
|
||
}
|
||
}
|
||
pub fn text(&self) -> Option<&str> {
|
||
match self {
|
||
XmpValue::Text(s) => Some(s.trim()),
|
||
XmpValue::Array(a) => a.first().and_then(XmpValue::text),
|
||
XmpValue::Struct(_) => None,
|
||
}
|
||
}
|
||
/// Array items (a lone value is a one-item array).
|
||
pub fn items(&self) -> &[XmpValue] {
|
||
match self {
|
||
XmpValue::Array(a) => a,
|
||
other => std::slice::from_ref(other),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn node_struct(n: &Node) -> XmpValue {
|
||
let mut m = BTreeMap::new();
|
||
for (k, v) in &n.attrs {
|
||
if !is_rdf_meta_attr(k) {
|
||
m.insert(k.clone(), XmpValue::Text(v.clone()));
|
||
}
|
||
}
|
||
for c in &n.children {
|
||
m.insert(c.name.clone(), node_value(c));
|
||
}
|
||
XmpValue::Struct(m)
|
||
}
|
||
|
||
fn node_value(n: &Node) -> XmpValue {
|
||
if let Some(arr) = n.children.iter().find(|c| matches!(c.name.as_str(), "rdf:Seq" | "rdf:Bag" | "rdf:Alt")) {
|
||
return XmpValue::Array(arr.children.iter().filter(|li| li.name == "rdf:li").map(node_value).collect());
|
||
}
|
||
if let Some(d) = n.children.iter().find(|c| c.name == "rdf:Description") {
|
||
return node_struct(d);
|
||
}
|
||
if !n.children.is_empty() || n.attrs.iter().any(|(k, _)| !is_rdf_meta_attr(k)) {
|
||
return node_struct(n);
|
||
}
|
||
XmpValue::Text(n.attr("rdf:resource").map(str::to_string).unwrap_or_else(|| n.text.trim().to_string()))
|
||
}
|
||
|
||
#[derive(Debug, Default)]
|
||
struct Node {
|
||
name: String,
|
||
attrs: Vec<(String, String)>,
|
||
children: Vec<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();
|
||
let mut values = BTreeMap::new();
|
||
for d in descs {
|
||
collect_description(d, "", &mut props);
|
||
if let XmpValue::Struct(m) = node_struct(d) {
|
||
values.extend(m);
|
||
}
|
||
}
|
||
let first = |k: &str| props.get(k).and_then(|v| v.first()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
|
||
let num = |k: &str| first(k).and_then(|s| parse_number(&s));
|
||
let mut m = Metadata {
|
||
make: first("tiff:Make"),
|
||
model: first("tiff:Model"),
|
||
serial_number: first("exifEX:BodySerialNumber").or_else(|| first("aux:SerialNumber")),
|
||
software: first("xmp:CreatorTool"),
|
||
lens_make: first("exifEX:LensMake"),
|
||
lens_model: first("exifEX:LensModel").or_else(|| first("aux:Lens")),
|
||
lens_serial_number: first("exifEX:LensSerialNumber").or_else(|| first("aux:LensSerialNumber")),
|
||
exposure_time: num("exif:ExposureTime").filter(|v| *v > 0.0),
|
||
f_number: num("exif:FNumber").filter(|v| *v > 0.0),
|
||
focal_length: num("exif:FocalLength").filter(|v| *v > 0.0),
|
||
focal_length_35mm: num("exif:FocalLengthIn35mmFilm").filter(|v| *v > 0.0),
|
||
exposure_bias: num("exif:ExposureBiasValue"),
|
||
exposure_program: num("exif:ExposureProgram").map(|v| v as u16),
|
||
metering_mode: num("exif:MeteringMode").map(|v| v as u16),
|
||
white_balance: num("exif:WhiteBalance").map(|v| v as u16),
|
||
orientation: num("tiff:Orientation").map(|v| v as u16).filter(|v| (1..=8).contains(v)).map(Orientation::from_exif),
|
||
width: num("tiff:ImageWidth").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"),
|
||
alt_text: first("Iptc4xmpCore:AltTextAccessibility"),
|
||
extended_description: first("Iptc4xmpCore:ExtDescrAccessibility"),
|
||
sublocation: first("Iptc4xmpCore:Location"),
|
||
city: first("photoshop:City"),
|
||
state: first("photoshop:State"),
|
||
country: first("photoshop:Country"),
|
||
copyright: first("dc:rights"),
|
||
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, values })
|
||
}
|
||
|
||
fn frac(v: f64) -> String {
|
||
if v > 0.0 && v < 1.0 {
|
||
let inv = 1.0 / v;
|
||
if (inv - inv.round()).abs() < 1e-6 {
|
||
return format!("1/{}", inv.round() as u64);
|
||
}
|
||
}
|
||
let (n, d) = 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 lc: Vec<(&str, &str)> = lc_settings.map(|s| ("settings", s)).into_iter().collect();
|
||
write_xmp_lc(meta, &lc)
|
||
}
|
||
|
||
/// Like [`write_xmp`], with any number of simple `lc:<name>` properties (e.g. `settings`, `flag`).
|
||
pub fn write_xmp_lc(meta: &Metadata, lc: &[(&str, &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());
|
||
push("Iptc4xmpCore:Location", meta.sublocation.clone());
|
||
push("photoshop:City", meta.city.clone());
|
||
push("photoshop:State", meta.state.clone());
|
||
push("photoshop:Country", meta.country.clone());
|
||
if let Some(g) = meta.gps {
|
||
push("exif:GPSLatitude", Some(fmt_gps_coord(g.latitude, 'N', 'S')));
|
||
push("exif:GPSLongitude", Some(fmt_gps_coord(g.longitude, 'E', 'W')));
|
||
if let Some(a) = g.altitude {
|
||
push("exif:GPSAltitude", Some(frac(a.abs())));
|
||
push("exif:GPSAltitudeRef", Some(if a < 0.0 { "1" } else { "0" }.into()));
|
||
}
|
||
}
|
||
|
||
let mut x = String::new();
|
||
x.push_str("<?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).filter(|(p, _)| *p != "crs") {
|
||
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())));
|
||
}
|
||
for (k, v) in lc {
|
||
if !k.is_empty() && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
|
||
// element content: quotes needn't be escaped, which keeps the JSON readable
|
||
x.push_str(&format!(" <lc:{k}>{}</lc:{k}>\n", partial_escape(*v)));
|
||
}
|
||
}
|
||
let array = |x: &mut String, k: &str, kind: &str, items: &[String], lang: bool| {
|
||
if items.is_empty() {
|
||
return;
|
||
}
|
||
x.push_str(&format!(" <{k}>\n <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, "Iptc4xmpCore:AltTextAccessibility", "Alt", &one(&meta.alt_text), true);
|
||
array(&mut x, "Iptc4xmpCore:ExtDescrAccessibility", "Alt", &one(&meta.extended_description), true);
|
||
array(&mut x, "dc:rights", "Alt", &one(&meta.copyright), true);
|
||
array(&mut x, "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()),
|
||
alt_text: Some("A tree by the sea".into()),
|
||
extended_description: Some("Long <description>".into()),
|
||
sublocation: Some("Champ de Mars".into()),
|
||
city: Some("Paris".into()),
|
||
state: Some("Île-de-France".into()),
|
||
country: Some("France".into()),
|
||
keywords: vec!["tree".into(), "sky & sea".into()],
|
||
hierarchical_keywords: vec!["Places|France|Paris".into()],
|
||
rating: Some(-1),
|
||
label: Some("Green".into()),
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn roundtrip_all_fields() {
|
||
let m = full();
|
||
let json = r#"{"version":1,"exposure":0.5,"note":"<&>"}"#;
|
||
let x = write_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 lc_extras_and_crs_prefix() {
|
||
let x = write_xmp_lc(&Metadata::default(), &[("settings", "{\"a\":1}"), ("flag", "pick"), ("bad name", "x")]);
|
||
assert!(!x.contains("xmlns:crs"), "we never write crs:");
|
||
let d = parse_xmp(&x).unwrap();
|
||
assert_eq!(d.lc_settings.as_deref(), Some("{\"a\":1}"));
|
||
assert_eq!(d.properties["lc:flag"], vec!["pick".to_string()]);
|
||
assert!(!x.contains("bad name"));
|
||
let foreign = r#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||
xmlns:cr="http://ns.adobe.com/camera-raw-settings/1.0/" cr:Exposure2012="+0.5"/>"#;
|
||
assert_eq!(parse_xmp(foreign).unwrap().properties["crs:Exposure2012"], vec!["+0.5".to_string()]);
|
||
}
|
||
|
||
#[test]
|
||
fn gps_coords() {
|
||
assert_eq!(parse_gps_coord("45,30N"), Some(45.5));
|
||
assert_eq!(parse_gps_coord("45,30,36S"), Some(-45.51));
|
||
assert_eq!(parse_gps_coord("45X"), None);
|
||
assert_eq!(parse_gps_coord(""), None);
|
||
assert_eq!(parse_gps_coord("é"), None);
|
||
assert_eq!(fmt_gps_coord(-45.51, 'N', 'S'), "45,30.600000S");
|
||
}
|
||
}
|