mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-24 10:09:01 -06:00
#2519: read PSD and PSB metadata
This commit is contained in:
@@ -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};
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,22 @@ 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 hdr_png_export_is_16_bit_pq_rec2020() {
|
||||
let mut s = Session::with_demo();
|
||||
|
||||
@@ -43,6 +43,34 @@ fn write_png(path: &Path, seed: u8) {
|
||||
std::fs::write(path, bytes).unwrap();
|
||||
}
|
||||
|
||||
pub(crate) fn synthetic_psd_with_metadata(metadata: &lightcraft_meta::Metadata) -> Vec<u8> {
|
||||
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<u8> {
|
||||
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]
|
||||
|
||||
@@ -8,7 +8,7 @@ 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<XmpData>` | Metadata + the opaque `lc:settings` JSON + every property (`prefix:name`, structs flattened as `a:b/c:d`). |
|
||||
| `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). |
|
||||
@@ -30,7 +30,8 @@ and node count are limited.
|
||||
## 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.
|
||||
|
||||
## Sources
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
//! 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<u16> {
|
||||
let bytes = self.take(2)?;
|
||||
Some(u16::from_be_bytes([*bytes.first()?, *bytes.get(1)?]))
|
||||
}
|
||||
|
||||
fn u32(&mut self) -> Option<u32> {
|
||||
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()
|
||||
}
|
||||
|
||||
fn parse(bytes: &[u8]) -> Option<Embedded> {
|
||||
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<Embedded> {
|
||||
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 => exif = Some(data),
|
||||
1060 => xmp = Some(data),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Some(Embedded {
|
||||
exif: exif.map(crate::strip_exif_header).map(<[u8]>::to_vec),
|
||||
xmp: xmp.map(|bytes| String::from_utf8_lossy(bytes).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<u8> {
|
||||
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<u8> {
|
||||
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<u8> {
|
||||
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 psd_skips_unknown_resources_with_pascal_name_and_payload_padding() {
|
||||
let exif = crate::write_exif(&dated_metadata());
|
||||
let mut resources = resource(4000, b"xy", &[1, 2, 3]);
|
||||
resources.extend(resource(1058, b"", &exif));
|
||||
let file = image(1, &[8, 7, 6], &resources);
|
||||
assert_eq!(crate::extract(&file).capture_time, dated_metadata().capture_time);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_truncation_and_hostile_lengths_return_no_embedded_metadata() {
|
||||
let file = dated_image(1);
|
||||
for end in 0..file.len() {
|
||||
assert_eq!(embedded(&file[..end]), Embedded::default(), "truncated at {end}");
|
||||
}
|
||||
|
||||
let mut hostile_colour_length = file.clone();
|
||||
hostile_colour_length[26..30].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
assert_eq!(embedded(&hostile_colour_length), Embedded::default());
|
||||
|
||||
let mut hostile_resource_length = file.clone();
|
||||
hostile_resource_length[30..34].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
assert_eq!(embedded(&hostile_resource_length), Embedded::default());
|
||||
|
||||
let mut hostile_payload_length = file;
|
||||
hostile_payload_length[42..46].copy_from_slice(&u32::MAX.to_be_bytes());
|
||||
assert_eq!(embedded(&hostile_payload_length), Embedded::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_rejects_unknown_versions_and_incomplete_resource_records() {
|
||||
let file = dated_image(1);
|
||||
let mut unknown_version = file.clone();
|
||||
unknown_version[4..6].copy_from_slice(&3u16.to_be_bytes());
|
||||
assert_eq!(embedded(&unknown_version), Embedded::default());
|
||||
|
||||
let mut truncated_record = file;
|
||||
truncated_record.pop();
|
||||
assert_eq!(embedded(&truncated_record), Embedded::default());
|
||||
}
|
||||
}
|
||||
@@ -351,7 +351,7 @@ pub fn tag_rows(tiff: &Tiff) -> Vec<TagRow> {
|
||||
}
|
||||
|
||||
/// [`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<TagRow> {
|
||||
if Tiff::sniff(bytes).is_some()
|
||||
&& let Ok(t) = Tiff::parse(bytes)
|
||||
|
||||
+4
-1
@@ -1,6 +1,6 @@
|
||||
# Gaps: where LightCraft falls short of Lightroom
|
||||
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-21 · **Change:** minor (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 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)
|
||||
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-22 · **Change:** patch (PSD/PSB metadata issue #2519: embedded EXIF/XMP extraction and PSB import support; ranked gaps and readiness counts unchanged)
|
||||
|
||||
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
|
||||
@@ -60,6 +60,8 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
|
||||
|
||||
## Closed recently (kept for calibration)
|
||||
|
||||
- 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
|
||||
@@ -79,6 +81,7 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Lightroom parity checklist
|
||||
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-21 · **Change:** minor (LR-MERGE-HDR / LR-MERGE-PANO: Lanczos-3 warps; LR-IMP-LOCAL: browsing from the sidebar records the folder; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LR-IMP-ADD-DIALOG: hierarchical import album destination reconciled with main, #2362; KEY-SLIDER-NUDGE: vertical wheel nudges need a click or drag in the row; leaving it disarms them; LR-EDIT-OPTICS-PROFILE: Fujifilm nine-knot lens tables read at the plain radius; LR-VIEW-ZOOM: only a full-size drag within 20 ms clears a slow-drag verdict; LRC-LIB-LIBFOLDERS, LR-BEHAV-BG-TASKS: Synchronize Folder's scan shows its folder listing and stops it on ✕; LRC-LIB-LIBFOLDERS: Synchronize Folder says a folder is up to date; LR-LIB-STACK: filmstrip stack badge click; LRC-LIB-REFVIEW / CATALOG-SWITCH: Escape preserves edits; catalog-local pins; deleted and malformed references refused; LRC-LIB-COMPARE: the Info panel labels the active photo while culling, #2398; KEYC-RATING: `\` shows / hides the Filter Bar in the grids; LR-VIEW-PHOTOGRID: the grid reads only the photos an import batch or an edit touched, not the whole list; earlier: LRC-PRINT-PAGE: Print Page Options; LR-META-XMP: fewer false unimported-XMP warnings) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-22 · **Change:** patch (LR-EXP-PSD / LR-META-EXIF-FULL: PSD/PSB imports and image resources 1058/1060 now feed EXIF/XMP metadata; status and feature counts unchanged) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6
|
||||
|
||||
The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we
|
||||
have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good
|
||||
@@ -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, 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; 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/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)
|
||||
|
||||
@@ -798,6 +798,7 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove
|
||||
|
||||
| Date | Change | Summary |
|
||||
|---|---|---|
|
||||
| 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 |
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# What's new in LightCraft
|
||||
|
||||
## 2026-10-22
|
||||
|
||||
### 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
|
||||
|
||||
Reference in New Issue
Block a user