diff --git a/crates/io/src/adjust_map.rs b/crates/io/src/adjust_map.rs index 053a38b99b..70d2479ee7 100644 --- a/crates/io/src/adjust_map.rs +++ b/crates/io/src/adjust_map.rs @@ -139,6 +139,19 @@ pub fn label(key: &[u8; 4]) -> &'static str { } } +/// Keys of a Brightness/Contrast `CgEd` descriptor: the ones [`write`] emits plus Photoshop's +/// preset bookkeeping. +const BRIT_CGED_KEYS: [&str; 9] = ["Vrsn", "Brgh", "Cntr", "means", "Lab ", "useLegacy", "Auto", "presetKind", "presetFileName"]; + +/// Whether a `brit` layer's `CgEd` descriptor holds settings Brightness/Contrast has no place +/// for. Photoshop 2026's Light adjustment (Exposure, Contrast, Highlights, Shadows, Whites, +/// Blacks) is stored under the `brit` key; read as Brightness/Contrast it kept only Contrast and +/// lost the rest without a word (#6542), so such a layer stays [`Adjustment::Unsupported`]. +pub fn is_extended_brit(cged: Option<&[u8]>) -> bool { + cged.and_then(|c| VersionedDescriptor::parse_prefix(c).ok()) + .is_some_and(|(v, _)| v.descriptor.items.iter().any(|(k, _)| !BRIT_CGED_KEYS.iter().any(|n| k.is(n)))) +} + /// Why [`parse`] keeps a block as [`Adjustment::Unsupported`], when there is more to say than /// "it could not be read" (so far: Color Lookup). pub fn unreadable_reason(key: &[u8; 4], data: &[u8]) -> Option { @@ -204,6 +217,8 @@ fn parse_any(key: &[u8; 4], data: &[u8], cged: Option<&[u8]>) -> Adjustment { b"nvrt" => Some(Adjustment::Invert), b"thrs" => be16(data, 0).map(|v| Adjustment::Threshold { level: f32::from(v) / 255.0 }), b"post" => be16(data, 0).map(|v| Adjustment::Posterize { levels: u32::from(v) }), + // Kept raw (with its `CgEd`, which the PSD writer keeps while the layer is unchanged). + b"brit" if is_extended_brit(cged) => None, b"brit" => { let modern = cged.and_then(|c| VersionedDescriptor::parse_prefix(c).ok()).and_then(|(v, _)| { let d = v.descriptor; diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index fd07adde50..6a476c553d 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -2113,6 +2113,42 @@ mod tests { assert_eq!(density, 0.5); } + #[test] + fn light_adjustment_is_kept_verbatim_and_warned() { + // #6542: Photoshop 2026's Light layer is a `brit` block whose `CgEd` descriptor carries + // more than Brightness/Contrast. Synthetic block: the extra key names are placeholders. + use photocraft_psd::descriptor::{Descriptor, Value, VersionedDescriptor}; + let brit = vec![0, 0, 0xff, 0xfe, 0, 127, 0, 0]; + let d = Descriptor::new("null") + .with("Vrsn", Value::Integer(1)) + .with("Brgh", Value::Integer(0)) + .with("Cntr", Value::Integer(-2)) + .with("Exps", Value::Double(-2.3)) + .with("Hghl", Value::Integer(-64)) + .with("useLegacy", Value::Boolean(false)); + let cged = VersionedDescriptor::new(d).to_bytes(); + let adjustment = crate::adjust_map::parse(b"brit", &brit, Some(&cged), crate::adjust_map::Channels::Rgb); + assert_eq!(adjustment, photocraft_doc::Adjustment::Unsupported { psd_key: "brit".into(), raw: brit.clone() }); + let mut doc = Document::with_background("Light", photocraft_geom::Size::new(8, 8), ColorMode::Rgb, SampleType::U8, photocraft_doc::Color::WHITE); + let mut layer = Layer::new("Light 1", LayerContent::Adjustment(adjustment)); + layer.psd_blocks = vec![(*b"brit", std::sync::Arc::new(brit.clone())), (*b"CgEd", std::sync::Arc::new(cged.clone()))]; + doc.layers.push(layer); + let block = |file: &PsdFile, key: &[u8; 4]| { + let records = &file.layer_info.as_ref().unwrap().layers; + records.iter().flat_map(|r| r.blocks.iter()).find(|b| b.key == *key).map(|b| b.data.clone()) + }; + let file = PsdFile::from_bytes(&document_to_psd(&doc).to_bytes().unwrap()).unwrap(); + let (restored, warnings) = crate::psd_import::psd_to_document(&file); + assert!(warnings.iter().any(|w| w.contains("\"Light 1\"") && w.contains("Light settings")), "{warnings:?}"); + // Saved again unchanged, both blocks are written back byte for byte. + let again = PsdFile::from_bytes(&document_to_psd(&restored).to_bytes().unwrap()).unwrap(); + assert_eq!(block(&again, b"brit").as_deref(), Some(&brit[..])); + assert_eq!(block(&again, b"CgEd").as_deref(), Some(&cged[..])); + // A plain Brightness/Contrast descriptor still reads as Brightness/Contrast. + let plain = crate::adjust_map::write(&photocraft_doc::Adjustment::BrightnessContrast { brightness: 5.0, contrast: -2.0, legacy: false }); + assert!(!crate::adjust_map::is_extended_brit(Some(&plain[1].1))); + } + #[test] fn size_limit_boundary_keeps_limit_and_switches_above_it() { assert!(!exceeds_psd_size_limit(Some(PSD_MAX_FILE_SIZE))); diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index bf4707bce7..8e26d99e81 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -377,10 +377,16 @@ impl Ctx<'_> { // Kept verbatim for saving, but it renders as nothing: say so rather than open the // layer silently without its effect (#1763). if matches!(adj, photocraft_doc::Adjustment::Unsupported { .. }) { - let why = adjust_map::unreadable_reason(k, &data).map(|r| format!(" ({r})")).unwrap_or_default(); + // Photoshop 2026's Light adjustment shares the Brightness/Contrast key (#6542). + let light = k == b"brit" && adjust_map::is_extended_brit(cged); + let why = if light { + " (Light adjustments, with Exposure, Highlights, Shadows, Whites and Blacks, aren't supported yet)".to_string() + } else { + adjust_map::unreadable_reason(k, &data).map(|r| format!(" ({r})")).unwrap_or_default() + }; self.warn(format!( "layer \"{name}\": its {} settings could not be read{why}; the layer is kept as saved but has no effect", - adjust_map::label(k) + if light { "Light" } else { adjust_map::label(k) } )); } LayerContent::Adjustment(adj)