Export: Remove location info also covers Original and DNG formats

No location in the XMP sidecar or the DNG's packet; exporting an original
that embeds GPS with the option on is refused instead of leaking it.

Sources: own design.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Andrew FrahnandClaude Opus 5.5 committed 2026-10-11 19:30:23 +11:00
1 parent 0ba118de92
commit db7e37843e
3 files changed
+91 -3

No files matched your search

+17 -2
View File
@@ -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<Metadata> {
@@ -1402,6 +1408,8 @@ enum Work {
read: Option<crate::merge::ByteReader>,
packet: String,
dng: Option<DngCompression>,
/// 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() {
+11 -1
View File
@@ -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)
+63
View File
@@ -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);
}