mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-29 12:37:51 -06:00
742 lines
33 KiB
Rust
742 lines
33 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, Region, RegionKind, parse_number};
|
||
use lightcraft_geom::{Point, Rect};
|
||
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),
|
||
// Read-only: face/pet/focus regions written by Lightroom, digiKam, Picasa and others (MWG Region
|
||
// Guidelines v2.0). Canonicalising these three lets a packet use any prefix for them.
|
||
("mwg-rs", "http://www.metadataworkinggroup.com/schemas/regions/"),
|
||
("stArea", "http://ns.adobe.com/xmp/sType/Area#"),
|
||
("stDim", "http://ns.adobe.com/xap/1.0/sType/Dimensions#"),
|
||
];
|
||
|
||
/// The camera-raw-settings namespace URI (prefix `crs`), read for interchange only.
|
||
pub const CRS_NS: &str = "http://ns.adobe.com/camera-raw-settings/1.0/";
|
||
|
||
/// XMP read failure.
|
||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||
pub enum XmpError {
|
||
Xml(String),
|
||
/// Nesting / size limits exceeded.
|
||
TooComplex,
|
||
}
|
||
|
||
impl std::fmt::Display for XmpError {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
match self {
|
||
XmpError::Xml(e) => write!(f, "XMP is not well-formed XML: {e}"),
|
||
XmpError::TooComplex => write!(f, "XMP packet too deeply nested or too large"),
|
||
}
|
||
}
|
||
}
|
||
|
||
impl std::error::Error for XmpError {}
|
||
|
||
/// Result of [`parse_xmp`].
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub struct XmpData {
|
||
pub metadata: Metadata,
|
||
/// 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. Foreign namespaces that reuse a recognized
|
||
/// prefix use `Q{hex-encoded namespace URI}local` instead, so they cannot impersonate known properties.
|
||
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;
|
||
// Expanding a long namespace URI on many short names must not amplify a small packet without bound.
|
||
pub(crate) const MAX_OPAQUE_NAME_BYTES: usize = 8 * 1024 * 1024;
|
||
|
||
/// Most text a converter's flattened property keys may add up to. Keys repeat their whole ancestor
|
||
/// path (`parent/field`), so a document with a long nested name and a wide child fan-out otherwise
|
||
/// retains its prefix once per property: node limits alone cannot bound that duplication.
|
||
const MAX_KEY_BYTES: usize = 1 << 20;
|
||
|
||
/// Names canonicalised by namespace URI (see [`NAMESPACES`]); conflicting foreign prefixes are opaque.
|
||
pub(crate) fn canonical_name(qname: &str, scopes: &[Vec<(String, String)>], opaque_budget: &mut usize) -> Result<String, XmpError> {
|
||
canonical(qname, scopes, opaque_budget)
|
||
}
|
||
|
||
fn canonical(qname: &str, scopes: &[Vec<(String, String)>], opaque_budget: &mut usize) -> Result<String, XmpError> {
|
||
let (prefix, local) = qname.split_once(':').unwrap_or(("", qname));
|
||
if prefix == "xml" || prefix == "xmlns" {
|
||
return Ok(qname.to_string());
|
||
}
|
||
let uri = scopes.iter().rev().flat_map(|s| s.iter().rev()).find(|(p, _)| p == prefix).map(|(_, u)| u.as_str());
|
||
Ok(match uri {
|
||
Some(u) => match NAMESPACES.iter().find(|(_, nu)| *nu == u) {
|
||
Some((p, _)) => format!("{p}:{local}"),
|
||
None if NAMESPACES.iter().any(|(p, _)| *p == prefix) => {
|
||
// A foreign `lc:settings` must never match our `lc:*` ownership rule (nor may a
|
||
// foreign `xmp:Rating` become a rating). Braces cannot occur in an XML QName;
|
||
// hex keeps URI slashes out of the flattened property's `/` path separators.
|
||
let size =
|
||
u.len().checked_mul(2).and_then(|n| n.checked_add(3)).and_then(|n| n.checked_add(local.len())).ok_or(XmpError::TooComplex)?;
|
||
*opaque_budget = opaque_budget.checked_sub(size).ok_or(XmpError::TooComplex)?;
|
||
let mut key = String::with_capacity(size);
|
||
key.push_str("Q{");
|
||
for byte in u.bytes() {
|
||
for nibble in [byte >> 4, byte & 15] {
|
||
key.push(char::from(if nibble < 10 { b'0' + nibble } else { b'a' + (nibble - 10) }));
|
||
}
|
||
}
|
||
key.push('}');
|
||
key.push_str(local);
|
||
key
|
||
}
|
||
None if prefix.is_empty() => local.to_string(),
|
||
None => qname.to_string(),
|
||
},
|
||
None => qname.to_string(),
|
||
})
|
||
}
|
||
|
||
fn entity(name: &str) -> Option<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).filter(|&c| is_xml_char(c))?
|
||
}
|
||
})
|
||
}
|
||
|
||
/// The XML 1.0 `Char` production (the surrogate range can't occur in a `char`).
|
||
fn is_xml_char(c: char) -> bool {
|
||
matches!(c, '\t' | '\n' | '\r') || (c >= '\u{20}' && c != '\u{FFFE}' && c != '\u{FFFF}')
|
||
}
|
||
|
||
/// Drop the characters XML 1.0 forbids, which strict parsers (Adobe XMP Toolkit, expat, libxml2) reject the
|
||
/// whole document for.
|
||
fn xml_safe(s: &str) -> String {
|
||
s.chars().filter(|&c| is_xml_char(c)).collect()
|
||
}
|
||
|
||
/// Unescape attribute values (predefined + numeric references; unknown references kept verbatim).
|
||
pub(crate) fn unescape_attr(s: &str) -> String {
|
||
let mut out = String::with_capacity(s.len());
|
||
let mut rest = s;
|
||
while let Some(i) = rest.find('&') {
|
||
out.push_str(&rest[..i]);
|
||
rest = &rest[i..];
|
||
match 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;
|
||
let mut opaque_budget = MAX_OPAQUE_NAME_BYTES;
|
||
loop {
|
||
let ev = reader.read_event().map_err(|e| XmpError::Xml(e.to_string()))?;
|
||
match ev {
|
||
Event::Start(ref e) | Event::Empty(ref e) => {
|
||
count += 1;
|
||
if count > MAX_NODES || stack.len() > MAX_DEPTH {
|
||
return Err(XmpError::TooComplex);
|
||
}
|
||
let mut scope = Vec::new();
|
||
let mut raw_attrs = Vec::new();
|
||
for a in e.attributes().with_checks(false).flatten() {
|
||
let k = String::from_utf8_lossy(a.key.as_ref()).into_owned();
|
||
let v = unescape_attr(&String::from_utf8_lossy(&a.value));
|
||
if k == "xmlns" {
|
||
scope.push((String::new(), v));
|
||
} else if let Some(p) = k.strip_prefix("xmlns:") {
|
||
scope.push((p.to_string(), v));
|
||
} else {
|
||
raw_attrs.push((k, v));
|
||
}
|
||
}
|
||
scopes.push(scope);
|
||
let name = canonical(&String::from_utf8_lossy(e.name().as_ref()), &scopes, &mut opaque_budget)?;
|
||
let attrs =
|
||
raw_attrs.into_iter().map(|(k, v)| Ok((canonical(&k, &scopes, &mut opaque_budget)?, v))).collect::<Result<_, XmpError>>()?;
|
||
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>>, budget: &mut usize) {
|
||
for (k, v) in &d.attrs {
|
||
if !is_rdf_meta_attr(k) && take_key_bytes(&format!("{prefix}{k}"), budget) {
|
||
props.entry(format!("{prefix}{k}")).or_default().push(v.clone());
|
||
}
|
||
}
|
||
for c in &d.children {
|
||
collect_property(c, prefix, props, budget);
|
||
}
|
||
}
|
||
|
||
/// Charge one flattened key's length to the key budget; `false` when the budget is spent (the
|
||
/// property is then left out, like other elements a limit has cut).
|
||
fn take_key_bytes(key: &str, budget: &mut usize) -> bool {
|
||
let n = key.len();
|
||
if *budget < n {
|
||
return false;
|
||
}
|
||
*budget -= n;
|
||
true
|
||
}
|
||
|
||
fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap<String, Vec<String>>, budget: &mut usize) {
|
||
let key = format!("{prefix}{}", p.name);
|
||
if let Some(arr) = p.children.iter().find(|c| matches!(c.name.as_str(), "rdf:Seq" | "rdf:Bag" | "rdf:Alt")) {
|
||
let mut items: Vec<(bool, String)> = arr
|
||
.children
|
||
.iter()
|
||
.filter(|li| li.name == "rdf:li")
|
||
.map(|li| {
|
||
(li.attr("xml:lang") == Some("x-default"), li.attr("rdf:resource").map(str::to_string).unwrap_or_else(|| li.text.trim().to_string()))
|
||
})
|
||
.collect();
|
||
if arr.name == "rdf:Alt" {
|
||
items.sort_by_key(|(default, _)| !*default);
|
||
}
|
||
if take_key_bytes(&key, budget) {
|
||
props.entry(key).or_default().extend(items.into_iter().map(|(_, v)| v));
|
||
}
|
||
return;
|
||
}
|
||
let struct_prefix = format!("{key}/");
|
||
if p.attr("rdf:parseType") == Some("Resource") {
|
||
for c in &p.children {
|
||
collect_property(c, &struct_prefix, props, budget);
|
||
}
|
||
return;
|
||
}
|
||
if let Some(d) = p.children.iter().find(|c| c.name == "rdf:Description") {
|
||
collect_description(d, &struct_prefix, props, budget);
|
||
return;
|
||
}
|
||
let field_attrs: Vec<&(String, String)> = p.attrs.iter().filter(|(k, _)| !is_rdf_meta_attr(k)).collect();
|
||
if !field_attrs.is_empty() && p.children.is_empty() && p.text.trim().is_empty() {
|
||
for (k, v) in field_attrs {
|
||
let full = format!("{struct_prefix}{k}");
|
||
if take_key_bytes(&full, budget) {
|
||
props.entry(full).or_default().push(v.clone());
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
let v = p.attr("rdf:resource").map(str::to_string).unwrap_or_else(|| p.text.trim().to_string());
|
||
if take_key_bytes(&key, budget) {
|
||
props.entry(key).or_default().push(v);
|
||
}
|
||
}
|
||
|
||
fn find_descriptions<'a>(n: &'a Node, out: &mut Vec<&'a Node>) {
|
||
if n.name == "rdf:Description" {
|
||
out.push(n);
|
||
return;
|
||
}
|
||
for c in &n.children {
|
||
find_descriptions(c, out);
|
||
}
|
||
}
|
||
|
||
/// XMP GPS coordinate `DDD,MM,SSk` or `DDD,MM.mmk` (k = N/S/E/W) → signed degrees.
|
||
fn parse_gps_coord(s: &str) -> Option<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)
|
||
}
|
||
|
||
/// `mwg-rs:Regions` → every region in its `RegionList`, converted to the full-image normalized frame.
|
||
/// Malformed or out-of-range entries are skipped rather than rejecting the whole list: one bad region
|
||
/// (from Lightroom, digiKam, Picasa, or a hand-edited file) shouldn't hide the rest.
|
||
fn parse_regions(regions: &XmpValue) -> Vec<Region> {
|
||
// `AppliedToDimensions` documents the pixel size regions were authored against. It's only consulted
|
||
// for the rare region whose own Area is in pixel units instead of MWG's normalized default below.
|
||
let px_dims = regions.field("mwg-rs:AppliedToDimensions").and_then(|d| {
|
||
let w = d.field("stDim:w").and_then(XmpValue::text).and_then(parse_number)?;
|
||
let h = d.field("stDim:h").and_then(XmpValue::text).and_then(parse_number)?;
|
||
(w > 0.0 && h > 0.0).then_some((w, h))
|
||
});
|
||
let Some(list) = regions.field("mwg-rs:RegionList") else { return Vec::new() };
|
||
list.items().iter().filter_map(|item| parse_one_region(item, px_dims)).collect()
|
||
}
|
||
|
||
fn parse_one_region(item: &XmpValue, px_dims: Option<(f64, f64)>) -> Option<Region> {
|
||
let area = item.field("mwg-rs:Area")?;
|
||
let num_attr = |k: &str| area.field(k).and_then(XmpValue::text).and_then(parse_number);
|
||
// stArea:x/y are the region's CENTER, not its top-left corner (MWG Region Guidelines §Area).
|
||
let (cx, cy, w, h) = (num_attr("stArea:x")?, num_attr("stArea:y")?, num_attr("stArea:w")?, num_attr("stArea:h")?);
|
||
let finite_and_sane = [cx, cy, w, h].iter().all(|v| v.is_finite() && v.abs() <= 1.0e6);
|
||
if !finite_and_sane || w <= 0.0 || h <= 0.0 {
|
||
return None;
|
||
}
|
||
// `stArea:unit` is "normalized" (a fraction of AppliedToDimensions) in every writer we know of, and
|
||
// that needs no cross-referencing at all. Only a region that explicitly says "pixel" gets divided
|
||
// down, and only if AppliedToDimensions is there to divide by; otherwise it's dropped rather than
|
||
// stored as a nonsense fraction.
|
||
let is_pixels = area.field("stArea:unit").and_then(XmpValue::text).is_some_and(|u| u.eq_ignore_ascii_case("pixel"));
|
||
let (cx, cy, w, h) = match (is_pixels, px_dims) {
|
||
(false, _) => (cx, cy, w, h),
|
||
(true, Some((dw, dh))) => (cx / dw, cy / dh, w / dw, h / dh),
|
||
(true, None) => return None,
|
||
};
|
||
// Lightroom Classic stores the box of a photo with Exif orientation 3 / 6 / 8 in the sensor's own
|
||
// frame and writes the orientation as the region's `mwg-rs:Rotation` (π for 3, −π/2 for 6, +π/2 for
|
||
// 8; 0 for 1) while `AppliedToDimensions` names the upright size. Turn it into the upright frame.
|
||
// MWG has no way to say "mirrored", so orientations 2/4/5/7 can't be told apart from their unmirrored
|
||
// twins here; boxes on mirrored photos are taken as written (see LR-LIB-PEOPLE in docs/parity-checklist.md).
|
||
let rotation = item.field("mwg-rs:Rotation").and_then(XmpValue::text).and_then(parse_number).filter(|r| r.is_finite());
|
||
let (quarter, half) = (std::f64::consts::FRAC_PI_2, std::f64::consts::PI);
|
||
let (cx, cy, w, h) = match rotation {
|
||
Some(r) if (r + quarter).abs() < 0.05 => (1.0 - cy, cx, h, w),
|
||
Some(r) if (r - quarter).abs() < 0.05 => (cy, 1.0 - cx, h, w),
|
||
Some(r) if (r.abs() - half).abs() < 0.05 => (1.0 - cx, 1.0 - cy, w, h),
|
||
_ => (cx, cy, w, h),
|
||
};
|
||
// Keep only the part of the box inside the photo: a region hanging off an edge (common for faces
|
||
// cut by the frame) is clipped, and one entirely outside it (hostile or stale) is dropped, so every
|
||
// stored rect is within 0..1 and non-empty.
|
||
let rect = Rect::from_center(Point::new(cx, cy), w, h).intersect(&Rect::UNIT);
|
||
if rect.is_empty() {
|
||
return None;
|
||
}
|
||
let name = item.field("mwg-rs:Name").and_then(XmpValue::text).map(str::to_string).filter(|s| !s.is_empty());
|
||
let description = item.field("mwg-rs:Description").and_then(XmpValue::text).map(str::to_string).filter(|s| !s.is_empty());
|
||
let kind = match item.field("mwg-rs:Type").and_then(XmpValue::text).unwrap_or("") {
|
||
t if t.eq_ignore_ascii_case("face") => RegionKind::Face,
|
||
t if t.eq_ignore_ascii_case("pet") => RegionKind::Pet,
|
||
t if t.eq_ignore_ascii_case("focus") => RegionKind::Focus,
|
||
t if t.eq_ignore_ascii_case("barcode") => RegionKind::BarCode,
|
||
t => RegionKind::Other(t.to_string()),
|
||
};
|
||
Some(Region { rect, kind, name, description })
|
||
}
|
||
|
||
fn fmt_gps_coord(v: f64, pos: char, neg: char) -> String {
|
||
let a = v.abs();
|
||
let mut d = a.trunc();
|
||
let mut m = ((a - d) * 60.0 * 1e6).round() / 1e6;
|
||
if m >= 60.0 {
|
||
d += 1.0;
|
||
m = 0.0;
|
||
}
|
||
format!("{},{:.6}{}", d as i64, m, if v < 0.0 { neg } else { pos })
|
||
}
|
||
|
||
/// Parse an XMP packet (with or without the `<?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();
|
||
// One budget for the whole document: several descriptions cannot each spend a fresh one.
|
||
let mut key_budget = MAX_KEY_BYTES;
|
||
for d in descs {
|
||
collect_description(d, "", &mut props, &mut key_budget);
|
||
if let XmpValue::Struct(m) = node_struct(d) {
|
||
values.extend(m);
|
||
}
|
||
}
|
||
let first = |k: &str| props.get(k).and_then(|v| v.first()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
|
||
let num = |k: &str| first(k).and_then(|s| parse_number(&s));
|
||
let mut m = Metadata {
|
||
make: first("tiff:Make"),
|
||
model: first("tiff:Model"),
|
||
serial_number: first("exifEX:BodySerialNumber").or_else(|| first("aux:SerialNumber")),
|
||
software: first("xmp:CreatorTool"),
|
||
lens_make: first("exifEX:LensMake"),
|
||
lens_model: first("exifEX:LensModel").or_else(|| first("aux:Lens")),
|
||
lens_serial_number: first("exifEX:LensSerialNumber").or_else(|| first("aux:LensSerialNumber")),
|
||
exposure_time: num("exif:ExposureTime").filter(|v| *v > 0.0),
|
||
f_number: num("exif:FNumber").filter(|v| *v > 0.0),
|
||
focal_length: num("exif:FocalLength").filter(|v| *v > 0.0),
|
||
focal_length_35mm: num("exif:FocalLengthIn35mmFilm").filter(|v| *v > 0.0),
|
||
exposure_bias: num("exif:ExposureBiasValue"),
|
||
exposure_program: num("exif:ExposureProgram").map(|v| v as u16),
|
||
metering_mode: num("exif:MeteringMode").map(|v| v as u16),
|
||
white_balance: num("exif:WhiteBalance").map(|v| v as u16),
|
||
orientation: num("tiff:Orientation").map(|v| v as u16).filter(|v| (1..=8).contains(v)).map(Orientation::from_exif),
|
||
width: num("tiff:ImageWidth").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"),
|
||
copyright_marked: first("xmpRights:Marked").and_then(|s| match s.to_ascii_lowercase().as_str() {
|
||
"true" => Some(true),
|
||
"false" => Some(false),
|
||
_ => None,
|
||
}),
|
||
usage_terms: first("xmpRights:UsageTerms"),
|
||
copyright_url: first("xmpRights:WebStatement"),
|
||
rating: num("xmp:Rating").map(|v| v.round().clamp(-1.0, 5.0) as i8),
|
||
label: first("xmp:Label"),
|
||
..Default::default()
|
||
};
|
||
m.iso = [num("exif:ISOSpeedRatings"), num("exifEX:PhotographicSensitivity")].into_iter().flatten().find(|v| *v > 0.0).map(|v| v as u32);
|
||
if let Some(spec) = props.get("exifEX:LensSpecification").filter(|v| v.len() == 4) {
|
||
let v: Vec<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)))
|
||
&& (-90.0..=90.0).contains(&lat)
|
||
&& (-180.0..=180.0).contains(&lon)
|
||
{
|
||
let altitude = num("exif:GPSAltitude").map(|a| if first("exif:GPSAltitudeRef").as_deref() == Some("1") { -a } else { a });
|
||
m.gps = Some(Gps { latitude: lat, longitude: lon, altitude });
|
||
}
|
||
m.regions = values.get("mwg-rs:Regions").map(parse_regions).unwrap_or_default();
|
||
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());
|
||
push("xmpRights:Marked", meta.copyright_marked.map(|m| if m { "True" } else { "False" }.to_string()));
|
||
push("xmpRights:WebStatement", meta.copyright_url.clone());
|
||
if let Some(g) = meta.gps {
|
||
push("exif:GPSLatitude", Some(fmt_gps_coord(g.latitude, 'N', 'S')));
|
||
push("exif:GPSLongitude", Some(fmt_gps_coord(g.longitude, 'E', 'W')));
|
||
if let Some(a) = g.altitude {
|
||
push("exif:GPSAltitude", Some(frac(a.abs())));
|
||
push("exif:GPSAltitudeRef", Some(if a < 0.0 { "1" } else { "0" }.into()));
|
||
}
|
||
}
|
||
|
||
let mut x = String::new();
|
||
x.push_str("<?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(xml_safe(v))));
|
||
}
|
||
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(xml_safe(v))));
|
||
}
|
||
}
|
||
let array = |x: &mut String, k: &str, kind: &str, items: &[String], lang: bool| {
|
||
if items.is_empty() {
|
||
return;
|
||
}
|
||
x.push_str(&format!(" <{k}>\n <rdf:{kind}>\n"));
|
||
for it in items {
|
||
if lang {
|
||
x.push_str(&format!(" <rdf:li xml:lang=\"x-default\">{}</rdf:li>\n", escape(xml_safe(it))));
|
||
} else {
|
||
x.push_str(&format!(" <rdf:li>{}</rdf:li>\n", escape(xml_safe(it))));
|
||
}
|
||
}
|
||
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, "xmpRights:UsageTerms", "Alt", &one(&meta.usage_terms), true);
|
||
array(&mut x, "dc:creator", "Seq", &one(&meta.artist), false);
|
||
array(&mut x, "dc:subject", "Bag", &meta.keywords, false);
|
||
array(&mut x, "lr:hierarchicalSubject", "Bag", &meta.hierarchical_keywords, false);
|
||
array(&mut x, "exif:ISOSpeedRatings", "Seq", &meta.iso.map(|v| vec![v.to_string()]).unwrap_or_default(), false);
|
||
if let Some(s) = meta.lens_spec {
|
||
array(&mut x, "exifEX:LensSpecification", "Seq", &s.iter().map(|&v| frac(v)).collect::<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;
|