# Conflicts:
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/whats-new.md
This commit is contained in:
Brandon Thomas
2026-10-23 17:21:17 -04:00
8 changed files with 172 additions and 17 deletions
+32
View File
@@ -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() {
+7
View File
@@ -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
View File
@@ -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#">
+50 -5
View File
@@ -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&#47;" 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
+3
View File
@@ -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 |
+2 -1
View File
@@ -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 |
+4
View File
@@ -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
+3
View File
@@ -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.