mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-14 19:27:01 -04:00
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:
1 parent
0ba118de92
commit
db7e37843e
3 files changed
+91
-3
No files matched your search
@@ -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() {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user