diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index bfeade59c..0e2254afd 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -1827,10 +1827,14 @@ impl PreparedExport { }; 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" - )); + 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() { + 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, diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index ae5a9ae0a..82d4c57d6 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -1177,6 +1177,22 @@ pub(crate) mod tests { assert_eq!(super::meta_of(&m).0.keywords, ["travel|Italy|Rome", "beach"]); } + #[test] + fn psd_probe_reads_embedded_capture_time_gps_and_title() { + let capture = lightcraft_meta::DateTime::parse_iso("2023-05-14T14:40:39").unwrap(); + let metadata = lightcraft_meta::Metadata { + capture_time: Some(capture), + gps: Some(lightcraft_meta::Gps { latitude: 45.0, longitude: 7.0, altitude: None }), + title: Some("PSD metadata".into()), + ..Default::default() + }; + let bytes = crate::tests_xmp::synthetic_psd_with_metadata(&metadata); + let probe = probe_bytes("metadata.psd", &bytes).unwrap(); + assert_eq!(probe.captured.as_deref(), Some("2023-05-14T14:40:39")); + assert_eq!(probe.meta.gps, Some((45.0, 7.0))); + assert_eq!(probe.meta.title, "PSD metadata"); + } + use super::*; use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index bc8c1c962..62bd33820 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -36,7 +36,7 @@ use crate::media::ProbeInfo; /// This includes preview-only raws; the file probe decides whether sensor decoding is available. pub const EXTENSIONS: &[&str] = &[ "jpg", "jpeg", "png", "tif", "tiff", "webp", "dng", "cr2", "cr3", "nef", "nrw", "arw", "sr2", "srf", "raf", "orf", "rw2", "rwl", "raw", "pef", - "srw", "psd", "jxl", "gif", "bmp", + "srw", "psd", "psb", "jxl", "gif", "bmp", // Decoded when built with the codecs' `heif` feature; otherwise the import reports them as // failed with the reason ("HEIC/HEIF support isn't included in this build"). "heic", "heif", "avif", // no AV1 decoder yet: reported as failed, like a HEIC without the feature diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index 4830490b5..a835ecba4 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -4,7 +4,7 @@ use serde_json::json; use crate::Session; use crate::export::{ExportFormat, ExportOptions, export_photo}; -use crate::tests_xmp::{synthetic_dng_with, temp_dir}; +use crate::tests_xmp::{synthetic_dng_with, synthetic_psd_with_metadata, temp_dir}; #[test] fn full_size_export_of_a_cropped_photo_is_not_upscaled() { @@ -657,6 +657,92 @@ fn remove_location_refuses_a_raw_that_embeds_gps() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn remove_location_refuses_a_psd_that_embeds_gps() { + let dir = temp_dir("export-noloc-psd"); + let gps = lightcraft_meta::Metadata { gps: Some(lightcraft_meta::Gps { latitude: 45.0, longitude: 7.0, altitude: None }), ..Default::default() }; + let path = dir.join("Geo.psd"); + let bytes = synthetic_psd_with_metadata(&gps); + assert_eq!(lightcraft_meta::extract(&bytes).gps, gps.gps, "the fixture embeds EXIF GPS"); + std::fs::write(&path, &bytes).unwrap(); + let mut session = Session::new().with_fs(); + session.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + let id = session.catalog.photos().next().unwrap().id; + let err = export_photo(&mut session, id, &ExportOptions::from_json(&json!({"format": "original", "removeLocation": 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); +} + +#[test] +fn remove_location_refuses_a_psd_with_unreadable_xmp() { + let dir = temp_dir("export-noloc-psd-xmp"); + let path = dir.join("Unreadable.psd"); + let pixels = [40, 80, 120]; + let image = lightcraft_codecs::EncodeImage::new(1, 1, 3, lightcraft_codecs::Samples::U8(&pixels)); + let xmp = " Vec { + let exif = lightcraft_meta::write_exif(metadata); + let xmp = lightcraft_meta::write_xmp(metadata, None); + let pixels = [40, 80, 120]; + let image = lightcraft_codecs::EncodeImage::new(1, 1, 3, lightcraft_codecs::Samples::U8(&pixels)); + let embedded = lightcraft_codecs::EncodeMeta { exif: Some(&exif), xmp: Some(&xmp), ..Default::default() }; + lightcraft_codecs::encode_psd(&image, &embedded).unwrap() +} + +pub(crate) fn synthetic_psb_with_metadata(metadata: &lightcraft_meta::Metadata) -> Vec { + let exif = lightcraft_meta::write_exif(metadata); + let xmp = lightcraft_meta::write_xmp(metadata, None); + let pixels = [1024u16, 2048, 3072]; + let image = lightcraft_codecs::EncodeImage::new(1, 1, 3, lightcraft_codecs::Samples::U16(&pixels)); + let embedded = lightcraft_codecs::EncodeMeta { exif: Some(&exif), xmp: Some(&xmp), ..Default::default() }; + let psd = lightcraft_codecs::encode_psd(&image, &embedded).unwrap(); + let colour_length = usize::try_from(u32::from_be_bytes(psd[26..30].try_into().unwrap())).unwrap(); + let resource_length_offset = 30 + colour_length; + let resource_length = usize::try_from(u32::from_be_bytes(psd[resource_length_offset..resource_length_offset + 4].try_into().unwrap())).unwrap(); + let resource_end = resource_length_offset + 4 + resource_length; + + let mut psb = psd[..resource_end].to_vec(); + psb[4..6].copy_from_slice(&2u16.to_be_bytes()); + psb.extend_from_slice(&0u64.to_be_bytes()); // PSB layer and mask information length + psb.extend_from_slice(&psd[resource_end + 4..]); // compression and uncompressed 16-bit pixel planes + psb +} + fn only(s: &Session) -> PhotoId { s.catalog.photos().next().unwrap().id } @@ -1036,10 +1064,21 @@ fn external_edit_copies_every_selected_photo() { fn external_edit_copy_as_psd_round_trips() { let dir = temp_dir("extedit-psd"); let src = dir.join("pier.png"); - write_png(&src, 40); + let capture = lightcraft_meta::DateTime::parse_iso("2023-05-14T14:40:39").unwrap(); + let source_meta = lightcraft_meta::Metadata { capture_time: Some(capture), title: Some("Dated original".into()), ..Default::default() }; + let exif = lightcraft_meta::write_exif(&source_meta); + let xmp = lightcraft_meta::write_xmp(&source_meta, None); + let rgba = lightcraft_raster::Rgba8::from_fn(40, 24, |x, y| [(x * 6) as u8, (y * 9) as u8, 40, 255]); + let png = lightcraft_codecs::encode_png( + &lightcraft_codecs::EncodeImage::rgba8(&rgba), + &lightcraft_codecs::EncodeMeta { exif: Some(&exif), xmp: Some(&xmp), ..Default::default() }, + ) + .unwrap(); + std::fs::write(&src, png).unwrap(); let mut s = Session::new().with_fs(); s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); let orig = s.active().unwrap(); + assert_eq!(s.catalog.photo(orig).unwrap().captured.as_deref(), Some("2023-05-14T14:40:39")); s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.5})).unwrap(); let r = s.execute("photo.editExternal", &json!({"format": "psd", "bitDepth": 16})).unwrap(); let path = r["path"].as_str().unwrap().to_string(); @@ -1048,6 +1087,13 @@ fn external_edit_copy_as_psd_round_trips() { assert_eq!(d.bit_depth, 16); assert!(d.icc.is_some(), "the colour space travels with the file"); let new = lightcraft_catalog::PhotoId(r["id"].as_u64().unwrap()); + assert_eq!(s.catalog.photo(new).unwrap().captured.as_deref(), Some("2023-05-14T14:40:39")); + let all_metadata = s.execute("photo.allMetadata", &json!({"id": new.0})).unwrap(); + assert!(all_metadata["exif"].as_array().unwrap().iter().any(|row| row["tag"] == json!(0x9003)), "{all_metadata}"); + assert!( + all_metadata["xmp"].as_array().unwrap().iter().any(|row| row["name"] == "dc:title" && row["value"] == "Dated original"), + "{all_metadata}" + ); assert_eq!(s.catalog.stack_of(new).map(|st| (st.top(), st.photos.contains(&orig))), Some((new, true))); // the editor saves the document back over the same file: Reload shows its pixels let before = s.render_now(new, 16, 16).unwrap().image.data; @@ -1069,6 +1115,33 @@ fn external_edit_copy_as_psd_round_trips() { let _ = std::fs::remove_dir_all(&dir); } +#[test] +fn psb_import_retains_capture_time_and_all_metadata() { + let dir = temp_dir("import-psb-metadata"); + let path = dir.join("dated.psb"); + let capture = lightcraft_meta::DateTime::parse_exif("2023:05:14 14:40:39"); + let metadata = lightcraft_meta::Metadata { + capture_time: capture, + gps: Some(lightcraft_meta::Gps { latitude: 45.0, longitude: 7.0, altitude: None }), + title: Some("PSB original".into()), + ..Default::default() + }; + let bytes = synthetic_psb_with_metadata(&metadata); + assert_eq!(lightcraft_meta::extract(&bytes).capture_time, capture); + assert_eq!(lightcraft_meta::extract(&bytes).gps, metadata.gps); + std::fs::write(&path, bytes).unwrap(); + + let mut session = Session::new().with_fs(); + let imported = session.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + assert_eq!(imported["imported"].as_array().map(Vec::len), Some(1), "{imported}"); + let id = session.catalog.photos().next().unwrap().id; + assert_eq!(session.catalog.photo(id).unwrap().captured.as_deref(), Some("2023-05-14T14:40:39")); + let all_metadata = session.execute("photo.allMetadata", &json!({"id": id.0})).unwrap(); + assert!(all_metadata["exif"].as_array().unwrap().iter().any(|row| row["tag"] == json!(0x9003)), "{all_metadata}"); + assert!(all_metadata["xmp"].as_array().unwrap().iter().any(|row| row["name"] == "dc:title" && row["value"] == "PSB original"), "{all_metadata}"); + let _ = std::fs::remove_dir_all(&dir); +} + /// Reload: a file changed on disk (an external editor saved it) gets its new size / hash, the /// cached source is dropped and renders change; unchanged files are left alone. #[test] diff --git a/crates/meta/README.md b/crates/meta/README.md index 5c002ff1b..519426784 100644 --- a/crates/meta/README.md +++ b/crates/meta/README.md @@ -8,11 +8,12 @@ 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 and returns the Exif, XMP (+ extended XMP), ICC and IPTC blocks. `jpeg_segments`, `png_chunks`, `webp_chunks` are the per-container walkers. | +| `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` | Metadata + the opaque `lc:settings` JSON + every property (`prefix:name`, structs flattened as `a:b/c:d`). | | `write_xmp(&Metadata, Option<&str>) -> String` | Complete `` 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. | +| `try_extract(&[u8]) -> Result` | Like `extract`, but reports malformed PSD/PSB headers, image-resource sections, or embedded EXIF/XMP structure (including duplicate EXIF/XMP resources) so callers can fail closed when location absence cannot be established. Other formats remain best-effort. | `Metadata` carries camera (make, model, serial, software), lens (make, model, serial, specification), capture (date-time with UTC offset and milliseconds, exposure time, f-number, ISO incl. extended ISO, focal length and @@ -37,7 +38,9 @@ if repeated use of a long namespace URI would exceed that budget. ## Robustness No panics on malformed input: proptests feed random bytes to every container walker and random text to the -XMP parser; truncation tests cut a sample JPEG at every byte. +XMP parser; truncation tests cut a sample JPEG at every byte and exercise truncated PSD/PSB headers and +image-resource records with hostile declared lengths. +PSD/PSB XMP resources require valid UTF-8; trailing NUL padding is trimmed after validation. ## Sources diff --git a/crates/meta/src/container.rs b/crates/meta/src/container.rs index 3ac60907f..3cfb7e287 100644 --- a/crates/meta/src/container.rs +++ b/crates/meta/src/container.rs @@ -7,6 +7,7 @@ //! - WebP (RIFF container spec): `EXIF`, `XMP `, `ICCP` chunks. //! - TIFF-based files (TIFF, DNG and most raws): the whole file is the Exif block; XMP (700), ICC (34675) and //! IPTC (33723) from IFD0. +//! - Photoshop PSD/PSB: EXIF (image resource 1058) and XMP (image resource 1060) from the bounded image-resource section. //! - Minolta MRW (see [`crate::mrw`]): the `TTW` block is the Exif block. //! - Canon CR3 (ISO base media file, see [`crate::cr3`]): `CMT1`/`CMT2`/`CMT4` merged into one Exif block; the //! XMP `uuid` box. @@ -29,6 +30,9 @@ pub struct Embedded { /// Sniff the container and collect its metadata blocks. Unknown containers yield an empty result. pub fn embedded(bytes: &[u8]) -> Embedded { + if bytes.starts_with(b"8BPS") { + return crate::psd::embedded(bytes); + } if bytes.starts_with(b"FUJIFILMCCD-RAW") { // Fujifilm RAF: metadata lives in the embedded JPEG whose (offset, length) are big-endian u32s at byte 84 let be = |i: usize| bytes.get(i..i + 4).map(|s| u32::from_be_bytes([s[0], s[1], s[2], s[3]]) as usize); @@ -71,6 +75,12 @@ pub fn embedded(bytes: &[u8]) -> Embedded { } } +/// Locate metadata blocks while preserving PSD/PSB resource-structure errors for callers that +/// must establish whether embedded GPS is absent. Other containers keep their best-effort walk. +pub(crate) fn try_embedded(bytes: &[u8]) -> Result { + if bytes.starts_with(b"8BPS") { crate::psd::parse(bytes).ok_or(crate::ExtractError::MalformedPsd) } else { Ok(embedded(bytes)) } +} + const XMP_SIG: &[u8] = b"http://ns.adobe.com/xap/1.0/\0"; const XMP_EXT_SIG: &[u8] = b"http://ns.adobe.com/xmp/extension/\0"; diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index 59062dfda..baebbfe0f 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -7,7 +7,7 @@ //! - [`parse_xmp`] / [`write_xmp`] — XMP packets (interchange fields + the opaque `lc:settings` JSON). //! - [`parse_iptc`] — IPTC-IIM record 2 datasets. //! - [`parse_gpx`] — GPS track logs (GPX) for geotagging by capture time. -//! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw, Canon CR3). +//! - [`extract`] — all of the above for a whole file (JPEG, PNG, WebP, TIFF/DNG/raw, PSD/PSB, Canon CR3). //! - [`cr3`] — the Canon CR3 container (metadata blocks, XMP, the JPEG and raw tracks). #![forbid(unsafe_code)] @@ -20,6 +20,7 @@ mod focus_read; mod gpx; mod iptc; pub mod mrw; +mod psd; pub mod tags; mod xmp; mod xmp_merge; @@ -282,17 +283,57 @@ impl Metadata { /// Precedence: EXIF, then IPTC fills gaps, then XMP overrides user fields and fills the rest. pub fn extract(bytes: &[u8]) -> Metadata { let e = embedded(bytes); - let mut m = e.exif.as_deref().map(read_exif).unwrap_or_default(); + let exif = e.exif.as_deref().map(read_exif).unwrap_or_default(); + let xmp = e.xmp.as_deref().and_then(|xmp| parse_xmp(xmp).ok()).map(|xmp| xmp.metadata); + extract_embedded(e, exif, xmp) +} + +/// Read metadata while reporting malformed PSD/PSB containers, embedded EXIF, or embedded XMP instead of treating +/// them as empty. Other container formats retain [`extract`]'s best-effort behavior. +pub fn try_extract(bytes: &[u8]) -> Result { + let e = container::try_embedded(bytes)?; + let is_psd = bytes.starts_with(b"8BPS"); + let exif = match e.exif.as_deref() { + Some(exif) if is_psd => try_read_exif(exif).map_err(|_| ExtractError::MalformedPsd)?, + Some(exif) => read_exif(exif), + None => Metadata::default(), + }; + let xmp = match e.xmp.as_deref() { + Some(xmp) if is_psd => Some(parse_xmp(xmp).map_err(|_| ExtractError::MalformedPsd)?.metadata), + Some(xmp) => parse_xmp(xmp).ok().map(|xmp| xmp.metadata), + None => None, + }; + Ok(extract_embedded(e, exif, xmp)) +} + +fn extract_embedded(e: Embedded, mut m: Metadata, xmp: Option) -> Metadata { if let Some(i) = e.iptc.as_deref() { m.fill_missing(&parse_iptc(i)); } - if let Some(x) = e.xmp.as_deref().and_then(|x| parse_xmp(x).ok()) { - m.overlay_user_fields(&x.metadata); - m.fill_missing(&x.metadata); + if let Some(xmp) = xmp { + m.overlay_user_fields(&xmp); + m.fill_missing(&xmp); } m } +/// A container-level error reported by [`try_extract`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ExtractError { + /// The PSD/PSB header, image-resource section, or embedded EXIF/XMP structure is malformed. + MalformedPsd, +} + +impl std::fmt::Display for ExtractError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::MalformedPsd => f.write_str("malformed PSD/PSB header, image-resource section, or embedded EXIF/XMP structure"), + } + } +} + +impl std::error::Error for ExtractError {} + /// A lens name from its focal and aperture range (Exif LensSpecification, or a Nikon maker note's Lens), written the /// way Lightroom shows a lens it has no name for: `50.0 mm f/1.8`, `18.0-135.0 mm f/3.5-5.6`. pub fn lens_spec_name(spec: [f64; 4]) -> Option { @@ -351,6 +392,17 @@ mod tests { assert_eq!(m.exposure_display(), None); } + #[test] + fn try_extract_keeps_non_psd_exif_best_effort() { + let payload = b"Exif\0\0not a TIFF stream"; + let mut jpeg = vec![0xff, 0xd8, 0xff, 0xe1]; + jpeg.extend_from_slice(&u16::try_from(payload.len() + 2).unwrap().to_be_bytes()); + jpeg.extend_from_slice(payload); + jpeg.extend_from_slice(&[0xff, 0xd9]); + + assert!(matches!(try_extract(&jpeg), Ok(metadata) if metadata == Metadata::default())); + } + #[test] fn merge_rules() { let mut a = Metadata { make: Some("A".into()), rating: Some(1), ..Default::default() }; diff --git a/crates/meta/src/psd.rs b/crates/meta/src/psd.rs new file mode 100644 index 000000000..bda16d960 --- /dev/null +++ b/crates/meta/src/psd.rs @@ -0,0 +1,285 @@ +//! EXIF and XMP image resources in Photoshop PSD and PSB files. + +use crate::container::Embedded; + +struct Reader<'a> { + bytes: &'a [u8], + offset: usize, +} + +impl<'a> Reader<'a> { + fn take(&mut self, length: usize) -> Option<&'a [u8]> { + let end = self.offset.checked_add(length)?; + let bytes = self.bytes.get(self.offset..end)?; + self.offset = end; + Some(bytes) + } + + fn u16(&mut self) -> Option { + let bytes = self.take(2)?; + Some(u16::from_be_bytes([*bytes.first()?, *bytes.get(1)?])) + } + + fn u32(&mut self) -> Option { + let bytes = self.take(4)?; + Some(u32::from_be_bytes([*bytes.first()?, *bytes.get(1)?, *bytes.get(2)?, *bytes.get(3)?])) + } +} + +pub(crate) fn embedded(bytes: &[u8]) -> Embedded { + parse(bytes).unwrap_or_default() +} + +pub(crate) fn parse(bytes: &[u8]) -> Option { + let mut reader = Reader { bytes, offset: 0 }; + if reader.take(4)? != b"8BPS" || !matches!(reader.u16()?, 1 | 2) { + return None; + } + // The fixed header after the version is reserved bytes, channels, dimensions, depth and colour mode. + reader.take(6 + 2 + 4 + 4 + 2 + 2)?; + let colour_mode_length = usize::try_from(reader.u32()?).ok()?; + reader.take(colour_mode_length)?; + let resource_length = usize::try_from(reader.u32()?).ok()?; + let resources = reader.take(resource_length)?; + parse_resources(resources) +} + +fn parse_resources(bytes: &[u8]) -> Option { + let mut reader = Reader { bytes, offset: 0 }; + let (mut exif, mut xmp) = (None, None); + while reader.offset < bytes.len() { + if reader.take(4)? != b"8BIM" { + return None; + } + let id = reader.u16()?; + let name_length = usize::from(*reader.take(1)?.first()?); + let pascal_length = 1usize.checked_add(name_length)?.checked_add(1)? & !1; + reader.take(pascal_length.checked_sub(1)?)?; + let data_length = usize::try_from(reader.u32()?).ok()?; + let data = reader.take(data_length)?; + if !data_length.is_multiple_of(2) { + reader.take(1)?; + } + match id { + 1058 if exif.is_some() => return None, + 1058 => exif = Some(data), + 1060 if xmp.is_some() => return None, + 1060 => xmp = Some(std::str::from_utf8(data).ok()?), + _ => {} + } + } + + Some(Embedded { + exif: exif.map(crate::strip_exif_header).map(<[u8]>::to_vec), + xmp: xmp.map(|text| text.trim_end_matches('\0').to_string()), + ..Default::default() + }) +} + +#[cfg(test)] +mod tests { + use super::embedded; + use crate::container::Embedded; + use crate::{DateTime, Gps, Metadata}; + + fn resource(id: u16, name: &[u8], data: &[u8]) -> Vec { + let mut out = b"8BIM".to_vec(); + out.extend_from_slice(&id.to_be_bytes()); + out.push(u8::try_from(name.len()).unwrap()); + out.extend_from_slice(name); + if !(name.len() + 1).is_multiple_of(2) { + out.push(0); + } + out.extend_from_slice(&u32::try_from(data.len()).unwrap().to_be_bytes()); + out.extend_from_slice(data); + if !data.len().is_multiple_of(2) { + out.push(0); + } + out + } + + fn image(version: u16, colour_data: &[u8], resources: &[u8]) -> Vec { + let mut out = b"8BPS".to_vec(); + out.extend_from_slice(&version.to_be_bytes()); + out.extend_from_slice(&[0; 6]); + out.extend_from_slice(&3u16.to_be_bytes()); // channels + out.extend_from_slice(&1u32.to_be_bytes()); // height + out.extend_from_slice(&1u32.to_be_bytes()); // width + out.extend_from_slice(&8u16.to_be_bytes()); // depth + out.extend_from_slice(&3u16.to_be_bytes()); // RGB + out.extend_from_slice(&u32::try_from(colour_data.len()).unwrap().to_be_bytes()); + out.extend_from_slice(colour_data); + out.extend_from_slice(&u32::try_from(resources.len()).unwrap().to_be_bytes()); + out.extend_from_slice(resources); + out + } + + fn dated_metadata() -> Metadata { + Metadata { + capture_time: DateTime::parse_exif("2023:05:14 14:40:39"), + gps: Some(Gps { latitude: 45.0, longitude: 7.0, altitude: None }), + ..Default::default() + } + } + + fn dated_image(version: u16) -> Vec { + let exif = crate::write_exif(&dated_metadata()); + image(version, &[], &resource(1058, b"", &exif)) + } + + #[test] + fn psd_capture_time_is_extracted() { + let expected = dated_metadata().capture_time; + let actual = crate::extract(&dated_image(1)); + assert_eq!(actual.capture_time, expected); + assert_eq!(actual.gps, dated_metadata().gps); + } + + #[test] + fn psb_capture_time_is_extracted() { + let expected = dated_metadata().capture_time; + let actual = crate::extract(&dated_image(2)); + assert_eq!(actual.capture_time, expected); + assert_eq!(actual.gps, dated_metadata().gps); + } + + #[test] + fn psd_extracts_xmp_without_exif() { + let metadata = Metadata { + capture_time: DateTime::parse_iso("2024-02-03T04:05:06Z"), + gps: Some(Gps { latitude: -33.9, longitude: 151.2, altitude: Some(25.0) }), + title: Some("Embedded XMP".into()), + ..Default::default() + }; + let xmp = crate::write_xmp(&metadata, None); + let file = image(1, &[], &resource(1060, b"", xmp.as_bytes())); + let actual = crate::extract(&file); + assert_eq!(actual.capture_time, metadata.capture_time); + assert_eq!(actual.gps, metadata.gps); + assert_eq!(actual.title, metadata.title); + } + + #[test] + fn psd_normalizes_the_optional_exif_resource_prefix() { + let mut exif = b"Exif\0\0".to_vec(); + exif.extend(crate::write_exif(&dated_metadata())); + let file = image(1, &[], &resource(1058, b"", &exif)); + assert_eq!(crate::extract(&file).capture_time, dated_metadata().capture_time); + } + + #[test] + fn try_extract_rejects_structurally_malformed_psd_exif() { + for version in [1, 2] { + let file = image(version, &[], &resource(1058, b"", b"not a TIFF stream")); + assert!(matches!(crate::try_extract(&file), Err(crate::ExtractError::MalformedPsd))); + } + } + + #[test] + fn try_extract_accepts_unknown_exif_tags_in_psd() { + let mut ifd0 = lightcraft_tiff::IfdBuilder::new(); + ifd0.set(0xc001, lightcraft_tiff::Value::Ascii("vendor metadata".into())); + let exif = lightcraft_tiff::TiffWriter::default().write(&[ifd0]).unwrap(); + let file = image(1, &[], &resource(1058, b"", &exif)); + assert!(crate::try_extract(&file).is_ok()); + } + + #[test] + fn try_extract_rejects_malformed_psd_xmp_but_extract_remains_best_effort() { + let file = image(1, &[], &resource(1060, b"", b" Vec { } /// [`tag_rows`] of a file's bytes: a TIFF-based file directly, else its embedded EXIF block -/// (JPEG, PNG, WebP, HEIF…). Empty when there is none. +/// (JPEG, PNG, WebP, HEIF, PSD/PSB…). Empty when there is none. pub fn file_tag_rows(bytes: &[u8]) -> Vec { if Tiff::sniff(bytes).is_some() && let Ok(t) = Tiff::parse(bytes) diff --git a/docs/gaps.md b/docs/gaps.md index 3bcac4363..968902c94 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -62,6 +62,8 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w - 2026-10-22: saving XMP preserves foreign namespaces even when they use `lc:` or `xmp:` prefixes; importing them no longer interprets their properties as native settings or ratings. Parser, merge and save-command regressions cover the correction; no ranked gap or readiness estimate changes. +- 2026-10-22: PSD/PSB imports and metadata readers now expose embedded EXIF resource 1058 and XMP resource 1060 for capture dates, GPS and All Metadata (#2519); imported PSD edit copies retain their capture date, and the `removeLocation` Original-export guard sees embedded PSD/PSB GPS. No ranked gap or headline feature count closes. + - 2026-10-20: the import review album picker follows the sidebar folder hierarchy and searches album paths (#2362, LR-IMP-ADD-DIALOG), retaining None/New album and existing destination IDs. This closes a usability subgap; no ranked gap or headline feature count closes. - 2026-10-12: original-pixel detail at 100% (gap 4, LR-VIEW-ZOOM, #323): soft proofing gets zoom windows like @@ -82,6 +84,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| | 2026-10-22 | patch | XMP namespace-prefix collisions no longer overwrite foreign metadata; ranked gaps and estimates unchanged | +| 2026-10-22 | patch | Issue #2519: PSD/PSB EXIF resource 1058 and XMP resource 1060 now use the common metadata extractor, and `.psb` files are accepted by import; no ranked gap or readiness count changes | | 2026-10-21 | minor | Gap 28 shrinks: F8 shows / hides the right panel (`view.rightPanel`); rating `[` `]`, flag cycling and F1 help were already bound, so the entry lists only filter-bar keys, Tab / ⇧Tab and F5; 2–4 h (was 4–8) | | 2026-10-20 | minor | #2362: import destination albums use the shared hierarchy/search picker instead of a flat name list; existing import workflows retained; ranked gaps/readiness counts unchanged | | 2026-10-19 | minor | Gap 21: Indonesian (`id`) shipped with every catalog message but What's New; four new languages left (Hindi, Arabic, Korean, Vietnamese), 135–239 h | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 0eb3559c5..9790523e1 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -169,7 +169,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map | | LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset | | LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | manual rereading also reads sidecar-free JPEG embedded XMP (first complete standard APP1 before SOS/EOI, within 16 MiB of headers and 4096 marker/fill steps; no extended assembly or between-scan packets, not strict whole-JPEG validation; reader cases in `crates/engine/src/sidecar.rs`); a discovered unreadable sidecar fails without embedded fallback; procedural refresh/undo, non-UTF-8 sidecar and two explicit malformed fixtures in `crates/engine/src/tests_xmp.rs` (file-write checks use auto-write off); sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte (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 (`.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 `.xmp` and never the raw's `IMG_0001.xmp`, issue #1905); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934); settings Lightroom writes only for itself (`CompatibleVersion`, the profile digests, the lens profile's name / file / setup, `CropConstrainToUnitSquare`, the lens profile's strength at 100 %) are not reported as unimported (`crates/engine/src/crs.rs`) | -| LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | `photo.allMetadata` reuses loaded original bytes for embedded XMP and keeps sidecar precedence (`crates/engine/src/tests_xmp.rs`); read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets | +| 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) @@ -407,7 +407,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests_export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy | | LR-EXP-RENDERDNG | Render to DNG (Classic 15.5) | P2 | 🟡 | `cmd:app.export` (`format: rendereddng`), `crates/raw/src/dngwrite.rs` (`write_rendered_dng`), `crates/raw/tests/it/rendered_dng.rs` | export format `rendereddng` (dialog: Format DNG, "DNG of: Rendered photo"): the rendered photo (edit, crop, size, sharpening, watermark baked in) as a LinearRaw DNG of 16-bit float Deflate tiles in the export colour space, with `ColorMatrix1` / `ForwardMatrix1` for that space, a neutral `AsShotNeutral` and an identity `ProfileToneCurve` so raw converters show it as rendered; HDR edits keep highlights above white. Not a menu command on selected photos (Lightroom adds the DNG to the catalog next to the original); export only | | LR-EXP-PHOTOS | Export to the system photo library | P2 | 🟡 | `cmd:app.export` (`addToPhotos`, `photosAlbum`), `cmd:export.addToPhotos`, `cmd:export.photosImports`, `crates/engine/src/apple_photos.rs`, `crates/ui-egui/src/panels/dialogs.rs`, `packaging/macos/entitlements.plist`, `docs/apple-photos.md` | macOS: the exported files go to Apple Photos through Apple Events to its import command (a constant AppleScript run by `/usr/bin/osascript`; album names and paths are separate arguments), into a top-level album of exactly that name, made when missing (several of that name: refused); the Export dialog's Add to Apple Photos, Export with Previous, presets, MCP and `lightcraft-cli` all reach it; imports are jobs, off the UI thread in the desktop app and awaited in one-shot headless runs, one at a time, reserved by an export before it writes; permission, missing Photos, timeouts and partial imports are reported; the packaged app carries the Apple Events entitlement and usage text. Tested end to end with the packaged app on macOS 26.6.2 / Photos 11.0 (manual steps in `docs/apple-photos.md`). Other platforms' photo libraries are not covered | -| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. Open Original in Other Editor (RAW, no copy) hands that editor each selected photo's original file itself instead (a raw stays a raw, e.g. for DxO PhotoLab: no render, no copy, nothing added; several photos go to one start of the editor, which DxO PhotoLab 8 needs to get them all; demo photos, missing files and originals that are not a file type LightCraft reads are skipped with the reason, so a path an imported catalog named cannot hand a program to the system opener, issue #2135). No "Edit a copy / Edit original" choice for non-raw files yet | +| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. Open Original in Other Editor (RAW, no copy) hands that editor each selected photo's original file itself instead (a raw stays a raw, e.g. for DxO PhotoLab: no render, no copy, nothing added; several photos go to one start of the editor, which DxO PhotoLab 8 needs to get them all; demo photos, missing files and originals that are not a file type LightCraft reads are skipped with the reason, so a path an imported catalog named cannot hand a program to the system opener, issue #2135). Imported PSD/PSB photos retain embedded capture dates; PSD edit copies retain EXIF/XMP fields in the catalog and All Metadata. Original with Remove Location Info is refused when embedded PSD/PSB GPS is detected. No "Edit a copy / Edit original" choice for non-raw files yet | ## T. Share (SHARE) @@ -799,6 +799,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| | 2026-10-22 | patch | LR-META-XMP: namespace URI determines ownership; foreign namespaces using recognized prefixes survive saves and cannot supply native settings or ratings; status and counts unchanged | +| 2026-10-22 | patch | LR-EXP-PSD, LR-META-EXIF-FULL: PSD/PSB imports and image resources 1058/1060 now supply EXIF and XMP to metadata readers and All Metadata; Original with Remove Location Info refuses detected PSD/PSB GPS; statuses and feature counts unchanged | | 2026-10-21 | patch | LR-IMP-ADD-DIALOG: reconcile #2362 with current main; deleted-album guard and hierarchical picker retained; counts unchanged | | 2026-10-20 | minor | LR-IMP-ADD-DIALOG: #2362 gives the import review the shared hierarchy/search album picker while retaining None/New album and existing destination IDs; status and feature counts unchanged | | 2026-10-21 | patch | KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the slider's row; leaving it (even between two frames) or Tab focus alone does not arm them (issue #1355); status unchanged | diff --git a/docs/whats-new.md b/docs/whats-new.md index ebc31e1b2..2c3a15b36 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -41,6 +41,11 @@ the edges of wooden planks, a branch or a bird's wing were taken for the horizon, sometimes for a 25° turn. - When it finds no horizon or vertical lines, it says so instead of doing nothing without a word. +### PSD and PSB metadata stays available +- LightCraft now imports PSD and PSB files with EXIF and XMP image resources. Capture dates, GPS and descriptive fields + stay available in All Metadata; PSD edit copies keep their capture date. Original exports with Remove Location Info enabled + are refused when a PSD or PSB embeds GPS, since LightCraft cannot strip metadata from a byte-for-byte original copy. + ## 2026-10-21 ### Sharper resampling in HDR and panorama merges