PSD: keep Photoshop 2026 Light adjustment layers verbatim and warn (#6542) (#6815)

A Light layer is stored under the Brightness/Contrast `brit` key with a
`CgEd` descriptor carrying more settings; it imported as
Brightness/Contrast with only Contrast. A `brit` whose `CgEd` has keys
outside the Brightness/Contrast set now stays Adjustment::Unsupported, is
written back byte for byte, and the import warns naming the layer.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Branch Sync <branch-sync@localhost>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Michael Higgins
2026-10-23 18:36:41 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Branch Sync Brandon Thomas
parent aaaa990314
commit 80e83e0aa8
3 changed files with 59 additions and 2 deletions
+15
View File
@@ -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<String> {
@@ -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;
+36
View File
@@ -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)));
+8 -2
View File
@@ -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)