collective

Browse / github.com/storytold/lightcraft / patch

LR-META-XMP: reject invalid GPS subdivisions

LR-META-XMP: reject invalid GPS subdivisions ## Summary Reject malformed XMP GPS coordinates whose minute or second components are outside `[0, 60)`. Keep the original XMP values visible in All Metadata while preventing invalid values from becoming catalog GPS or overriding valid EXIF GPS. ## Root cause `parse_gps_coord` combined degree/minute/second values without validating the subdivisions. Values such as `45,60N` therefore normalized to a plausible coordinate. The Original + Remove Location guard relied on projected `metadata.gps`, so a parser-only correction would have stopped detecting raw malformed GPS in byte-for-byte Original

Status
open
Score
0.00
Adopters
0 other operators
Author
ronibalthazar (imported from pull request #3054, open)
Base
bb08a697a41b2561a54f1b740245119503b24758
Size
+609 −54 in 29 files
Review
pending
Shared
2026-10-28
Id
b2a70960078a801ced8f981e

How it went

No other operator has reported using it yet.

Diff

raw · apps/lightcraft/src/native_menu.rs crates/engine/src/export.rs crates/engine/src/tests_export.rs crates/engine/src/tests_xmp.rs crates/meta/README.md crates/meta/src/container.rs crates/meta/src/xmp.rs crates/ui-egui/locales/cs.json crates/ui-egui/locales/de.json crates/ui-egui/locales/es.json crates/ui-egui/locales/fi.json crates/ui-egui/locales/fr.json crates/ui-egui/locales/id.json crates/ui-egui/locales/it.json crates/ui-egui/locales/ja.json crates/ui-egui/locales/ka.json crates/ui-egui/locales/pl.json crates/ui-egui/locales/pt-br.json crates/ui-egui/locales/ru.json crates/ui-egui/locales/sv.json crates/ui-egui/locales/uk.json crates/ui-egui/locales/vi.json crates/ui-egui/locales/zh-hans.json crates/ui-egui/locales/zh-hant.json crates/ui-egui/src/panels/compare.rs docs/gaps.md docs/parity-checklist.md docs/whats-new.md docs/xmp-interop.md

diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs--- a/apps/lightcraft/src/native_menu.rs+++ b/apps/lightcraft/src/native_menu.rs@@ -489,9 +489,7 @@ impl NativeMenu {             // events never reach `shortcuts::handle`, so this is the one guard for AppKit's             // accelerators and clicks: while the blocking library window or a quit/settings notice             // owns input, a queued command is dropped (Quit stays for the existing confirmation).-            let (cmd, params) = if key == QUIT {-                (key, serde_json::json!({}))-            } else if key == "app.about" || key == SETTINGS {+            let (cmd, params) = if key == QUIT || key == "app.about" || key == SETTINGS {                 (key, serde_json::json!({}))             } else if let Some(it) = self.items.get(&key) {                 (it.cmd.clone(), it.params.clone())diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs--- a/crates/engine/src/export.rs+++ b/crates/engine/src/export.rs@@ -1837,7 +1837,8 @@ impl PreparedExport {                     if remove_location {                         let metadata = lightcraft_meta::try_extract(&bytes)                             .map_err(|e| format!("{label}: can't verify location metadata in this original file: {e}"))?;-                        if metadata.gps.is_some() {+                        let embeds_xmp_gps = original_embeds_xmp_gps(&bytes, &label)?;+                        if metadata.gps.is_some() || embeds_xmp_gps {                             return Err(format!(                                 "{label}: can't remove the location from an original file that embeds GPS; export as JPEG, TIFF or DNG instead"                             ));@@ -1874,6 +1875,29 @@ impl PreparedExport {     } } +/// Recognized raw XMP GPS properties still make an Original unsafe to copy when their values are+/// malformed and therefore cannot be projected into `Metadata`. Inspect both standard and+/// Extended XMP packets from the source bytes; don't infer metadata from XML substrings. A packet+/// that can't be inspected or has a truncated property map fails closed.+fn original_embeds_xmp_gps(bytes: &[u8], label: &str) -> Result<bool, String> {+    let embedded = lightcraft_meta::embedded(bytes);+    let mut found_gps = false;+    for packet in [embedded.xmp.as_deref(), embedded.xmp_extended.as_deref()].into_iter().flatten() {+        let xmp = lightcraft_meta::parse_xmp(packet)+            .map_err(|e| format!("{label}: can't verify location metadata in this original file: malformed embedded XMP: {e}"))?;+        if xmp.properties_truncated {+            return Err(format!("{label}: can't verify location metadata in this original file: embedded XMP property limit was exceeded"));+        }+        found_gps |= ["exif:GPSLatitude", "exif:GPSLongitude"]+            .into_iter()+            .any(|key| xmp.properties.get(key).is_some_and(|values| values.iter().any(|value| !value.trim().is_empty())));+    }+    if embedded.chunk_walk_incomplete {+        return Err(format!("{label}: can't verify location metadata in this original file: embedded PNG/WebP chunk stream is incomplete"));+    }+    Ok(found_gps)+}+ /// How an exported DNG stores its raw data. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] #[serde(rename_all = "camelCase")]@@ -2439,6 +2463,218 @@ mod tests {         }     } +    fn xmp_with_gps() -> String {+        r#"<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:exif="http://ns.adobe.com/exif/1.0/" exif:GPSLatitude="45,0N" exif:GPSLongitude="7,0E"/></rdf:RDF>"#.into()+    }++    fn jpeg_with_extended_xmp(packet: &str) -> Vec<u8> {+        let image = test_image();+        let mut jpeg = lightcraft_codecs::encode_jpeg(&EncodeImage::rgba8(&image), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap();+        let scan = jpeg.windows(2).position(|window| window == [0xff, 0xda]).unwrap();+        let mut payload = b"http://ns.adobe.com/xmp/extension/\0".to_vec();+        payload.extend_from_slice(b"0123456789ABCDEF0123456789ABCDEF");+        payload.extend_from_slice(&(packet.len() as u32).to_be_bytes());+        payload.extend_from_slice(&0u32.to_be_bytes());+        payload.extend_from_slice(packet.as_bytes());+        let length = u16::try_from(payload.len() + 2).unwrap().to_be_bytes();+        let mut segment = vec![0xff, 0xe1, length[0], length[1]];+        segment.extend_from_slice(&payload);+        jpeg.splice(scan..scan, segment);+        jpeg+    }++    fn png_with_gps_after_xmp_key_budget() -> Vec<u8> {+        let level = "n".repeat(200);+        let mut packet = String::from(+            "<rdf:RDF xmlns:rdf=\"http://www.w3.org/1999/02/22-rdf-syntax-ns#\" xmlns:lc=\"http://ns.lightcraft.app/lc/1.0/\" xmlns:exif=\"http://ns.adobe.com/exif/1.0/\"><rdf:Description><lc:root rdf:parseType=\"Resource\">",+        );+        for _ in 0..4 {+            packet.push_str(&format!("<lc:{level} rdf:parseType=\"Resource\">"));+        }+        for index in 0..1600 {+            packet.push_str(&format!("<lc:leaf{index}>v</lc:leaf{index}>"));+        }+        for _ in 0..4 {+            packet.push_str(&format!("</lc:{level}>"));+        }+        packet.push_str("</lc:root>");+        let tail = "t".repeat(77);+        packet.push_str(&format!("<lc:{tail}>v</lc:{tail}><exif:GPSLatitude>45,0N</exif:GPSLatitude></rdf:Description></rdf:RDF>"));+        let image = test_image();+        lightcraft_codecs::encode_png(&EncodeImage::rgba8(&image), &EncodeMeta { xmp: Some(&packet), ..Default::default() }).unwrap()+    }++    fn png_with_truncated_chunk_after_xmp() -> Vec<u8> {+        let mut png = b"\x89PNG\r\n\x1a\n".to_vec();+        let packet = lightcraft_meta::write_xmp(&Metadata::default(), None);+        let mut itxt = b"XML:com.adobe.xmp\0\0\0\0\0".to_vec();+        itxt.extend_from_slice(packet.as_bytes());+        png.extend_from_slice(&(itxt.len() as u32).to_be_bytes());+        png.extend_from_slice(b"iTXt");+        png.extend_from_slice(&itxt);+        png.extend_from_slice(&[0; 4]);+        png.extend_from_slice(&16u32.to_be_bytes());+        png.extend_from_slice(b"tEXtpartial");+        png+    }++    fn webp_with_truncated_chunk_after_xmp() -> Vec<u8> {+        let mut webp = b"RIFF\0\0\0\0WEBP".to_vec();+        let packet = lightcraft_meta::write_xmp(&Metadata::default(), None);+        webp.extend_from_slice(b"XMP ");+        webp.extend_from_slice(&(packet.len() as u32).to_le_bytes());+        webp.extend_from_slice(packet.as_bytes());+        if !packet.len().is_multiple_of(2) {+            webp.push(0);+        }+        webp.extend_from_slice(b"JUNK");+        webp.extend_from_slice(&16u32.to_le_bytes());+        webp.extend_from_slice(b"partial");+        let declared_size = webp.len().saturating_sub(8).saturating_add(16 - b"partial".len());+        webp[4..8].copy_from_slice(&(declared_size as u32).to_le_bytes());+        webp+    }++    fn png_with_truncated_xmp_chunk() -> Vec<u8> {+        let mut png = b"\x89PNG\r\n\x1a\n".to_vec();+        let mut data = b"XML:com.adobe.xmp\0\0\0\0\0".to_vec();+        data.extend_from_slice(b"<x:xmpmeta>");+        png.extend_from_slice(&(data.len().saturating_add(64) as u32).to_be_bytes());+        png.extend_from_slice(b"iTXt");+        png.extend_from_slice(&data);+        png+    }++    fn png_with_truncated_chunk_after_iend() -> Vec<u8> {+        let mut png = b"\x89PNG\r\n\x1a\n".to_vec();+        png.extend_from_slice(&0u32.to_be_bytes());+        png.extend_from_slice(b"IEND");+        png.extend_from_slice(&[0; 4]);+        png.extend_from_slice(&16u32.to_be_bytes());+        png.extend_from_slice(b"tEXtpartial");+        png+    }++    fn webp_with_truncated_xmp_chunk() -> Vec<u8> {+        let mut webp = b"RIFF\0\0\0\0WEBP".to_vec();+        let mut data = b"<x:xmpmeta>".to_vec();+        let declared_len = data.len().saturating_add(64);+        webp.extend_from_slice(b"XMP ");+        webp.extend_from_slice(&(declared_len as u32).to_le_bytes());+        webp.append(&mut data);+        let declared_size = 4usize.saturating_add(8).saturating_add(declared_len).saturating_add(declared_len & 1);+        webp[4..8].copy_from_slice(&(declared_size as u32).to_le_bytes());+        webp+    }++    fn prepared_original(bytes: Vec<u8>, file_name: &str, remove_location: bool) -> PreparedExport {+        let bytes = std::sync::Arc::new(bytes);+        let read: crate::merge::ByteReader = std::sync::Arc::new(move |_| Ok(bytes.as_ref().clone()));+        PreparedExport {+            photo: lightcraft_catalog::PhotoId(1),+            file_name: file_name.into(),+            source_dir: None,+            work: Work::File {+                path: file_name.into(),+                read: Some(read),+                packet: String::new(),+                dng: None,+                quality: 90,+                lossy_edge: None,+                remove_location,+                label: file_name.into(),+                size: (1, 1),+            },+            guard: std::sync::Arc::new(crate::originals::OriginalGuard::default()),+            weight: 1,+        }+    }++    #[test]+    fn original_xmp_gps_guard_checks_extended_packets() {+        let packet = xmp_with_gps();+        let bytes = jpeg_with_extended_xmp(&packet);+        assert_eq!(lightcraft_meta::embedded(&bytes).xmp_extended.as_deref(), Some(packet.as_str()));+        assert!(original_embeds_xmp_gps(&bytes, "test.jpg").unwrap());+    }++    #[test]+    fn original_xmp_gps_guard_rejects_unreadable_extended_packets() {+        let bytes = jpeg_with_extended_xmp("<x:xmpmeta><broken");+        assert!(original_embeds_xmp_gps(&bytes, "test.jpg").is_err());+    }++    #[test]+    fn original_xmp_gps_guard_fails_closed_when_properties_are_truncated() {+        let bytes = png_with_gps_after_xmp_key_budget();+        let packet = lightcraft_meta::embedded(&bytes).xmp.unwrap();+        let parsed = lightcraft_meta::parse_xmp(&packet).unwrap();+        assert!(+            !parsed.properties.contains_key("exif:GPSLatitude"),+            "fixture reaches the key budget before GPS; keys={} bytes={}",+            parsed.properties.len(),+            parsed.properties.keys().map(String::len).sum::<usize>()+        );+        assert!(original_embeds_xmp_gps(&bytes, "test.png").is_err());+    }++    #[test]+    fn original_xmp_gps_guard_rejects_truncated_png_chunks() {+        let bytes = png_with_truncated_chunk_after_xmp();+        assert!(lightcraft_meta::embedded(&bytes).xmp.is_some(), "complete XMP precedes the truncated chunk");+        assert!(original_embeds_xmp_gps(&bytes, "test.png").is_err());+    }++    #[test]+    fn original_xmp_gps_guard_rejects_truncated_webp_chunks() {+        let bytes = webp_with_truncated_chunk_after_xmp();+        assert!(lightcraft_meta::embedded(&bytes).xmp.is_some(), "complete XMP precedes the truncated chunk");+        assert!(original_embeds_xmp_gps(&bytes, "test.webp").is_err());+    }++    #[test]+    fn original_xmp_gps_guard_rejects_xmp_chunks_truncated_before_extraction() {+        for (name, bytes) in [("test.png", png_with_truncated_xmp_chunk()), ("test.webp", webp_with_truncated_xmp_chunk())] {+            assert!(lightcraft_meta::embedded(&bytes).xmp.is_none(), "{name}: truncated packet should not be extracted");+            assert!(original_embeds_xmp_gps(&bytes, name).is_err(), "{name}: incomplete extraction must fail closed");+        }+    }++    #[test]+    fn original_xmp_gps_guard_rejects_trailing_data_after_png_iend() {+        let bytes = png_with_truncated_chunk_after_iend();+        assert!(lightcraft_meta::embedded(&bytes).chunk_walk_incomplete);+        assert!(original_embeds_xmp_gps(&bytes, "test.png").is_err());+    }++    #[test]+    fn original_copy_refuses_incomplete_chunk_stream_without_writing() {+        let options = ExportOptions::from_json(&json!({"format": "original", "removeLocation": true}));+        let copy_options = ExportOptions::from_json(&json!({"format": "original", "removeLocation": false}));+        let destination = Destination { dir: "unused-output".into(), exact: None };+        for (name, bytes) in [("truncated.png", png_with_truncated_chunk_after_xmp()), ("truncated.webp", webp_with_truncated_chunk_after_xmp())] {+            let item = prepared_original(bytes.clone(), name, true);+            let mut writes = Vec::new();+            let mut write = |path: &str, _bytes: &[u8]| {+                writes.push(path.to_owned());+                Ok(())+            };+            let results = run_batch(vec![item], &options, &destination, &mut write, &|_| false, false, &mut |_, _| true).unwrap();+            assert!(results[0]["error"].as_str().is_some_and(|e| e.contains("chunk stream is incomplete")), "{name}: {results:?}");+            assert!(writes.is_empty(), "{name}: incomplete Original must not write bytes or a sidecar");++            let item = prepared_original(bytes, name, false);+            let mut copied = Vec::new();+            let mut write = |path: &str, bytes: &[u8]| {+                copied.push((path.to_owned(), bytes.to_vec()));+                Ok(())+            };+            let results = run_batch(vec![item], &copy_options, &destination, &mut write, &|_| false, false, &mut |_, _| true).unwrap();+            assert!(results[0]["error"].is_null(), "{name}: location removal is disabled: {results:?}");+            assert_eq!(copied.len(), 2, "{name}: copy the Original and its sidecar");+        }+    }+     #[test]     fn size_limit_respected() {         let img = test_image();diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs--- a/crates/engine/src/tests_export.rs+++ b/crates/engine/src/tests_export.rs@@ -697,6 +697,67 @@ fn remove_location_refuses_a_psd_that_embeds_gps() {     let _ = std::fs::remove_dir_all(&dir); } +#[test]+fn remove_location_refuses_original_with_unprojected_xmp_gps_without_writing() {+    use crate::export::{Destination, prepare_batch, run_batch};++    let dir = temp_dir("export-noloc-invalid-xmp-gps");+    let image = lightcraft_raster::Rgba8::from_fn(32, 24, |x, y| [(x * 7) as u8, (y * 9) as u8, 90, 255]);+    let mut session = Session::new().with_fs();+    let options = ExportOptions::from_json(&json!({"format": "original", "removeLocation": true}));+    let cases = [+        ("latitude-minute", "45,60N", "7,15E"),+        ("latitude-second", "45,0,60N", "7,15E"),+        ("longitude-minute", "45,15N", "7,60E"),+        ("longitude-second", "45,15N", "7,0,60E"),+    ];+    for (name, latitude, longitude) in cases {+        let source = dir.join(format!("{name}.jpg"));+        let packet = format!(+            r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">+            <rdf:Description xmlns:exif="http://ns.adobe.com/exif/1.0/" exif:GPSLatitude="{latitude}" exif:GPSLongitude="{longitude}"/>+            </rdf:RDF></x:xmpmeta>"#+        );+        let meta = lightcraft_codecs::EncodeMeta { xmp: Some(&packet), ..Default::default() };+        let original = lightcraft_codecs::encode_jpeg(&lightcraft_codecs::EncodeImage::rgba8(&image), 90, Default::default(), &meta).unwrap();+        assert_eq!(lightcraft_meta::embedded(&original).xmp.as_deref(), Some(packet.as_str()));+        std::fs::write(&source, &original).unwrap();++        session.execute("library.import", &json!({"paths": [source.to_string_lossy()]})).unwrap();+        let id = session.catalog.photos().find(|p| p.file_name == format!("{name}.jpg")).unwrap().id;+        assert_eq!(session.catalog.photo(id).unwrap().meta.gps, None, "{name} is not projected");+        // The source remains authoritative even if catalog metadata has already been cleared.+        session.execute("photo.setMeta", &json!({"ids": [id.0], "gps": null})).unwrap();+        assert_eq!(session.catalog.photo(id).unwrap().meta.gps, None);++        let items = prepare_batch(&mut session, &[id], &options).unwrap();+        let mut writes = Vec::new();+        let mut write = |path: &str, _bytes: &[u8]| {+            writes.push(path.to_string());+            Ok(())+        };+        let exists = |_path: &str| false;+        let mut progress = |_done: usize, _name: &str| true;+        let output = dir.join("out").join(name);+        let results = run_batch(+            items,+            &options,+            &Destination { dir: output.to_string_lossy().into_owned(), exact: None },+            &mut write,+            &exists,+            false,+            &mut progress,+        )+        .unwrap();+        assert_eq!(results.len(), 1, "{results:?}");+        assert!(results[0]["error"].as_str().is_some_and(|e| e.contains("can't remove the location")), "{results:?}");+        assert!(writes.is_empty(), "neither the original nor its sidecar may be written: {writes:?}");+        assert_eq!(std::fs::read(&source).unwrap(), original);+        assert!(!output.exists());+    }+    let _ = std::fs::remove_dir_all(&dir);+}+ #[test] fn remove_location_refuses_a_psd_with_unreadable_xmp() {     let dir = temp_dir("export-noloc-psd-xmp");diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs--- a/crates/engine/src/tests_xmp.rs+++ b/crates/engine/src/tests_xmp.rs@@ -654,14 +654,86 @@ fn jpeg_xmp_packet(rating: i8, title: &str) -> String { }  fn write_jpeg_xmp(path: &Path, packet: &str) -> Vec<u8> {+    write_jpeg_xmp_with_exif(path, packet, None)+}++fn write_jpeg_xmp_with_exif(path: &Path, packet: &str, exif: Option<&lightcraft_meta::Metadata>) -> Vec<u8> {     let img = lightcraft_raster::Rgba8::from_fn(32, 24, |x, y| [(x * 7) as u8, (y * 9) as u8, 90, 255]);-    let meta = lightcraft_codecs::EncodeMeta { xmp: Some(packet), ..Default::default() };+    let exif = exif.map(lightcraft_meta::write_exif);+    let meta = lightcraft_codecs::EncodeMeta { exif: exif.as_deref(), xmp: Some(packet), ..Default::default() };     let bytes = lightcraft_codecs::encode_jpeg(&lightcraft_codecs::EncodeImage::rgba8(&img), 90, Default::default(), &meta).unwrap();     assert_eq!(lightcraft_meta::embedded(&bytes).xmp.as_deref(), Some(packet), "the procedural JPEG carries the requested packet");     std::fs::write(path, &bytes).unwrap();     bytes } +#[test]+fn xmp_gps_component_bounds_do_not_invent_or_override_imported_gps() {+    let src = temp_dir("gps-component-bounds");+    let xmp = |lat: &str, lon: &str| {+        format!(+            r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">+            <rdf:Description xmlns:exif="http://ns.adobe.com/exif/1.0/" exif:GPSLatitude="{lat}" exif:GPSLongitude="{lon}"/>+            </rdf:RDF></x:xmpmeta>"#+        )+    };+    let invalid_only_path = src.join("invalid-only.jpg");+    let invalid_only_packet = xmp("45,60N", "7,15E");+    let invalid_only_bytes = write_jpeg_xmp_with_exif(&invalid_only_path, &invalid_only_packet, None);++    let fallback_path = src.join("exif-fallback.jpg");+    let fallback_packet = xmp("45,0,60N", "7,15E");+    let fallback_gps = lightcraft_meta::Gps { latitude: 48.85, longitude: 2.35, altitude: None };+    let exif = lightcraft_meta::Metadata { gps: Some(fallback_gps), ..Default::default() };+    let fallback_bytes = write_jpeg_xmp_with_exif(&fallback_path, &fallback_packet, Some(&exif));++    let valid_xmp_path = src.join("valid-xmp.jpg");+    let valid_xmp_packet = xmp("45,30N", "7,15E");+    let valid_xmp_bytes = write_jpeg_xmp_with_exif(&valid_xmp_path, &valid_xmp_packet, Some(&exif));++    let mut session = Session::new().with_fs();+    let imported = session+        .execute(+            "library.import",+            &json!({"paths": [invalid_only_path.to_string_lossy(), fallback_path.to_string_lossy(), valid_xmp_path.to_string_lossy()]}),+        )+        .unwrap();+    assert_eq!(imported["imported"].as_array().unwrap().len(), 3, "{imported}");+    let id_for = |name: &str| session.catalog.photos().find(|p| p.file_name == name).unwrap().id;+    let invalid_id = id_for("invalid-only.jpg");+    let fallback_id = id_for("exif-fallback.jpg");+    let valid_xmp_id = id_for("valid-xmp.jpg");+    assert_eq!(session.catalog.photo(invalid_id).unwrap().meta.gps, None, "invalid embedded XMP cannot invent a location");+    assert_eq!(+        session.catalog.photo(fallback_id).unwrap().meta.gps,+        Some((fallback_gps.latitude, fallback_gps.longitude)),+        "valid EXIF remains the fallback"+    );+    assert_eq!(session.catalog.photo(valid_xmp_id).unwrap().meta.gps, Some((45.5, 7.25)), "valid XMP continues to override EXIF");++    for (id, raw_lat, raw_lon) in [(invalid_id, "45,60N", "7,15E"), (fallback_id, "45,0,60N", "7,15E")] {+        let all = session.execute("photo.allMetadata", &json!({"id": id.0})).unwrap();+        let properties = all["xmp"].as_array().unwrap();+        assert!(properties.iter().any(|p| p["name"] == "exif:GPSLatitude" && p["value"] == raw_lat), "raw latitude remains visible: {all}");+        assert!(properties.iter().any(|p| p["name"] == "exif:GPSLongitude" && p["value"] == raw_lon), "raw longitude remains visible: {all}");++        session.execute("library.select", &json!({"ids": [id.0]})).unwrap();+        session.execute("photo.saveMetadataToFile", &json!({})).unwrap();+        let path = if id == invalid_id { src.join("invalid-only.xmp") } else { src.join("exif-fallback.xmp") };+        let saved = lightcraft_meta::parse_xmp(&std::fs::read_to_string(path).unwrap()).unwrap().metadata;+        if id == invalid_id {+            assert_eq!(saved.gps, None, "invalid raw fields are not canonicalized into the sidecar");+        } else {+            assert_eq!(saved.gps, Some(fallback_gps), "the valid EXIF fallback is saved");+        }+    }++    assert_eq!(std::fs::read(&invalid_only_path).unwrap(), invalid_only_bytes, "import and metadata save leave original bytes unchanged");+    assert_eq!(std::fs::read(&fallback_path).unwrap(), fallback_bytes, "import and metadata save leave original bytes unchanged");+    assert_eq!(std::fs::read(&valid_xmp_path).unwrap(), valid_xmp_bytes, "import leaves the valid XMP source unchanged");+    let _ = std::fs::remove_dir_all(&src);+}+ #[test] fn embedded_jpeg_xmp_reread_refreshes_metadata_and_undoes_once() {     let src = temp_dir("jpeg-xmp-refresh");diff --git a/crates/meta/README.md b/crates/meta/README.md--- a/crates/meta/README.md+++ b/crates/meta/README.md@@ -8,8 +8,8 @@ Photo metadata: EXIF, XMP (read + write), IPTC-IIM, and the containers that carr |---|---| | `read_exif(&[u8]) -> Metadata` | TIFF-structured Exif block (whole TIFF/DNG/raw file, or JPEG APP1 payload with/without `Exif\0\0`). `try_read_exif` reports header errors. | | `from_tiff(&Tiff) -> Metadata` | Same, from an already parsed stream (used by `lightcraft-raw`). |-| `embedded(&[u8]) -> Embedded` | Sniffs JPEG / PNG / WebP / TIFF / PSD / PSB and returns the Exif, XMP (+ extended XMP), ICC and IPTC blocks. PSD/PSB EXIF resource 1058 and XMP resource 1060 are read from the bounded image-resource section without decoding pixels. `jpeg_segments`, `png_chunks`, `webp_chunks` are the per-container walkers. |-| `parse_xmp(&str) -> Result<XmpData>` | Metadata + the opaque `lc:settings` JSON + every property (`prefix:name`, structs flattened as `a:b/c:d`). |+| `embedded(&[u8]) -> Embedded` | Sniffs JPEG / PNG / WebP / TIFF / PSD / PSB and returns the Exif, XMP (+ extended XMP), ICC and IPTC blocks. PSD/PSB EXIF resource 1058 and XMP resource 1060 are read from the bounded image-resource section without decoding pixels. `Embedded::chunk_walk_incomplete` reports a PNG/WebP chunk stream that could not be fully inspected; callers that must prove a location is absent should fail closed. `jpeg_segments`, `png_chunks`, `webp_chunks` are the per-container walkers. |+| `parse_xmp(&str) -> Result<XmpData>` | Metadata + the opaque `lc:settings` JSON + flattened properties (`prefix:name`, structs as `a:b/c:d`); `properties_truncated` reports when the key-size budget omitted entries. | | `write_xmp(&Metadata, Option<&str>) -> String` | Complete `<?xpacket?>` packet; the second argument is LightCraft's full develop settings JSON. | | `parse_iptc(&[u8]) -> Metadata` | IIM record 2: title (2:5), keywords (2:25), by-line (2:80), copyright (2:116), caption (2:120), date/time created (2:55/2:60). | | `extract(&[u8]) -> Metadata` | Everything for a whole file. Precedence: EXIF → IPTC fills gaps → XMP overrides user fields (rating, label, title, caption, artist, copyright, keywords, GPS) and fills the rest. |@@ -35,6 +35,11 @@ Merging preserves the original foreign XML rather than writing these opaque keys 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. +XMP GPS latitude and longitude are projected into `Metadata` only when each supplied minute and second+component is in `[0, 60)` and the resulting coordinate is within its axis range. Invalid coordinate+properties remain available in `XmpData::properties`, but are not applied as GPS and cannot override+valid EXIF GPS during extraction.+ ## Robustness  No panics on malformed input: proptests feed random bytes to every container walker and random text to thediff --git a/crates/meta/src/container.rs b/crates/meta/src/container.rs--- a/crates/meta/src/container.rs+++ b/crates/meta/src/container.rs@@ -26,6 +26,8 @@ pub struct Embedded {     pub icc: Option<Vec<u8>>,     /// IPTC-IIM stream.     pub iptc: Option<Vec<u8>>,+    /// True when the PNG/WebP chunk stream could not be fully inspected.+    pub chunk_walk_incomplete: bool, }  /// Sniff the container and collect its metadata blocks. Unknown containers yield an empty result.@@ -69,6 +71,7 @@ pub fn embedded(bytes: &[u8]) -> Embedded {                 lightcraft_tiff::Value::Byte(b) | lightcraft_tiff::Value::Undefined(b) => b.clone(),                 other => lightcraft_tiff::writer::encode(t.order, other),             }),+            chunk_walk_incomplete: false,         }     } else {         Embedded::default()@@ -167,10 +170,41 @@ fn photoshop_iptc(mut b: &[u8]) -> Option<Vec<u8>> { pub fn png_chunks(b: &[u8]) -> Embedded {     let mut out = Embedded::default();     let mut i = 8usize;-    while i + 12 <= b.len() {-        let len = u32::from_be_bytes([b[i], b[i + 1], b[i + 2], b[i + 3]]) as usize;-        let typ = &b[i + 4..i + 8];-        let Some(data) = b.get(i + 8..(i + 8).saturating_add(len)) else { break };+    let mut ended = false;+    loop {+        let Some(header_end) = i.checked_add(8).filter(|end| *end <= b.len()) else {+            out.chunk_walk_incomplete = true;+            break;+        };+        let Some(header) = b.get(i..header_end) else {+            out.chunk_walk_incomplete = true;+            break;+        };+        let Some(length_bytes) = header.get(..4) else {+            out.chunk_walk_incomplete = true;+            break;+        };+        let Ok(length_bytes) = <[u8; 4]>::try_from(length_bytes) else {+            out.chunk_walk_incomplete = true;+            break;+        };+        let len = u32::from_be_bytes(length_bytes) as usize;+        let Some(data_end) = header_end.checked_add(len) else {+            out.chunk_walk_incomplete = true;+            break;+        };+        let Some(chunk_end) = data_end.checked_add(4).filter(|end| *end <= b.len()) else {+            out.chunk_walk_incomplete = true;+            break;+        };+        let Some(typ) = header.get(4..8) else {+            out.chunk_walk_incomplete = true;+            break;+        };+        let Some(data) = b.get(header_end..data_end) else {+            out.chunk_walk_incomplete = true;+            break;+        };         match typ {             b"eXIf" => out.exif = Some(crate::strip_exif_header(data).to_vec()),             b"iTXt" if data.starts_with(b"XML:com.adobe.xmp\0") => {@@ -185,10 +219,17 @@ pub fn png_chunks(b: &[u8]) -> Embedded {                     }                 }             }-            b"IEND" => break,+            b"IEND" => {+                ended = len == 0 && chunk_end == b.len();+                out.chunk_walk_incomplete |= !ended;+                break;+            }             _ => {}         }-        i += 12 + len;+        i = chunk_end;+    }+    if !ended {+        out.chunk_walk_incomplete = true;     }     out }@@ -196,18 +237,69 @@ pub fn png_chunks(b: &[u8]) -> Embedded { /// WebP RIFF chunks `EXIF`, `XMP `, `ICCP`. pub fn webp_chunks(b: &[u8]) -> Embedded {     let mut out = Embedded::default();+    let Some(size_bytes) = b.get(4..8) else {+        out.chunk_walk_incomplete = true;+        return out;+    };+    let Ok(size_bytes) = <[u8; 4]>::try_from(size_bytes) else {+        out.chunk_walk_incomplete = true;+        return out;+    };+    let riff_size = u32::from_le_bytes(size_bytes) as usize;+    let Some(riff_end) = 8usize.checked_add(riff_size) else {+        out.chunk_walk_incomplete = true;+        return out;+    };+    if riff_size < 4 || riff_end < 12 {+        out.chunk_walk_incomplete = true;+        return out;+    }+    if riff_end != b.len() {+        out.chunk_walk_incomplete = true;+    }+    let end = riff_end.min(b.len());     let mut i = 12usize;-    while i + 8 <= b.len() {-        let id = &b[i..i + 4];-        let len = u32::from_le_bytes([b[i + 4], b[i + 5], b[i + 6], b[i + 7]]) as usize;-        let Some(data) = b.get(i + 8..(i + 8).saturating_add(len)) else { break };+    while i < end {+        let Some(header_end) = i.checked_add(8).filter(|next| *next <= end) else {+            out.chunk_walk_incomplete = true;+            break;+        };+        let Some(header) = b.get(i..header_end) else {+            out.chunk_walk_incomplete = true;+            break;+        };+        let Some(length_bytes) = header.get(4..8) else {+            out.chunk_walk_incomplete = true;+            break;+        };+        let Ok(length_bytes) = <[u8; 4]>::try_from(length_bytes) else {+            out.chunk_walk_incomplete = true;+            break;+        };+        let len = u32::from_le_bytes(length_bytes) as usize;+        let Some(data_end) = header_end.checked_add(len) else {+            out.chunk_walk_incomplete = true;+            break;+        };+        let Some(chunk_end) = data_end.checked_add(len & 1).filter(|next| *next <= end) else {+            out.chunk_walk_incomplete = true;+            break;+        };+        let Some(id) = header.get(..4) else {+            out.chunk_walk_incomplete = true;+            break;+        };+        let Some(data) = b.get(header_end..data_end) else {+            out.chunk_walk_incomplete = true;+            break;+        };         match id {             b"EXIF" => out.exif = Some(crate::strip_exif_header(data).to_vec()),             b"XMP " => out.xmp = Some(String::from_utf8_lossy(data).into_owned()),             b"ICCP" => out.icc = Some(data.to_vec()),             _ => {}         }-        i += 8 + len + (len & 1);+        i = chunk_end;     }     out }@@ -299,6 +391,7 @@ mod tests {         let m = crate::extract(&p);         assert_eq!(m.iso, Some(400));         assert_eq!(m.label.as_deref(), Some("Red"));+        assert!(!embedded(&p).chunk_walk_incomplete);          let mut w = b"RIFF\0\0\0\0WEBP".to_vec();         let mut body = |id: &[u8], data: &[u8]| {@@ -314,11 +407,30 @@ mod tests {         let mut ex = b"Exif\0\0".to_vec();         ex.extend_from_slice(&exif);         body(b"EXIF", &ex);+        let riff_size = w.len().saturating_sub(8) as u32;+        w[4..8].copy_from_slice(&riff_size.to_le_bytes());         let e = webp_chunks(&w);+        assert!(!e.chunk_walk_incomplete);         assert_eq!(e.icc.as_deref(), Some(&b"abc"[..]));         assert_eq!(crate::extract(&w).f_number, Some(2.8));     } +    #[test]+    fn chunk_walk_reports_truncated_png_and_webp() {+        let mut png = b"\x89PNG\r\n\x1a\n".to_vec();+        png.extend_from_slice(&32u32.to_be_bytes());+        png.extend_from_slice(b"iTXtshort");+        assert!(png_chunks(&png).chunk_walk_incomplete);++        let mut webp = b"RIFF\0\0\0\0WEBP".to_vec();+        webp.extend_from_slice(b"XMP ");+        webp.extend_from_slice(&32u32.to_le_bytes());+        webp.extend_from_slice(b"short");+        let riff_size = webp.len().saturating_sub(8).saturating_add(27) as u32;+        webp[4..8].copy_from_slice(&riff_size.to_le_bytes());+        assert!(webp_chunks(&webp).chunk_walk_incomplete);+    }+     #[test]     fn tiff_file_is_its_own_exif() {         let t = sample_exif(ByteOrder::Big);diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs--- a/crates/meta/src/xmp.rs+++ b/crates/meta/src/xmp.rs@@ -72,6 +72,8 @@ pub struct XmpData {     /// 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>>,+    /// True when the flattened property map omitted entries after reaching its cumulative key-size limit.+    pub properties_truncated: bool,     /// Every top-level property with its full structure (arrays of structs such as local     /// corrections, which [`XmpData::properties`] flattens).     pub values: BTreeMap<String, XmpValue>,@@ -337,14 +339,17 @@ 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) {+fn collect_description(d: &Node, prefix: &str, props: &mut BTreeMap<String, Vec<String>>, budget: &mut usize, truncated: &mut bool) {     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());+        if !is_rdf_meta_attr(k) {+            let key = format!("{prefix}{k}");+            if take_property_key(&key, budget, truncated) {+                props.entry(key).or_default().push(v.clone());+            }         }     }     for c in &d.children {-        collect_property(c, prefix, props, budget);+        collect_property(c, prefix, props, budget, truncated);     } } @@ -359,7 +364,13 @@ fn take_key_bytes(key: &str, budget: &mut usize) -> bool {     true } -fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap<String, Vec<String>>, budget: &mut usize) {+fn take_property_key(key: &str, budget: &mut usize, truncated: &mut bool) -> bool {+    let keep = take_key_bytes(key, budget);+    *truncated |= !keep;+    keep+}++fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap<String, Vec<String>>, budget: &mut usize, truncated: &mut bool) {     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@@ -373,7 +384,7 @@ fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap<String, Vec<Str         if arr.name == "rdf:Alt" {             items.sort_by_key(|(default, _)| !*default);         }-        if take_key_bytes(&key, budget) {+        if take_property_key(&key, budget, truncated) {             props.entry(key).or_default().extend(items.into_iter().map(|(_, v)| v));         }         return;@@ -381,26 +392,26 @@ fn collect_property(p: &Node, prefix: &str, props: &mut BTreeMap<String, Vec<Str     let struct_prefix = format!("{key}/");     if p.attr("rdf:parseType") == Some("Resource") {         for c in &p.children {-            collect_property(c, &struct_prefix, props, budget);+            collect_property(c, &struct_prefix, props, budget, truncated);         }         return;     }     if let Some(d) = p.children.iter().find(|c| c.name == "rdf:Description") {-        collect_description(d, &struct_prefix, props, budget);+        collect_description(d, &struct_prefix, props, budget, truncated);         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) {+            if take_property_key(&full, budget, truncated) {                 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) {+    if take_property_key(&key, budget, truncated) {         props.entry(key).or_default().push(v);     } }@@ -423,8 +434,8 @@ fn parse_gps_coord(s: &str) -> Option<f64> {     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,+        [d, m] if (0.0..60.0).contains(m) => d + m / 60.0,+        [d, m, sec] if (0.0..60.0).contains(m) && (0.0..60.0).contains(sec) => d + m / 60.0 + sec / 3600.0,         _ => return None,     };     let v = match dir.to_ascii_uppercase() {@@ -520,10 +531,11 @@ pub fn parse_xmp(s: &str) -> Result<XmpData, XmpError> {     find_descriptions(&tree, &mut descs);     let mut props = BTreeMap::new();     let mut values = BTreeMap::new();+    let mut properties_truncated = false;     // 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);+        collect_description(d, "", &mut props, &mut key_budget, &mut properties_truncated);         if let XmpValue::Struct(m) = node_struct(d) {             values.extend(m);         }@@ -604,7 +616,7 @@ pub fn parse_xmp(s: &str) -> Result<XmpData, XmpError> {     }     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 })+    Ok(XmpData { metadata: m, lc_settings, properties: props, properties_truncated, values }) }  fn frac(v: f64) -> String {@@ -977,6 +989,49 @@ mod tests {         assert_eq!(gps("90,0S", "180,0W"), Some(Gps { latitude: -90.0, longitude: -180.0, altitude: None }));     } +    #[test]+    fn xmp_gps_component_bounds() {+        let parse = |lat: &str, lon: &str| {+            let x = format!(+                r#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"+                xmlns:e="http://ns.adobe.com/exif/1.0/" e:GPSLatitude="{lat}" e:GPSLongitude="{lon}"/>"#+            );+            parse_xmp(&x).unwrap()+        };++        for (lat, lon) in [+            ("45,60N", "7,15E"),+            ("45,99N", "7,15E"),+            ("45,0,60N", "7,15E"),+            ("45,0,99N", "7,15E"),+            ("45,15N", "7,60E"),+            ("45,15N", "7,99E"),+            ("45,15N", "7,0,60E"),+            ("45,15N", "7,0,99E"),+        ] {+            let xmp = parse(lat, lon);+            assert_eq!(xmp.metadata.gps, None, "{lat}, {lon}");+            assert_eq!(xmp.properties.get("exif:GPSLatitude").and_then(|v| v.first()).map(String::as_str), Some(lat));+            assert_eq!(xmp.properties.get("exif:GPSLongitude").and_then(|v| v.first()).map(String::as_str), Some(lon));+        }++        for (lat, lon, expected) in [+            ("45,59.5N", "7,59.5E", (45.0 + 59.5 / 60.0, 7.0 + 59.5 / 60.0)),+            ("45,59,59S", "7,59,59W", (-(45.0 + 59.0 / 60.0 + 59.0 / 3600.0), -(7.0 + 59.0 / 60.0 + 59.0 / 3600.0))),+            ("0,0N", "0,0E", (0.0, 0.0)),+            ("90,0N", "180,0W", (90.0, -180.0)),+            (" 45,30n ", " 7,15e ", (45.5, 7.25)),+        ] {+            let xmp = parse(lat, lon);+            let gps = xmp.metadata.gps.expect("valid XMP GPS components");+            assert!((gps.latitude - expected.0).abs() < 1e-9, "{lat}: {}", gps.latitude);+            assert!((gps.longitude - expected.1).abs() < 1e-9, "{lon}: {}", gps.longitude);+        }++        assert_eq!(parse("95,0N", "7,0E").metadata.gps, None, "combined latitude range remains checked");+        assert_eq!(parse("45,0N", "200,0E").metadata.gps, None, "combined longitude range remains checked");+    }+     /// MWG Region Guidelines v2.0, element form (`rdf:parseType="Resource"`, self-closing `Area`) — how     /// Lightroom typically writes it. One named face, one untyped/unnamed region (still usable), one     /// region with a description. `AppliedToDimensions` is present but irrelevant: normalized areas@@ -1221,6 +1276,7 @@ mod tests {         let d = parse_xmp(&doc).unwrap();         let total: usize = d.properties.keys().map(String::len).sum();         assert!(total <= MAX_KEY_BYTES, "flattened-key text {total} is over the {MAX_KEY_BYTES} budget");+        assert!(d.properties_truncated, "omitted flattened keys are reported to callers");         assert!(d.properties.len() < 3000, "keys stop being added once the budget is spent");         assert!(!d.properties.is_empty(), "properties parsed before the budget are kept");     }diff --git a/crates/ui-egui/locales/cs.json b/crates/ui-egui/locales/cs.json--- a/crates/ui-egui/locales/cs.json+++ b/crates/ui-egui/locales/cs.json@@ -1630,7 +1630,7 @@   "Revert": "Vrátit změny",   "Review": "Zkontrolovat",   "Review Import": "Zkontrolovat import",-  "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Pravé tlačítko na fotografii → Nastavit jako referenční fotografii, pak Zobrazení → Referenční zobrazení (Shift+R)",+  "Right-click a photo → Set as Reference Photo, then View → Reference View (Shift+R)": "Pravé tlačítko na fotografii → Nastavit jako referenční fotografii, pak Zobrazení → Referenční zobrazení (Shift+R)",   "Right-click a preset to delete it": "Přednastavení odstraníte kliknutím pravým tlačítkem",   "Rights Usage Terms": "Podmínky použití práv",   "Rotate": "Otočit",diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json--- a/crates/ui-egui/locales/de.json+++ b/crates/ui-egui/locales/de.json@@ -1295,7 +1295,7 @@   "Revert": "Zurück zum letzten Stand",   "Review": "Überprüfung",   "Review Import": "Import überprüfen",-  "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Rechtsklick auf ein Foto → Als Referenzfoto festlegen, dann Ansicht → Referenzansicht (Umschalt+R)",+  "Right-click a photo → Set as Reference Photo, then View → Reference View (Shift+R)": "Rechtsklick auf ein Foto → Als Referenzfoto festlegen, dann Ansicht → Referenzansicht (Umschalt+R)",   "Right-click a preset to delete it": "Rechtsklick auf eine Vorgabe, um sie zu löschen",   "Rights Usage Terms": "Nutzungsbedingungen",   "Rotate": "Drehen",diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json--- a/crates/ui-egui/locales/es.json+++ b/crates/ui-egui/locales/es.json@@ -1302,7 +1302,7 @@   "Revert": "Revertir",   "Review": "Revisión",   "Review Import": "Revisar importación",-  "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Clic derecho en una foto → Definir como foto de referencia y, después, Vista → Vista de referencia (Mayús+R)",+  "Right-click a photo → Set as Reference Photo, then View → Reference View (Shift+R)": "Clic derecho en una foto → Definir como foto de referencia y, después, Vista → Vista de referencia (Mayús+R)",   "Right-click a preset to delete it": "Clic derecho en un ajuste preestablecido para eliminarlo",   "Rights Usage Terms": "Condiciones de uso de los derechos",   "Rotate": "Rotar",diff --git a/crates/ui-egui/locales/fi.json b/crates/ui-egui/locales/fi.json--- a/crates/ui-egui/locales/fi.json+++ b/crates/ui-egui/locales/fi.json@@ -1677,7 +1677,7 @@   "Review": "Tarkistus",   "Review Import": "Tarkista tuonti",   "Right": "Oikea",-  "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Napsauta kuvaa hiiren kakkospainikkeella → Aseta vertailukuvaksi, sitten Näytä → Vertailunäkymä (Shift+R)",+  "Right-click a photo → Set as Reference Photo, then View → Reference View (Shift+R)": "Napsauta kuvaa hiiren kakkospainikkeella → Aseta vertailukuvaksi, sitten Näytä → Vertailunäkymä (Shift+R)",   "Right-click a preset to delete it": "Poista esiasetus napsauttamalla sitä hiiren kakkospainikkeella",   "Rights Usage Terms": "Oikeuksien käyttöehdot",   "Rotate": "Kierrä",diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json--- a/crates/ui-egui/locales/fr.json+++ b/crates/ui-egui/locales/fr.json@@ -1230,7 +1230,7 @@   "Revert": "Rétablir l'original",   "Review": "Révision",   "Review Import": "Vérifier l'importation",-  "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Clic droit sur une photo → Définir comme photo de référence, puis Affichage → Vue de référence (Maj+R)",+  "Right-click a photo → Set as Reference Photo, then View → Reference View (Shift+R)": "Clic droit sur une photo → Définir comme photo de référence, puis Affichage → Vue de référence (Maj+R)",   "Right-click a preset to delete it": "Clic droit sur un préréglage pour le supprimer",   "Rights Usage Terms": "Conditions d'utilisation des droits",   "Rotate": "Pivoter",diff --git a/crates/ui-egui/locales/id.json b/crates/ui-egui/locales/id.json--- a/crates/ui-egui/locales/id.json+++ b/crates/ui-egui/locales/id.json@@ -1668,7 +1668,7 @@   "Review": "Tinjauan",   "Review Import": "Tinjau impor",   "Right": "Kanan",-  "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Klik kanan foto → Jadikan foto referensi, lalu Tampilan → Tampilan referensi (Shift+R)",+  "Right-click a photo → Set as Reference Photo, then View → Reference View (Shift+R)": "Klik kanan foto → Jadikan foto referensi, lalu Tampilan → Tampilan referensi (Shift+R)",   "Right-click a preset to delete it": "Klik kanan preset untuk menghapusnya",   "Rights Usage Terms": "Ketentuan penggunaan",   "Rotate": "Putar",diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json--- a/crates/ui-egui/locales/it.json+++ b/crates/ui-egui/locales/it.json@@ -1266,7 +1266,7 @@   "Revert": "Ripristina",   "Review": "Revisione",   "Review Import": "Rivedi importazione",-  "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Clic destro su una foto → Imposta come foto di riferimento, poi Visualizza → Vista di riferimento (Maiusc+R)",+  "Right-click a photo → Set as Reference Photo, then View → Reference View (Shift+R)": "Clic destro su una foto → Imposta come foto di riferimento, poi Visualizza → Vista di riferimento (Maiusc+R)",   "Right-click a preset to delete it": "Clic destro su un preset per eliminarlo",   "Rights Usage Terms": "Condizioni d'uso dei diritti",   "Rotate": "Ruota",diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json--- a/crates/ui-egui/locales/ja.json+++ b/crates/ui-egui/locales/ja.json@@ -1180,7 +1180,7 @@   "Reverse the order": "逆順にする",   "Review": "レビュー",   "Review Import": "読み込み内容を確認",-  "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "写真を右クリックして参照写真に設定し、「表示 → 参照表示」(⇧R)を選択してください",+  "Right-click a photo → Set as Reference Photo, then View → Reference View (Shift+R)": "写真を右クリックして参照写真に設定し、「表示 → 参照表示」(⇧R)を選択してください",   "Right-click a preset to delete it": "プリセットを右クリックして削除",   "Rights Usage Terms": "利用条件",   "Rotate": "回転",diff --git a/crates/ui-egui/locales/ka.json b/crates/ui-egui/locales/ka.json--- a/crates/ui-egui/locales/ka.json+++ b/crates/ui-egui/locales/ka.json@@ -1754,7 +1754,7 @@   "Right-click a folder (or a disk other than the startup disk) in the sidebar's Folders section ▸ Synchronize": "მარჯვენა დაწკაპუნება საქაღალდეზე (ან სასტარტოს გარდა სხვა დისკზე) გვერდითი ზოლის საქაღალდეების სექციაში → საქაღალდის",   "Right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder…": "მარჯვენა დაწკაპუნება საქაღალდეზე → ახალი → ალბომის შექმნა… / სმარტ-ალბომის შექმნა… / სმარტ-ალბომის შექმნა ფილტრიდან… / საქაღალდის შექმნა…",   "Right-click a photo ▸ Edit In ▸ Edit in PhotoCraft (or Photo ▸ Edit In, ⇧⌘E) renders a copy with your edits, stacks it": "მარჯვენა დაწკაპუნება ფოტოზე → რედაქტირება სხვა პროგრამაში → რედაქტირება PhotoCraft-ში (ან ფოტო → რედაქტირება სხვა პროგრამაში, ⇧⌘E) არენდერებს ასლს თქვენი ცვლილებებით, ათავსებს მას",-  "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "დაწკაპეთ ფოტოზე მარჯვენა ღილაკით → ეტალონურ ფოტოდ დაყენება, შემდეგ ხედი → ეტალონური ხედი (Shift+R)",+  "Right-click a photo → Set as Reference Photo, then View → Reference View (Shift+R)": "დაწკაპეთ ფოტოზე მარჯვენა ღილაკით → ეტალონურ ფოტოდ დაყენება, შემდეგ ხედი → ეტალონური ხედი (Shift+R)",   "Right-click a preset to delete it": "პრესეტის წასაშლელად დაწკაპეთ მასზე მარჯვენა ღილაკით",   "Right-click ▸ Put New Keywords Inside This Keyword: new keywords, made with + or typed in the box above, go inside": "მარჯვენა დაწკაპუნება → ახალი საკვანძო სიტყვების ამ საკვანძო სიტყვაში ჩასმა: + ღილაკით ან ზემოთ ველში აკრეფილი ახალი საკვანძო სიტყვები",   "Rights Usage Terms": "უფლებების გამოყენების პირობები",diff --git a/crates/ui-egui/locales/pl.json b/crates/ui-egui/locales/pl.json--- a/crates/ui-egui/locales/pl.json+++ b/crates/ui-egui/locales/pl.json@@ -1205,7 +1205,7 @@   "Revert": "Cofnij zmiany",   "Review": "Przegląd",   "Review Import": "Sprawdź import",-  "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Kliknij zdjęcie prawym przyciskiem → Ustaw jako zdjęcie referencyjne, a potem Widok → Widok referencyjny (Shift+R)",+  "Right-click a photo → Set as Reference Photo, then View → Reference View (Shift+R)": "Kliknij zdjęcie prawym przyciskiem → Ustaw jako zdjęcie referencyjne, a potem Widok → Widok referencyjny (Shift+R)",   "Right-click a preset to delete it": "Kliknij ustawienie wstępne prawym przyciskiem, aby je usunąć",   "Rights Usage Terms": "Warunki korzystania z praw",   "Rotate": "Obróć",diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json--- a/crates/ui-egui/locales/pt-br.json+++ b/crates/ui-egui/locales/pt-br.json@@ -1253,7 +1253,7 @@   "Revert": "Reverter",   "Review": "Revisão",   "Review Import": "Revisar importação",-  "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Clique com o botão direito em uma foto → Definir como foto de referência, depois Exibir → Exibição de referência (Shift+R)",+  "Right-click a photo → Set as Reference Photo, then View → Reference View (Shift+R)": "Clique com o botão direito em uma foto → Definir como foto de referência, depois Exibir → Exibição de referência (Shift+R)",   "Right-click a preset to delete it": "Clique com o botão direito em uma predefinição para excluí-la",   "Rights Usage Terms": "Termos de uso dos direitos",   "Rotate": "Girar",diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json--- a/crates/ui-egui/locales/ru.json+++ b/crates/ui-egui/locales/ru.json@@ -1040,7 +1040,7 @@   "Retouch": "Ретушь",   "Revert": "Вернуть",   "Review Import": "Проверить импорт",-  "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Щёлкните фото правой кнопкой → Назначить эталонным фото, затем Вид → Режим эталона (Shift+R)",+  "Right-click a photo → Set as Reference Photo, then View → Reference View (Shift+R)": "Щёлкните фото правой кнопкой → Назначить эталонным фото, затем Вид → Режим эталона (Shift+R)",   "Right-click a preset to delete it": "Щёлкните пресет правой кнопкой, чтобы удалить его",   "Rights Usage Terms": "Условия использования прав",   "Rotate": "Повернуть",diff --git a/crates/ui-egui/locales/sv.json b/crates/ui-egui/locales/sv.json--- a/crates/ui-egui/locales/sv.json+++ b/crates/ui-egui/locales/sv.json@@ -1826,7 +1826,7 @@   "Review": "Granskning",   "Review Import": "Granska import",   "Right": "Höger",-  "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Högerklicka på ett foto → Ange som referensfoto, sedan Visa → Referensvy (Shift+R)",+  "Right-click a photo → Set as Reference Photo, then View → Reference View (Shift+R)": "Högerklicka på ett foto → Ange som referensfoto, sedan Visa → Referensvy (Shift+R)",   "Right-click a preset to delete it": "Högerklicka på en förinställning för att radera den",   "Rights Usage Terms": "Villkor för användningsrätt",   "Rotate": "Rotera",diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json--- a/crates/ui-egui/locales/uk.json+++ b/crates/ui-egui/locales/uk.json@@ -1273,7 +1273,7 @@   "Restore them": "Відновити їх",   "Retouch": "Ретушування",   "Review Import": "Перевірити імпорт",-  "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Клацніть фото правою кнопкою → Задати як еталонне фото, потім Вигляд → Еталонне подання (Shift+R)",+  "Right-click a photo → Set as Reference Photo, then View → Reference View (Shift+R)": "Клацніть фото правою кнопкою → Задати як еталонне фото, потім Вигляд → Еталонне подання (Shift+R)",   "Right-click a preset to delete it": "Клацніть набір налаштувань правою кнопкою, щоб видалити його",   "Rights Usage Terms": "Умови використання прав",   "Rotate Crop": "Обертати кадрування",diff --git a/crates/ui-egui/locales/vi.json b/crates/ui-egui/locales/vi.json--- a/crates/ui-egui/locales/vi.json+++ b/crates/ui-egui/locales/vi.json@@ -1543,7 +1543,7 @@   "Revert": "Hoàn tác",   "Review": "Xem lại",   "Review Import": "Xem lại nhập",-  "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Nhấp phải ảnh → Đặt làm ảnh tham chiếu, rồi Xem → Chế độ tham chiếu (Shift+R)",+  "Right-click a photo → Set as Reference Photo, then View → Reference View (Shift+R)": "Nhấp phải ảnh → Đặt làm ảnh tham chiếu, rồi Xem → Chế độ tham chiếu (Shift+R)",   "Right-click a preset to delete it": "Nhấp phải thiết lập sẵn để xóa",   "Rights Usage Terms": "Điều khoản sử dụng quyền",   "Rotate": "Xoay",diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json--- a/crates/ui-egui/locales/zh-hans.json+++ b/crates/ui-egui/locales/zh-hans.json@@ -1237,7 +1237,7 @@   "Revert": "恢复",   "Review": "审阅",   "Review Import": "查看导入内容",-  "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "右键单击照片 » 设为参考照片,然后选择“视图 → 参考视图”(Shift+R)",+  "Right-click a photo → Set as Reference Photo, then View → Reference View (Shift+R)": "右键单击照片 » 设为参考照片,然后选择“视图 → 参考视图”(Shift+R)",   "Right-click a preset to delete it": "右键单击预设即可将其删除",   "Rights Usage Terms": "使用条款",   "Rotate": "旋转",diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json--- a/crates/ui-egui/locales/zh-hant.json+++ b/crates/ui-egui/locales/zh-hant.json@@ -1238,7 +1238,7 @@   "Revert": "回復至上次儲存",   "Review": "審閱",   "Review Import": "檢視匯入",-  "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "在照片上按右鍵 → 設為參考照片,再選擇「檢視 → 參考檢視」(Shift+R)",+  "Right-click a photo → Set as Reference Photo, then View → Reference View (Shift+R)": "在照片上按右鍵 → 設為參考照片,再選擇「檢視 → 參考檢視」(Shift+R)",   "Right-click a preset to delete it": "在預設集上按右鍵即可刪除",   "Rights Usage Terms": "使用權利條款",   "Rotate": "旋轉",diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs--- a/crates/ui-egui/src/panels/compare.rs+++ b/crates/ui-egui/src/panels/compare.rs@@ -601,7 +601,7 @@ pub fn show_reference(app: &mut LightcraftApp, ui: &mut egui::Ui) {     let canvas = area_and_filmstrip(app, ui);     let reference = app.ui.reference.map(PhotoId).filter(|r| live(app, *r));     let (Some(r), Some(active)) = (reference, app.session.active()) else {-        super::empty_message(ui, canvas, "No reference photo", "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)");+        super::empty_message(ui, canvas, "No reference photo", "Right-click a photo → Set as Reference Photo, then View → Reference View (Shift+R)");         return;     };     let area = canvas.shrink(18.0);diff --git a/docs/gaps.md b/docs/gaps.md--- a/docs/gaps.md+++ b/docs/gaps.md@@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-24 · **Change:** minor (gap 30: the Info panel shows the selected photos' shared metadata and marks mixed fields; gap 28: ⌘⌥⇧C / ⌘⌥⇧V copy and paste metadata; gap 13: before a Lightroom catalog import the app asks merge or new library, #2672; gap 28: F8 shows / hides the right panel; import review album hierarchy, #2362; gap 20: Minolta MRW decodes; gap 10: 551 models verified; gap 15: Print Page Options — page numbers, page info, crop marks; gap 28: `\` shows / hides the Filter Bar in the grids; gap 21: Indonesian interface shipped, every message but What's New; gap 27: the Windows x64 installer verified on Windows 11; the Settings → Apps uninstall confirmation remains; gap 28: ⌘L Enable Filters switches the filter off and on; gap 15: Japanese photo and postcard paper sizes in the Print page setup and PDF)+> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-26 · **Change:** patch (XMP GPS subdivision validation; no ranked gap or readiness accounting changed)  Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is@@ -84,6 +84,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w  | Date | Change | Summary | |---|---|---|+| 2026-10-26 | patch | XMP GPS minute / second components outside `[0, 60)` no longer project into metadata; malformed values remain raw, valid EXIF fallback is preserved, and Original export Remove Location Info checks source XMP; no ranked gap or readiness change | | 2026-10-24 | minor | Gap 28: ⌘⌥⇧C / ⌘⌥⇧V (Ctrl+Alt+Shift+C / V on Windows and Linux) copy and paste metadata; no key leaves the entry; hours unchanged | | 2026-10-24 | minor | Gap 30 shrinks: the Info panel's Show Metadata For (Target Photo / Selected Photos, `view.infoSelectedPhotos`) shows the selected photos' shared values and marks fields that differ as Mixed; the All Metadata dialog remains; 1–3 h (was 4–8) | | 2026-10-23 | minor | Gap 13: before a Lightroom catalog import the app asks whether it merges into the open library or goes into a new one (opt-out in Settings, #2672); hours unchanged |diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md--- a/docs/parity-checklist.md+++ b/docs/parity-checklist.md@@ -166,9 +166,9 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). |---|---|---|---|---|---| | LR-META-INFO | Info panel | P0 | ✅ | `cmd:panel.info`, `cmd:photo.setMeta`, `cmd:view.infoSelectedPhotos`, `crates/ui-egui/src/panels/right.rs` (`info`, `camera_card`, `mixed_meta`) | camera card (camera, lens, size, format, focal length / shutter / aperture / ISO); title, caption, alt text, extended description, copyright, copyright status, rights usage terms, copyright info URL, creator; file name (rename), file path (reveal), capture time (edit); location, city, state, country; GPS. With several photos selected, Show Metadata For picks Selected Photos (the default: the values they share; a text field or GPS where they differ is empty and says Mixed, Copyright Status shows Mixed, and only what is typed or picked there is saved, to every selected photo) or Target Photo (the active photo's values; an edit goes to that photo alone). No flash, map snippet or people; a raw with no lens name (Nikon DSLRs) shows its range from Exif LensSpecification or the Nikon maker note's Lens tag, written like Lightroom's `24.0-70.0 mm f/2.8` | | 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-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; XMP minute / second subdivisions outside `[0, 60)` are not projected, while raw values remain in All Metadata; 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 (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-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; XMP GPS minutes / seconds must be in `[0, 60)`, invalid values remain visible as raw properties and do not override EXIF GPS; 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; PSD/PSB EXIF resource 1058 and XMP resource 1060 feed the same metadata rows and preserve sidecar precedence; metadata presets |  ## F. Edit panel — global adjustments (EDIT)@@ -398,7 +398,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EXP-COLORSPACE | Output colour space | P0 | ✅ | `cmd:app.export` (`colorSpace`), `crates/pipeline/src/output.rs`, `crates/engine/src/export.rs` | sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC); lossy JPEG XL declares sRGB / P3 / Rec. 2020 (Adobe RGB and ProPhoto written as Rec. 2020), lossless JPEG XL embeds the profile | | LR-EXP-HDR | HDR output | P2 | 🟡 | `cmd:app.export`, `crates/ui-egui/src/panels/dialogs.rs` | HDR output checkbox in the export dialog: gain map JPEG, PQ or HLG AVIF, PQ PNG, PQ JPEG XL, float TIFF; no HDR presets yet | | LR-EXP-SHARPEN | Output sharpening | P1 | ✅ | `cmd:app.export` (`sharpen`, `sharpenAmount`) | |-| LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | |+| LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | Original + Remove Location Info refuses to copy a source containing a nonblank recognized XMP GPS latitude / longitude property even when malformed coordinates are not projected. | | LR-EXP-WATERMARK | Watermark | P1 | 🟡 | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text uses Inter SemiBold, the existing Mincho-first CJK chain, then Thai faces from `CRAFT_FONTS_DIR`, shared with contact-sheet captions and slideshow overlays (#2052, partial). Horizontal Thai mark positioning remains incomplete. Arabic export fallback is deferred until joining and bidi reordering exist; UI font fallback is unchanged. Text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (EXIF orientation applied before sizing; oversized graphics fit the rotated image bounds without stretching, tested by `graphic_watermark_honours_exif_orientation` and `graphic_watermark_keeps_aspect_when_fitted_and_rotated`; `image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor` (nine positions), `insetX` / `insetY` (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single `inset` still reads as both; tested by `watermark_insets_are_horizontal_and_vertical`), `rotation` (0, 90, 180 or 270 degrees clockwise, the Export dialog's Rotate row, as Lightroom Classic's rotate buttons; sized before it is turned, placed and inset by the turned box; tested by `watermark_rotation_turns_the_text_in_its_corner` and `graphic_watermark_is_placed_scaled_and_blended`), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183). The Export dialog previews it on the first exported photo's thumbnail (`draw_watermark_preview` scales the 6 px minimum text size to the preview so it matches the file), tested by `watermark_preview_scales_the_minimum_text_size` and `export_dialog_previews_the_watermark` | | LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`, `startNumber`), `crates/engine/src/rename.rs` (`expand_tokens`) | one template language for export, Rename Photos and import renaming: `{name}`, `{num}` (number at the end of the name), `{seq}` / `{seq:N}`, `{date}` / `{date:%Y-%m-%d}`, `{folder}`, `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; free-form template rather than a list of named schemes | | LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `destinationMode`: specificFolder / sameAsOriginal, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; Export to: Specific folder or Same folder as original photo (each photo into its original's folder + the subfolder; a demo/browser photo or a missing folder is that photo's error, issue #956); existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) |@@ -799,6 +799,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove  | Date | Change | Summary | |---|---|---|+| 2026-10-26 | patch | LR-META-XMP / LR-META-LOCATION: XMP GPS minute and second components are bounded to `[0, 60)`; invalid values stay visible as raw properties without overriding EXIF, and Original + Remove Location Info still refuses raw GPS-bearing XMP; feature counts and readiness unchanged | | 2026-10-25 | patch | LR-BEHAV-TITLEBAR: the top bar's title-bar gestures come from craft-window's `title_bar`: a double-click follows the system setting, and a press on a button, the search field or a panel floating over the bar never moves the window; status counts unchanged | | 2026-10-24 | minor | KEYC-METADATA, LRC-LIB-KEYWORDS: ⌥0 / ⌥⇧0 (`keyword.nextSet`, `keyword.previousSet`) go to the next / previous keyword set; status counts unchanged (KEYC-METADATA stays 🟡) | | 2026-10-24 | minor | KEYC-METADATA: ⌘K / Ctrl+K (`panel.addKeywords`, Add Keywords) shows the Keywords panel, opens Keywording and puts the cursor in the Add keyword box; status counts unchanged (KEYC-METADATA stays 🟡) |diff --git a/docs/whats-new.md b/docs/whats-new.md--- a/docs/whats-new.md+++ b/docs/whats-new.md@@ -1,5 +1,12 @@ # What's new in LightCraft +## 2026-10-26++### Invalid XMP GPS subdivisions are ignored+- XMP coordinates with minutes or seconds of 60 or more no longer turn into a plausible but incorrect location.+  The original GPS properties remain available in All Metadata, and invalid XMP no longer overrides a valid EXIF+  location. Original export with Remove Location Info still refuses source files that contain those raw GPS fields.+ ## 2026-10-24  ### Relative-date filters and smart albums follow the clockdiff --git a/docs/xmp-interop.md b/docs/xmp-interop.md--- a/docs/xmp-interop.md+++ b/docs/xmp-interop.md@@ -28,6 +28,12 @@ What we write (standard namespaces, so other tools can read the metadata): | Location | `lc:location` | | Develop settings | `lc:settings` — our complete `DevelopSettings` as JSON (exact round trip, incl. masks, spots, crop and the rendering process; settings without `process` are V1, see [process-versions.md](process-versions.md)) | +When reading XMP GPS, each optional minute or second component must be in `[0, 60)` before the+coordinate is combined, and the final latitude / longitude must remain within its axis range.+Out-of-range values stay visible in All Metadata as raw XMP properties but do not become a catalog+location or override a valid EXIF location. Original export with Remove Location Info also checks+recognized nonblank GPS latitude / longitude properties in the source XMP before copying its bytes.+ `lc:` is `http://ns.lightcraft.app/lc/1.0/`.  Reading merges into the catalog with the **sidecar winning** for every field it states; fields it doesn't state are