# Conflicts:
#	docs/gaps.md
#	docs/parity-checklist.md
#	docs/whats-new.md
This commit is contained in:
Brandon Thomas
2026-10-23 17:21:18 -04:00
13 changed files with 555 additions and 17 deletions
+8 -4
View File
@@ -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,
+16
View File
@@ -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};
+1 -1
View File
@@ -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
+87 -1
View File
@@ -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 = "<x:xmpmeta><broken";
let embedded = lightcraft_codecs::EncodeMeta { xmp: Some(xmp), ..Default::default() };
let bytes = lightcraft_codecs::encode_psd(&image, &embedded).unwrap();
assert_eq!(lightcraft_meta::extract(&bytes), lightcraft_meta::Metadata::default(), "ordinary extraction stays best-effort");
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 verify location metadata") && err.contains("malformed PSD/PSB"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn remove_location_refuses_a_psd_with_invalid_utf8_xmp() {
let dir = temp_dir("export-noloc-psd-invalid-utf8");
let path = dir.join("Invalid.psd");
let gps = lightcraft_meta::Metadata { gps: Some(lightcraft_meta::Gps { latitude: 45.0, longitude: 7.0, altitude: None }), ..Default::default() };
let xmp = lightcraft_meta::write_xmp(&gps, 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 { xmp: Some(&xmp), ..Default::default() };
let mut bytes = lightcraft_codecs::encode_psd(&image, &embedded).unwrap();
assert_eq!(lightcraft_meta::extract(&bytes).gps, gps.gps);
let latitude = bytes.windows(2).position(|bytes| bytes == b"45").unwrap();
bytes[latitude] = 0xff;
assert!(lightcraft_meta::extract(&bytes).gps.is_none());
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 verify location metadata") && err.contains("malformed PSD/PSB"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn remove_location_refuses_a_psd_with_unreadable_resources() {
let dir = temp_dir("export-noloc-psd-malformed");
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 valid_bytes = synthetic_psd_with_metadata(&gps);
assert_eq!(lightcraft_meta::extract(&valid_bytes).gps, gps.gps, "the fixture embeds EXIF GPS before corruption");
let mut bytes = valid_bytes;
let colour_length = usize::try_from(u32::from_be_bytes(bytes[26..30].try_into().unwrap())).unwrap();
let resource_length_at = 30 + colour_length;
let resource_length = usize::try_from(u32::from_be_bytes(bytes[resource_length_at..resource_length_at + 4].try_into().unwrap())).unwrap();
let resource_start = resource_length_at + 4;
let resource_end = resource_start + resource_length;
bytes.splice(resource_end..resource_end, *b"BAD!");
bytes[resource_length_at..resource_length_at + 4].copy_from_slice(&u32::try_from(resource_length + 4).unwrap().to_be_bytes());
assert!(lightcraft_meta::extract(&bytes).gps.is_none(), "the malformed trailing resource reproduces the metadata gap");
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 verify location metadata") && err.contains("malformed PSD/PSB"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn hdr_png_export_is_16_bit_pq_rec2020() {
let mut s = Session::with_demo();
+74 -1
View File
@@ -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]
+5 -2
View File
@@ -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<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). |
| `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<Metadata, ExtractError>` | 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
+10
View File
@@ -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<Embedded, crate::ExtractError> {
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";
+57 -5
View File
@@ -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<Metadata, ExtractError> {
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>) -> 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<String> {
@@ -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() };
+285
View File
@@ -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<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()
}
pub(crate) 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 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<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 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"<x:xmpmeta><broken"));
assert_eq!(crate::extract(&file), Metadata::default());
assert!(matches!(crate::try_extract(&file), Err(crate::ExtractError::MalformedPsd)));
}
#[test]
fn try_extract_rejects_invalid_utf8_psd_xmp() {
let mut xmp = crate::write_xmp(&dated_metadata(), None).into_bytes();
let latitude = xmp.windows(2).position(|bytes| bytes == b"45").unwrap();
xmp[latitude] = 0xff;
let lossy = crate::parse_xmp(&String::from_utf8_lossy(&xmp)).unwrap();
assert!(lossy.metadata.gps.is_none(), "lossy decoding hides the corrupted GPS value without an XML error");
for version in [1, 2] {
let file = image(version, &[], &resource(1060, b"", &xmp));
assert!(matches!(crate::try_extract(&file), Err(crate::ExtractError::MalformedPsd)));
assert_eq!(embedded(&file), Embedded::default());
assert_eq!(crate::extract(&file), Metadata::default());
}
}
#[test]
fn psd_xmp_preserves_valid_utf8_and_trailing_nul_trimming() {
let metadata = Metadata { title: Some("Café 山".into()), ..dated_metadata() };
let xmp = crate::write_xmp(&metadata, None);
let mut padded = xmp.as_bytes().to_vec();
padded.extend_from_slice(b"\0\0");
for version in [1, 2] {
let file = image(version, &[], &resource(1060, b"", &padded));
assert_eq!(embedded(&file).xmp.as_deref(), Some(xmp.as_str()));
let actual = crate::try_extract(&file).unwrap();
assert_eq!(actual.gps, metadata.gps);
assert_eq!(actual.title, metadata.title);
}
}
#[test]
fn try_extract_rejects_duplicate_psd_exif_and_xmp_resources() {
let gps = dated_metadata();
let no_gps = Metadata::default();
let gps_exif = crate::write_exif(&gps);
let no_gps_exif = crate::write_exif(&no_gps);
let gps_xmp = crate::write_xmp(&gps, None);
let no_gps_xmp = crate::write_xmp(&no_gps, None);
let duplicate_exif = [resource(1058, b"", &gps_exif), resource(1058, b"", &no_gps_exif)].concat();
let duplicate_xmp = [resource(1060, b"", gps_xmp.as_bytes()), resource(1060, b"", no_gps_xmp.as_bytes())].concat();
for (id, resources) in [(1058, duplicate_exif), (1060, duplicate_xmp)] {
let file = image(1, &[], &resources);
assert!(matches!(crate::try_extract(&file), Err(crate::ExtractError::MalformedPsd)), "duplicate resource {id} must be rejected");
}
}
#[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());
}
}
+1 -1
View File
@@ -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)
+3
View File
@@ -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 |
+3 -2
View File
@@ -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 (`<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/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 |
+5
View File
@@ -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