diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 6a9e9147c..484abd7c8 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -1294,6 +1294,12 @@ pub fn encode_deep(img: &DeepImage, o: &ExportOptions, meta: Option<&Metadata>) r.map_err(|e| e.to_string()) } +/// Whether the export of `photo` under `o` leaves out its location (GPS and place names). +/// (`photo` and `catalog` let library-level privacy rules add to the export option.) +pub fn removes_location(_photo: &lightcraft_catalog::Photo, _catalog: &lightcraft_catalog::Catalog, o: &ExportOptions) -> bool { + o.remove_location +} + /// The metadata to embed for `photo` under `o.metadata` / `o.remove_location`. `None` = embed nothing. /// Keywords follow the catalog's keyword list (`Catalog::export_keywords`). pub fn export_metadata(photo: &lightcraft_catalog::Photo, catalog: &lightcraft_catalog::Catalog, o: &ExportOptions) -> Option { @@ -1402,6 +1408,8 @@ enum Work { read: Option, packet: String, dng: Option, + /// The original must not carry a location ([`removes_location`]). + remove_location: bool, label: String, size: (usize, usize), }, @@ -1461,8 +1469,9 @@ fn prepare_guarded( let work = Work::File { path: path.clone(), read: session.media.file_bytes.clone(), - packet: crate::sidecar::sidecar_packet(p, &session.catalog), + packet: crate::sidecar::sidecar_packet_with(p, &session.catalog, removes_location(p, &session.catalog, o)), dng, + remove_location: removes_location(p, &session.catalog, o), label: p.file_name.clone(), size: (p.width as usize, p.height as usize), }; @@ -1499,12 +1508,18 @@ impl PreparedExport { }; Ok(Exported { file_name, bytes, width: r.image.width, height: r.image.height, sidecars: Vec::new() }) } - Work::File { path, read, packet, dng, label, size } => { + Work::File { path, read, packet, dng, label, size, remove_location } => { let bytes = match &read { Some(r) => r(&path)?, None => std::fs::read(&path).map_err(|e| format!("{path}: {e}"))?, }; let Some(dng) = dng else { + // the file is copied byte for byte: refuse rather than leak a location it embeds + if remove_location && lightcraft_meta::extract(&bytes).gps.is_some() { + return Err(format!( + "{label}: can't remove the location from an original file that embeds GPS; export as JPEG, TIFF or DNG instead" + )); + } return Ok(Exported { file_name, bytes, width: size.0, height: size.1, sidecars: vec![("xmp", packet.into_bytes())] }); }; if lightcraft_raw::probe(&bytes).is_none() { diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index e92d193a7..06eba0b9f 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -248,6 +248,12 @@ impl SidecarData { /// The sidecar packet for a photo; the colour label is written by its name in `cat` (custom /// label names included, as other editors do). pub fn sidecar_packet(p: &Photo, cat: &lightcraft_catalog::Catalog) -> String { + sidecar_packet_with(p, cat, false) +} + +/// [`sidecar_packet`], without any location (GPS, sublocation, city, state, country) when +/// `remove_location` is set (an export with "Remove location info"). +pub fn sidecar_packet_with(p: &Photo, cat: &lightcraft_catalog::Catalog, remove_location: bool) -> String { let nz = |s: &str| (!s.trim().is_empty()).then(|| s.to_string()); let meta = lightcraft_meta::Metadata { software: Some("LightCraft".into()), @@ -271,6 +277,10 @@ pub fn sidecar_packet(p: &Photo, cat: &lightcraft_catalog::Catalog) -> String { gps: p.meta.gps.map(|(latitude, longitude)| lightcraft_meta::Gps { latitude, longitude, altitude: None }), ..Default::default() }; + let mut meta = meta; + if remove_location { + (meta.sublocation, meta.city, meta.state, meta.country, meta.gps) = (None, None, None, None, None); + } let settings = serde_json::to_string(&*p.develop).unwrap_or_default(); let flag = match p.flag { Flag::None => "none", @@ -278,7 +288,7 @@ pub fn sidecar_packet(p: &Photo, cat: &lightcraft_catalog::Catalog) -> String { Flag::Reject => "reject", }; let mut lc = vec![("settings", settings.as_str()), ("flag", flag)]; - if !p.meta.location.trim().is_empty() { + if !remove_location && !p.meta.location.trim().is_empty() { lc.push(("location", p.meta.location.as_str())); } lightcraft_meta::write_xmp_lc(&meta, &lc) diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index b8636c018..654d5dcca 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -440,3 +440,66 @@ fn hdr_avif_export_is_pq_rec2020() { // a photo without an HDR edit exports as an ordinary (sRGB) AVIF assert!(nclx(&sdr.bytes).is_none_or(|c| c[3] != 16)); } + +/// "Remove location info" holds for the file-copying formats too: the sidecar and the DNG's +/// packet carry no location, and an original that embeds GPS is refused rather than copied. +#[test] +fn remove_location_holds_for_original_and_dng_exports() { + let dir = temp_dir("export-noloc"); + let gps = + lightcraft_meta::Metadata { gps: Some(lightcraft_meta::Gps { latitude: 48.85, longitude: 2.35, altitude: None }), ..Default::default() }; + // a raw with no embedded GPS, located in the catalog + let raw = dir.join("Raw.dng"); + std::fs::write(&raw, synthetic_dng_with(None, Default::default())).unwrap(); + // a JPEG with GPS in its EXIF + let jpg = dir.join("Geo.jpg"); + let img = lightcraft_raster::Rgba8::from_fn(32, 24, |x, y| [(x * 8) as u8, (y * 10) as u8, 90, 255]); + let exif = lightcraft_meta::write_exif(&gps); + let meta = lightcraft_codecs::EncodeMeta { exif: Some(&exif), ..Default::default() }; + std::fs::write(&jpg, lightcraft_codecs::encode_jpeg(&lightcraft_codecs::EncodeImage::rgba8(&img), 90, Default::default(), &meta).unwrap()) + .unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [raw.to_string_lossy(), jpg.to_string_lossy()]})).unwrap(); + let id = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id; + let (rid, jid) = (id(&s, "Raw.dng"), id(&s, "Geo.jpg")); + s.execute("photo.setMeta", &json!({"ids": [rid.0, jid.0], "gps": [48.85, 2.35], "city": "Paris"})).unwrap(); + let opts = |f: &str, remove: bool| ExportOptions::from_json(&json!({"format": f, "removeLocation": remove})); + + // original raw: the sidecar keeps the location unless it is removed + let side = |e: &crate::export::Exported| lightcraft_meta::parse_xmp(std::str::from_utf8(&e.sidecars[0].1).unwrap()).unwrap().metadata; + let kept = side(&export_photo(&mut s, rid, &opts("original", false), 1).unwrap()); + assert!(kept.gps.is_some() && kept.city.is_some()); + let gone = side(&export_photo(&mut s, rid, &opts("original", true), 1).unwrap()); + assert!(gone.gps.is_none() && gone.city.is_none(), "{gone:?}"); + + // dng: the embedded packet + let e = export_photo(&mut s, rid, &opts("dng", false), 1).unwrap(); + assert!(lightcraft_meta::extract(&e.bytes).gps.is_some()); + let e = export_photo(&mut s, rid, &opts("dng", true), 1).unwrap(); + let m = lightcraft_meta::extract(&e.bytes); + assert!(m.gps.is_none() && m.city.is_none(), "{m:?}"); + + // original JPEG embedding GPS: copied as is normally, refused when the location must go + let e = export_photo(&mut s, jid, &opts("original", false), 1).unwrap(); + assert!(lightcraft_meta::extract(&e.bytes).gps.is_some()); + let err = export_photo(&mut s, jid, &opts("original", true), 1).err().unwrap(); + assert!(err.contains("can't remove the location") && err.contains("export as JPEG"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// An original raw whose own metadata holds GPS is refused under "Remove location info". +#[test] +fn remove_location_refuses_a_raw_that_embeds_gps() { + let dir = temp_dir("export-noloc-raw"); + let gps = lightcraft_meta::Metadata { gps: Some(lightcraft_meta::Gps { latitude: 10.0, longitude: 20.0, altitude: None }), ..Default::default() }; + let raw = dir.join("Geo.dng"); + let bytes = synthetic_dng_with(Some(&lightcraft_meta::write_xmp(&gps, None)), Default::default()); + assert!(lightcraft_meta::extract(&bytes).gps.is_some(), "fixture embeds GPS"); + std::fs::write(&raw, bytes).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [raw.to_string_lossy()]})).unwrap(); + let id = s.catalog.photos().next().unwrap().id; + let err = export_photo(&mut s, id, &ExportOptions::from_json(&json!({"format": "original", "removeLocation": true})), 1).err().unwrap(); + assert!(err.contains("can't remove the location"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); +}