mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
Merge PR #2526
# Conflicts: # docs/gaps.md # docs/parity-checklist.md # docs/whats-new.md
This commit is contained in:
@@ -1375,6 +1375,38 @@ const OTHER_APP_SIDECAR: &str = r#"<?xpacket begin="" id="W5M0MpCehiHzreSzNTc
|
||||
</x:xmpmeta>
|
||||
<?xpacket end="w"?>"#;
|
||||
|
||||
/// A foreign namespace may use a familiar prefix without becoming LightCraft-owned metadata.
|
||||
#[test]
|
||||
fn saving_preserves_foreign_namespace_prefix_collisions() {
|
||||
let src = temp_dir("namespace-src");
|
||||
let lib = temp_dir("namespace-lib");
|
||||
write_png(&src.join("shot.png"), 1);
|
||||
let foreign = r#"<rdf:Description xmlns:lc="https://example.org/vendor/" xmlns:xmp="https://example.org/vendor/"
|
||||
lc:Secret="keep-me" xmp:Rating="2"><lc:Other>also keep</lc:Other></rdf:Description>"#;
|
||||
let packet = format!(r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">{foreign}</rdf:RDF>"#);
|
||||
let path = src.join("shot.xmp");
|
||||
std::fs::write(&path, packet).unwrap();
|
||||
let mut s = Session::new().with_fs();
|
||||
s.open_library(&lib, false).unwrap();
|
||||
s.execute("library.import", &json!({"paths": [src.join("shot.png")]})).unwrap();
|
||||
let id = only(&s);
|
||||
assert_eq!(s.catalog.photo(id).unwrap().rating, 0, "a foreign rating must not become catalog metadata");
|
||||
s.execute("library.select", &json!({"ids": [id.0]})).unwrap();
|
||||
s.execute("photo.rate", &json!({"rating": 5})).unwrap();
|
||||
for _ in 0..2 {
|
||||
let result = s.execute("photo.saveMetadataToFile", &json!({})).unwrap();
|
||||
assert_eq!(result["failed"], json!([]), "{result}");
|
||||
assert_eq!(result["backups"], json!([]), "a valid packet must merge: {result}");
|
||||
assert_eq!(result["merged"], json!([path.to_string_lossy()]), "{result}");
|
||||
let saved = std::fs::read_to_string(&path).unwrap();
|
||||
assert!(saved.contains(foreign), "lost foreign bytes: {saved}");
|
||||
assert_eq!(lightcraft_meta::parse_xmp(&saved).unwrap().metadata.rating, Some(5));
|
||||
}
|
||||
drop(s);
|
||||
std::fs::remove_dir_all(src).unwrap();
|
||||
std::fs::remove_dir_all(lib).unwrap();
|
||||
}
|
||||
|
||||
/// Issue #92: saving merges into another app's sidecar instead of replacing it.
|
||||
#[test]
|
||||
fn saving_merges_into_another_apps_sidecar() {
|
||||
|
||||
@@ -27,6 +27,13 @@ prefixes by namespace URI, handles attribute and element forms, `rdf:Seq`/`Bag`/
|
||||
`rdf:parseType="Resource"` and nested-description structs, predefined and numeric entities. Nesting depth
|
||||
and node count are limited.
|
||||
|
||||
An explicitly foreign namespace using a recognized prefix (for example `lc` or `xmp`) cannot supply
|
||||
recognized properties. Its keys use `Q{hex-encoded namespace URI}local` in `properties` and `values`;
|
||||
hex encoding keeps `/` reserved for struct paths. Other unknown prefixes keep their existing QName keys.
|
||||
Merging preserves the original foreign XML rather than writing these opaque keys into the packet.
|
||||
The parser and merge scanner each cap generated opaque names at 8 MiB per packet, returning `TooComplex`
|
||||
if repeated use of a long namespace URI would exceed that budget.
|
||||
|
||||
## Robustness
|
||||
|
||||
No panics on malformed input: proptests feed random bytes to every container walker and random text to the
|
||||
|
||||
+71
-11
@@ -69,7 +69,8 @@ pub struct XmpData {
|
||||
/// 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.
|
||||
/// 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).
|
||||
@@ -150,26 +151,46 @@ impl Node {
|
||||
|
||||
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;
|
||||
|
||||
/// `prefix:local` with the prefix canonicalised by namespace URI (see [`NAMESPACES`]).
|
||||
pub(crate) fn canonical_name(qname: &str, scopes: &[Vec<(String, String)>]) -> String {
|
||||
canonical(qname, scopes)
|
||||
/// 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)>]) -> String {
|
||||
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 qname.to_string();
|
||||
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());
|
||||
match uri {
|
||||
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> {
|
||||
@@ -202,7 +223,7 @@ fn xml_safe(s: &str) -> String {
|
||||
}
|
||||
|
||||
/// Unescape attribute values (predefined + numeric references; unknown references kept verbatim).
|
||||
fn unescape_attr(s: &str) -> String {
|
||||
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('&') {
|
||||
@@ -229,6 +250,7 @@ fn parse_tree(s: &str) -> Result<Node, XmpError> {
|
||||
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 {
|
||||
@@ -251,8 +273,9 @@ fn parse_tree(s: &str) -> Result<Node, XmpError> {
|
||||
}
|
||||
}
|
||||
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 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();
|
||||
@@ -767,6 +790,43 @@ mod tests {
|
||||
assert_eq!(d.lc_settings, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_namespace_cannot_impersonate_known_metadata() {
|
||||
for body in [
|
||||
r#"xmp:Rating="2" lc:settings="foreign settings"/>"#,
|
||||
r#"><xmp:Rating>2</xmp:Rating><lc:settings>foreign settings</lc:settings></rdf:Description>"#,
|
||||
] {
|
||||
let packet = format!(
|
||||
r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description
|
||||
xmlns:xmp="https://example.org/vendor/" xmlns:lc="https://example.org/vendor/" {body}</rdf:RDF>"#
|
||||
);
|
||||
let d = parse_xmp(&packet).unwrap();
|
||||
assert_eq!(d.metadata.rating, None, "foreign rating: {packet}");
|
||||
assert_eq!(d.lc_settings, None, "foreign settings: {packet}");
|
||||
assert!(!d.properties.contains_key("xmp:Rating"));
|
||||
assert!(!d.values.contains_key("lc:settings"));
|
||||
assert!(d.properties.values().any(|v| v == &["foreign settings"]));
|
||||
assert!(d.properties.keys().all(|key| !key.contains('/')), "opaque names must not create struct paths");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn foreign_namespace_names_are_distinct_and_scoped() {
|
||||
let packet = r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
<rdf:Description xmlns:xmp="http://ns.adobe.com/xap/1.0/">
|
||||
<xmp:Rating xmlns:xmp="https://example.org/one/">1</xmp:Rating>
|
||||
<xmp:Rating xmlns:xmp="https://example.org/two/">2</xmp:Rating>
|
||||
<xmp:Rating>4</xmp:Rating>
|
||||
</rdf:Description></rdf:RDF>"#;
|
||||
let d = parse_xmp(packet).unwrap();
|
||||
assert_eq!(d.metadata.rating, Some(4), "a local rebinding must not escape its scope");
|
||||
assert_eq!(d.properties.len(), 3, "different foreign URIs must not collide");
|
||||
assert_eq!(d.values.len(), 3);
|
||||
assert_eq!(d.properties["xmp:Rating"], vec!["4"]);
|
||||
assert!(d.properties.values().any(|v| v == &["1"]));
|
||||
assert!(d.properties.values().any(|v| v == &["2"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attribute_form_foreign_prefixes_and_structs() {
|
||||
let x = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
||||
|
||||
@@ -23,7 +23,7 @@ use std::collections::{BTreeSet, HashSet};
|
||||
|
||||
use quick_xml::events::Event;
|
||||
|
||||
use crate::xmp::{XmpError, canonical_name, parse_xmp};
|
||||
use crate::xmp::{MAX_OPAQUE_NAME_BYTES, XmpError, canonical_name, parse_xmp, unescape_attr};
|
||||
|
||||
/// Which properties [`merge_xmp`] takes from the fresh packet. Names are canonical
|
||||
/// `prefix:name` (prefixes as [`crate::parse_xmp`] reports them); `prefix:*` is a whole namespace.
|
||||
@@ -101,6 +101,7 @@ fn scan(s: &str) -> Result<Scan, XmpError> {
|
||||
let mut stack: Vec<Kind> = Vec::new();
|
||||
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 before = pos(&reader);
|
||||
let ev = reader.read_event().map_err(|e| XmpError::Xml(e.to_string()))?;
|
||||
@@ -124,18 +125,21 @@ fn scan(s: &str) -> Result<Scan, XmpError> {
|
||||
}
|
||||
let v = String::from_utf8_lossy(&a.value).into_owned();
|
||||
if k == "xmlns" {
|
||||
scope.push((String::new(), v.clone()));
|
||||
scope.push((String::new(), unescape_attr(&v)));
|
||||
} else if let Some(p) = k.strip_prefix("xmlns:") {
|
||||
scope.push((p.to_string(), v.clone()));
|
||||
scope.push((p.to_string(), unescape_attr(&v)));
|
||||
}
|
||||
raw_attrs.push((k, v));
|
||||
}
|
||||
scopes.push(scope);
|
||||
let raw_name = String::from_utf8_lossy(e.name().as_ref()).into_owned();
|
||||
let name = canonical_name(&raw_name, &scopes);
|
||||
let name = canonical_name(&raw_name, &scopes, &mut opaque_budget)?;
|
||||
let kind = match stack.last() {
|
||||
Some(Kind::Rdf) if name == "rdf:Description" => {
|
||||
let attrs = raw_attrs.into_iter().map(|(raw, value)| Attr { name: canonical_name(&raw, &scopes), raw, value }).collect();
|
||||
let attrs = raw_attrs
|
||||
.into_iter()
|
||||
.map(|(raw, value)| Ok(Attr { name: canonical_name(&raw, &scopes, &mut opaque_budget)?, raw, value }))
|
||||
.collect::<Result<_, XmpError>>()?;
|
||||
out.descs.push(Desc { tag_start: before, tag_end: after, raw_name, empty, attrs, props: Vec::new(), end: after });
|
||||
Kind::Desc(out.descs.len() - 1)
|
||||
}
|
||||
@@ -399,6 +403,47 @@ mod tests {
|
||||
assert_eq!(d.properties.get("exif:DateTimeOriginal"), Some(&vec!["2024-05-06T07:08:09".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_foreign_properties_using_known_prefixes() {
|
||||
for property in [r#"lc:Secret="keep-me"/>"#, r#"><lc:Secret>keep-me</lc:Secret></rdf:Description>"#] {
|
||||
let desc = format!(r#"<rdf:Description rdf:about="" xmlns:lc="https://example.org/vendor/" {property}"#);
|
||||
let old = format!(r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">{desc}</rdf:RDF>"#);
|
||||
let out = merge_xmp(&old, &fresh(4, &[]), RULES).unwrap();
|
||||
assert!(out.contains(&desc), "lost foreign description: {out}");
|
||||
assert_eq!(parse_xmp(&out).unwrap().metadata.rating, Some(4));
|
||||
assert_eq!(merge_xmp(&out, &fresh(4, &[]), RULES).unwrap(), out);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn namespace_aliases_remain_distinct_during_merge() {
|
||||
// The alias URI includes a character reference: scanning and parsing must resolve it identically.
|
||||
let foreign = r#"<rdf:Description xmlns:xmp="https://example.org/vendor/" xmp:Rating="2"/>"#;
|
||||
let old = format!(
|
||||
r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">{foreign}
|
||||
<rdf:Description xmlns:a="http://ns.adobe.com/xap/1.0/" a:Rating="3"/>
|
||||
</rdf:RDF>"#
|
||||
);
|
||||
let out = merge_xmp(&old, &fresh(5, &[]), RULES).unwrap();
|
||||
assert!(out.contains(foreign), "lost foreign rating: {out}");
|
||||
assert!(!out.contains("a:Rating="), "genuine alias must be replaced: {out}");
|
||||
assert_eq!(parse_xmp(&out).unwrap().metadata.rating, Some(5));
|
||||
assert_eq!(merge_xmp(&out, &fresh(5, &[]), RULES).unwrap(), out);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_namespace_expansion_is_bounded() {
|
||||
// A roughly 40 KiB packet must not expand the same long URI into every property name indefinitely.
|
||||
let packet = format!(
|
||||
r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description
|
||||
xmlns:lc="https://example.org/{}">{}</rdf:Description></rdf:RDF>"#,
|
||||
"a".repeat(1024),
|
||||
"<lc:p/>".repeat(5000)
|
||||
);
|
||||
assert_eq!(parse_xmp(&packet).unwrap_err(), XmpError::TooComplex);
|
||||
assert!(matches!(scan(&packet), Err(XmpError::TooComplex)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_attributes_scan_in_linear_time() {
|
||||
// quick-xml's duplicate-name check compares each attribute with all earlier ones: 200 000 attributes on
|
||||
|
||||
@@ -60,6 +60,8 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
|
||||
|
||||
## Closed recently (kept for calibration)
|
||||
|
||||
- 2026-10-22: saving XMP preserves foreign namespaces even when they use `lc:` or `xmp:` prefixes; importing them no longer interprets their properties as native settings or ratings. Parser, merge and save-command regressions cover the correction; no ranked gap or readiness estimate changes.
|
||||
|
||||
- 2026-10-20: the import review album picker follows the sidebar folder hierarchy and searches album paths (#2362, LR-IMP-ADD-DIALOG), retaining None/New album and existing destination IDs. This closes a usability subgap; no ranked gap or headline feature count closes.
|
||||
|
||||
- 2026-10-12: original-pixel detail at 100% (gap 4, LR-VIEW-ZOOM, #323): soft proofing gets zoom windows like
|
||||
@@ -79,6 +81,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-22 | patch | XMP namespace-prefix collisions no longer overwrite foreign metadata; ranked gaps and estimates unchanged |
|
||||
| 2026-10-21 | minor | Gap 28 shrinks: F8 shows / hides the right panel (`view.rightPanel`); rating `[` `]`, flag cycling and F1 help were already bound, so the entry lists only filter-bar keys, Tab / ⇧Tab and F5; 2–4 h (was 4–8) |
|
||||
| 2026-10-20 | minor | #2362: import destination albums use the shared hierarchy/search picker instead of a flat name list; existing import workflows retained; ranked gaps/readiness counts unchanged |
|
||||
| 2026-10-19 | minor | Gap 21: Indonesian (`id`) shipped with every catalog message but What's New; four new languages left (Hindi, Arabic, Korean, Vietnamese), 135–239 h |
|
||||
|
||||
@@ -168,7 +168,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts).
|
||||
| LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own |
|
||||
| LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map |
|
||||
| LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset |
|
||||
| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | manual rereading also reads sidecar-free JPEG embedded XMP (first complete standard APP1 before SOS/EOI, within 16 MiB of headers and 4096 marker/fill steps; no extended assembly or between-scan packets, not strict whole-JPEG validation; reader cases in `crates/engine/src/sidecar.rs`); a discovered unreadable sidecar fails without embedded fallback; procedural refresh/undo, non-UTF-8 sidecar and two explicit malformed fixtures in `crates/engine/src/tests_xmp.rs` (file-write checks use auto-write off); sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`<file>.xmp` for all but the raw; an import ranks the files it is adding the same way, so a TIFF or JPEG arriving beside its raw reads its own `<file>.xmp` and never the raw's `IMG_0001.xmp`, issue #1905); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934); settings Lightroom writes only for itself (`CompatibleVersion`, the profile digests, the lens profile's name / file / setup, `CropConstrainToUnitSquare`, the lens profile's strength at 100 %) are not reported as unimported (`crates/engine/src/crs.rs`) |
|
||||
| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | manual rereading also reads sidecar-free JPEG embedded XMP (first complete standard APP1 before SOS/EOI, within 16 MiB of headers and 4096 marker/fill steps; no extended assembly or between-scan packets, not strict whole-JPEG validation; reader cases in `crates/engine/src/sidecar.rs`); a discovered unreadable sidecar fails without embedded fallback; procedural refresh/undo, non-UTF-8 sidecar and two explicit malformed fixtures in `crates/engine/src/tests_xmp.rs` (file-write checks use auto-write off); sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte (ownership follows namespace URI: foreign namespaces using `lc:` or `xmp:` cannot supply native settings or ratings and survive saving), an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`<file>.xmp` for all but the raw; an import ranks the files it is adding the same way, so a TIFF or JPEG arriving beside its raw reads its own `<file>.xmp` and never the raw's `IMG_0001.xmp`, issue #1905); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934); settings Lightroom writes only for itself (`CompatibleVersion`, the profile digests, the lens profile's name / file / setup, `CropConstrainToUnitSquare`, the lens profile's strength at 100 %) are not reported as unimported (`crates/engine/src/crs.rs`) |
|
||||
| LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | `photo.allMetadata` reuses loaded original bytes for embedded XMP and keeps sidecar precedence (`crates/engine/src/tests_xmp.rs`); read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets |
|
||||
|
||||
## F. Edit panel — global adjustments (EDIT)
|
||||
@@ -798,6 +798,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 2026-10-22 | patch | LR-META-XMP: namespace URI determines ownership; foreign namespaces using recognized prefixes survive saves and cannot supply native settings or ratings; status and counts unchanged |
|
||||
| 2026-10-21 | patch | LR-IMP-ADD-DIALOG: reconcile #2362 with current main; deleted-album guard and hierarchical picker retained; counts unchanged |
|
||||
| 2026-10-20 | minor | LR-IMP-ADD-DIALOG: #2362 gives the import review the shared hierarchy/search album picker while retaining None/New album and existing destination IDs; status and feature counts unchanged |
|
||||
| 2026-10-21 | patch | KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the slider's row; leaving it (even between two frames) or Tab focus alone does not arm them (issue #1355); status unchanged |
|
||||
|
||||
@@ -23,6 +23,10 @@
|
||||
Before, the field took the focus back every frame, so only Return and Esc got you out of it. Return still
|
||||
confirms and Esc still cancels.
|
||||
|
||||
### Foreign XMP metadata survives namespace-prefix collisions
|
||||
- Saving a sidecar keeps another application's properties even when its namespace uses prefixes such as `lc`
|
||||
or `xmp`. Those foreign properties no longer become LightCraft settings or ratings when imported.
|
||||
|
||||
## 2026-10-21
|
||||
|
||||
### Sharper resampling in HDR and panorama merges
|
||||
|
||||
@@ -81,6 +81,9 @@ the only copy of those edits outside that application's catalog. Saving never dr
|
||||
ones), `xmp:CreatorTool`, comments, the packet wrapper and padding. LightCraft's properties go into one
|
||||
`rdf:Description` of their own; owned properties written by another application (in attribute or element form) are
|
||||
removed from its description, which otherwise stays as it was.
|
||||
- Ownership follows the namespace URI, not the prefix spelling. A foreign namespace bound to `lc:` or `xmp:`
|
||||
stays foreign: its properties are preserved and do not become LightCraft settings or ratings. Genuine namespace
|
||||
aliases remain interchangeable, including namespace declarations containing XML character references.
|
||||
- A sidecar that can't be read as XMP (not well-formed, not UTF-8, no `rdf:RDF`) is first copied to
|
||||
`<name>.xmp.bak-<date><time>` (`-1`, `-2`… if taken — a backup is never overwritten), then replaced. A sidecar that
|
||||
can't be read at all (permissions) is left alone and the save fails.
|
||||
|
||||
Reference in New Issue
Block a user