From d5490695697ad704c633290ae99656e180d3fadc Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 02:22:59 -0400 Subject: [PATCH] feat(psd): Gradient Map stops in CMYK, Lab, Gray and HSB import and round-trip Photoshop writes a Gradient Map's colour stops in the document's own colour space (CMYK, Lab, Grayscale) or HSB. PhotoCraft only read RGB stops, so such layers imported as unsupported and did nothing. - Decode every plain PSD colour space (RGB, HSB, CMYK, Lab, Grayscale) in `grdm` stops; CMYK converts through the document's own CMYK profile (photocraft-cms), Lab through D50 Lab. Book colours and unknown ids stay unsupported (kept verbatim). - Saving keeps the file's own `grdm` block, so stops keep their colour space; toggling Reverse or Dither patches just those flags in place. Co-Authored-By: Claude Opus 5.5 --- crates/io/src/adjust_map.rs | 113 +++++++++++++++++++++++++++++++++-- crates/io/src/channel_map.rs | 45 +++++++++++++- crates/io/src/psd_export.rs | 6 ++ crates/io/src/psd_import.rs | 5 +- crates/io/tests/color.rs | 100 +++++++++++++++++++++++++++++++ 5 files changed, 260 insertions(+), 9 deletions(-) diff --git a/crates/io/src/adjust_map.rs b/crates/io/src/adjust_map.rs index 03f3180b..2489b669 100644 --- a/crates/io/src/adjust_map.rs +++ b/crates/io/src/adjust_map.rs @@ -267,7 +267,10 @@ fn be32(d: &[u8], at: usize) -> Option { /// key), reverse, dither, [method], name, colour stops (location /4096, midpoint %, colour /// space + four u16 components, 2 bytes), transparency stops, then smoothness (/4096) among /// noise-gradient fields. Smoothness, midpoints and the method are baked into dense stops like -/// gradient fills. Noise gradients (no colour stops) and non-RGB stops stay unsupported. +/// gradient fills. Stops may be in any plain colour space Photoshop writes (RGB, HSB, CMYK, Lab, +/// Grayscale: a CMYK or Lab document stores them in its own space); they are converted to RGB +/// through the colour-managed conversions (the document's CMYK profile when one is in scope). +/// Noise gradients (no colour stops) and stops in other spaces (book colours) stay unsupported. fn parse_gradient_map(d: &[u8]) -> Option { let version = be16(d, 0)?; let reverse = *d.get(2)? != 0; @@ -285,11 +288,9 @@ fn parse_gradient_map(d: &[u8]) -> Option { for _ in 0..n { let loc = (be32(d, at)? as f32 / 4096.0).clamp(0.0, 1.0); let mid = be32(d, at + 4)? as f32 / 100.0; - if be16(d, at + 8)? != 0 { - return None; - } - let c = |k: usize| be16(d, at + 10 + 2 * k).map(|v| f32::from(v) / 65535.0); - stops.push((loc, photocraft_color::Color::rgb(c(0)?, c(1)?, c(2)?))); + let space = be16(d, at + 8)?; + let c = |k: usize| be16(d, at + 10 + 2 * k); + stops.push((loc, crate::channel_map::decode_known_color(space, [c(0)?, c(1)?, c(2)?, c(3)?])?)); mids.push(mid); at += 20; } @@ -309,6 +310,23 @@ fn parse_gradient_map(d: &[u8]) -> Option { Some(Adjustment::GradientMap { stops, reverse, dither }) } +/// Points an imported `grdm` block at `target` when only Reverse and/or Dither differ (the +/// stops parse unchanged): patches those two bytes and returns `true`, so the file's own stops +/// (in whatever colour space it stored them), smoothness and method survive the save. `false` +/// leaves `data` untouched. +pub(crate) fn retarget_gradient_map(data: &mut [u8], target: &Adjustment, channels: Channels) -> bool { + let Adjustment::GradientMap { stops, reverse, dither } = target else { return false }; + let same_stops = matches!(parse(b"grdm", data, None, channels), Adjustment::GradientMap { stops: s, .. } if s == *stops); + match (same_stops, data.get_mut(2..4)) { + (true, Some(flags)) => { + flags[0] = u8::from(*reverse); + flags[1] = u8::from(*dither); + true + } + _ => false, + } +} + /// `grdm` version 1 for [`Adjustment::GradientMap`] (Classic interpolation, Smoothness 0, so /// the stops are reproduced exactly). fn write_gradient_map(stops: &[(f32, [f32; 3])], reverse: bool, dither: bool) -> Vec { @@ -824,6 +842,89 @@ mod tests { assert_eq!(write(&a)[0].1, block); } + /// A two-stop `grdm` (version 1) whose stops are the colour structures `a` and `b`. + fn grdm_with(a: (u16, [u16; 4]), b: (u16, [u16; 4])) -> Vec { + let mut v = write_gradient_map(&[(0.0, [0.0; 3]), (1.0, [1.0; 3])], false, false); + // Header 4 + name length 4 + "Custom\0" 14 + stop count 2; 20 bytes per stop. + for (i, (space, c)) in [a, b].into_iter().enumerate() { + let at = 24 + 20 * i + 8; + v[at..at + 2].copy_from_slice(&space.to_be_bytes()); + for (k, x) in c.iter().enumerate() { + v[at + 2 + 2 * k..at + 4 + 2 * k].copy_from_slice(&x.to_be_bytes()); + } + } + v + } + + fn ends(a: &Adjustment) -> ([f32; 3], [f32; 3]) { + match a { + Adjustment::GradientMap { stops, .. } => (stops[0].1, stops[stops.len() - 1].1), + other => panic!("{other:?}"), + } + } + + #[test] + fn gradient_map_stops_in_every_colour_space() { + use photocraft_color::{Color, ColorMode}; + let white_rgb = (0, [65535, 65535, 65535, 0]); + // CMYK, stored inverted: 65535 = no ink. Full K only. + let k_only = grdm_with((2, [65535, 65535, 65535, 0]), white_rgb); + let expected = Color { mode: ColorMode::Cmyk, c: [0.0, 0.0, 0.0, 1.0], alpha: 1.0 }.to_rgb(); + for channels in [Channels::Cmyk, Channels::Rgb, Channels::Lab] { + let a = parse(b"grdm", &k_only, None, channels); + let (first, last) = ends(&a); + assert!(first.iter().zip(expected).all(|(x, y)| (x - y).abs() < 1e-5), "{first:?} vs {expected:?}"); + assert!(first[0] < 0.4 && (first[0] - first[2]).abs() < 0.05, "K-only black is a dark neutral: {first:?}"); + assert_eq!(last, [1.0; 3]); + } + // Lab: L 50, a 0, b 0 is a neutral mid grey; L 100 white. + let lab = parse(b"grdm", &grdm_with((7, [5000, 0, 0, 0]), (7, [10000, 0, 0, 0])), None, Channels::Lab); + let (first, last) = ends(&lab); + assert!(first.iter().all(|v| (v - 0.466).abs() < 0.01), "{first:?}"); + assert!(last.iter().all(|v| *v > 0.99), "{last:?}"); + // Lab with chroma: a = +60 is reddish (a and b are signed hundredths). + let red = parse(b"grdm", &grdm_with((7, [5000, 6000, 0, 0]), white_rgb), None, Channels::Lab); + let (first, _) = ends(&red); + assert!(first[0] > first[1] + 0.2, "{first:?}"); + // Grayscale: 0..10000 is ink coverage (10000 = black). + let gray = parse(b"grdm", &grdm_with((8, [10000, 0, 0, 0]), (8, [0, 0, 0, 0])), None, Channels::Gray); + assert_eq!(ends(&gray), ([0.0; 3], [1.0; 3])); + // HSB: hue 240 degrees (2/3 of 65535), full saturation and brightness = blue. + let hsb = parse(b"grdm", &grdm_with((1, [43690, 65535, 65535, 0]), white_rgb), None, Channels::Rgb); + let (first, _) = ends(&hsb); + assert!(first[0] < 1e-3 && first[1] < 1e-3 && (first[2] - 1.0).abs() < 1e-3, "{first:?}"); + // Book colours and unknown ids are not guessed: the block stays raw. + for space in [3u16, 9, 0xFFFF] { + let d = grdm_with((space, [1, 2, 3, 4]), white_rgb); + assert!(matches!(parse(b"grdm", &d, None, Channels::Rgb), Adjustment::Unsupported { .. }), "space {space}"); + } + // Truncated stops never panic. + for n in 0..k_only.len() { + let _ = parse(b"grdm", &k_only[..n], None, Channels::Cmyk); + } + } + + #[test] + fn gradient_map_reverse_and_dither_keep_the_stops_block() { + let original = grdm_with((2, [65535, 65535, 65535, 0]), (7, [10000, 0, 0, 0])); + let Adjustment::GradientMap { stops, .. } = parse(b"grdm", &original, None, Channels::Cmyk) else { panic!() }; + let mut data = original.clone(); + let target = Adjustment::GradientMap { stops: stops.clone(), reverse: true, dither: true }; + assert!(retarget_gradient_map(&mut data, &target, Channels::Cmyk)); + assert_eq!(&data[2..4], &[1, 1]); + assert_eq!(data[4..], original[4..], "the stops keep their colour spaces"); + assert_eq!(parse(b"grdm", &data, None, Channels::Cmyk), target); + // Edited stops are not patched in. + let mut edited = stops; + edited[0].1 = [0.5; 3]; + let mut data2 = original.clone(); + let target = Adjustment::GradientMap { stops: edited, reverse: false, dither: false }; + assert!(!retarget_gradient_map(&mut data2, &target, Channels::Cmyk)); + assert_eq!(data2, original); + assert!(!retarget_gradient_map(&mut [], &target, Channels::Cmyk)); + assert!(!retarget_gradient_map(&mut data2, &Adjustment::Invert, Channels::Cmyk)); + } + fn rt(a: Adjustment) { let blocks = write(&a); assert!(!blocks.is_empty(), "{a:?}"); diff --git a/crates/io/src/channel_map.rs b/crates/io/src/channel_map.rs index f8dada8b..b2baf6f7 100644 --- a/crates/io/src/channel_map.rs +++ b/crates/io/src/channel_map.rs @@ -15,6 +15,7 @@ pub(crate) const DISPLAY_INFO_OLD: u16 = 1007; pub(crate) const QUICK_MASK_INFO: u16 = 1022; const SPACE_RGB: u16 = 0; +const SPACE_HSB: u16 = 1; const SPACE_CMYK: u16 = 2; const SPACE_LAB: u16 = 7; const SPACE_GRAY: u16 = 8; @@ -58,9 +59,25 @@ pub(crate) fn apply_display_info(data: &[u8], versioned: bool, channels: &mut [A } } +/// A PSD colour structure (space id + four u16 components) as an RGB [`Color`]; unknown spaces +/// (book colours…) read the first three components as RGB. See [`decode_known_color`]. pub(crate) fn decode_color(space: u16, c: [u16; 4]) -> Color { let n = |v: u16| f32::from(v) / 65535.0; - match space { + decode_known_color(space, c).unwrap_or_else(|| Color::rgb(n(c[0]), n(c[1]), n(c[2]))) +} + +/// A PSD colour structure in one of the spaces Photoshop writes for plain colours (0 RGB, +/// 1 HSB, 2 CMYK, 7 Lab, 8 Grayscale) as an RGB [`Color`], or `None` for any other space id. +/// CMYK goes through the colour-managed conversion (the active document CMYK profile, see +/// `photocraft_color::convert::with_cmyk_space`), Lab through the D50 Lab → sRGB conversion. +pub(crate) fn decode_known_color(space: u16, c: [u16; 4]) -> Option { + let n = |v: u16| f32::from(v) / 65535.0; + Some(match space { + SPACE_RGB => Color::rgb(n(c[0]), n(c[1]), n(c[2])), + SPACE_HSB => { + let rgb = hsb_to_rgb(n(c[0]) * 360.0, n(c[1]), n(c[2])); + Color::rgb(rgb[0], rgb[1], rgb[2]) + } // Photoshop colour structures store CMYK inverted (65535 = no ink). SPACE_CMYK => { let cmyk = Color { mode: ColorMode::Cmyk, c: [1.0 - n(c[0]), 1.0 - n(c[1]), 1.0 - n(c[2]), 1.0 - n(c[3])], alpha: 1.0 }; @@ -79,7 +96,23 @@ pub(crate) fn decode_color(space: u16, c: [u16; 4]) -> Color { let v = 1.0 - f32::from(c[0].min(10000)) / 10000.0; Color::rgb(v, v, v) } - _ => Color::rgb(n(c[0]), n(c[1]), n(c[2])), + _ => return None, + }) +} + +/// HSB (hue in degrees, saturation and brightness 0..1) → RGB. +fn hsb_to_rgb(h: f32, s: f32, v: f32) -> [f32; 3] { + let h = if h.is_finite() { h.rem_euclid(360.0) / 60.0 } else { 0.0 }; + let (s, v) = (s.clamp(0.0, 1.0), v.clamp(0.0, 1.0)); + let f = h - h.floor(); + let (p, q, t) = (v * (1.0 - s), v * (1.0 - s * f), v * (1.0 - s * (1.0 - f))); + match h.floor() as u8 { + 0 => [v, t, p], + 1 => [q, v, p], + 2 => [p, v, t], + 3 => [p, q, v], + 4 => [t, p, v], + _ => [v, p, q], } } @@ -117,5 +150,13 @@ mod tests { assert_eq!(g.to_rgb(), [0.0; 3]); let lab = decode_color(SPACE_LAB, [10000, 0, 0, 0]).to_rgb(); assert!(lab.iter().all(|v| *v > 0.95), "{lab:?}"); + // HSB: hue 120 degrees (65535 / 3), full saturation and brightness is pure green. + let g = decode_color(SPACE_HSB, [21845, 65535, 65535, 0]).to_rgb(); + assert!((g[0]).abs() < 1e-3 && (g[1] - 1.0).abs() < 1e-3 && g[2].abs() < 1e-3, "{g:?}"); + let half = decode_color(SPACE_HSB, [0, 0, 32768, 0]).to_rgb(); + assert!(half.iter().all(|v| (v - 0.5).abs() < 1e-3), "{half:?}"); + // Unknown spaces (book colours) are not guessed. + assert_eq!(decode_known_color(3, [1, 2, 3, 4]), None); + assert_eq!(decode_known_color(SPACE_RGB, [65535, 0, 0, 0]), Some(Color::rgb(1.0, 0.0, 0.0))); } } diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 771ebcb7..59806444 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -529,6 +529,12 @@ impl Ex { let legacy_phfl = k == b"phfl" && d.starts_with(&[0, 2]) && d.len() % 4 != 0; !legacy_phfl && adjust_map::ADJUSTMENT_KEYS.contains(&k) && adjust_map::parse(k, d, cged.as_deref(), channels) == *a }); + // A Gradient Map whose stops are unchanged keeps the file's own block (stops in + // the document's CMYK/Lab/Gray space, smoothness, method) with Reverse and Dither + // updated in place. + let keep = keep + || (matches!(a, photocraft_doc::Adjustment::GradientMap { .. }) + && raw.iter_mut().any(|(k, d)| k == b"grdm" && adjust_map::retarget_gradient_map(d, a, channels))); if !keep { raw.retain(|(k, _)| !adjust_map::ADJUSTMENT_KEYS.contains(&k) && k != b"CgEd"); let w = adjust_map::write(a); diff --git a/crates/io/src/psd_import.rs b/crates/io/src/psd_import.rs index da483c04..f5b2ecf4 100644 --- a/crates/io/src/psd_import.rs +++ b/crates/io/src/psd_import.rs @@ -702,7 +702,10 @@ pub fn psd_to_document_with(file: &PsdFile, ctl: &photocraft_raster::Interrupt) // Capped like `build` (#2940): a group past the document's nesting cap keeps no children, // so no file can make this tree, or anything walking or dropping it, recurse deeper. let tree = file.layer_tree_max_depth(photocraft_doc::MAX_GROUP_DEPTH); - doc.layers = cx.build(&tree, 0); + // Colours stored in the document's CMYK space (Gradient Map stops…) convert through + // its own profile, as the compositor and the PSD writer read them. + let space = photocraft_compose::cmyk_space(&doc); + doc.layers = photocraft_color::convert::with_cmyk_space(space.as_ref(), || cx.build(&tree, 0)); // Layer › Link Layers: resource 1026 holds one group id per layer record (0 = unlinked). if let Some(Ok(photocraft_psd::resources::ResourceData::LayerGroupInfo(groups))) = file.resources.iter().find(|r| r.id == ids::LAYER_GROUP_INFO).and_then(photocraft_psd::resources::ImageResource::parsed) diff --git a/crates/io/tests/color.rs b/crates/io/tests/color.rs index cb8acbd2..c5d44799 100644 --- a/crates/io/tests/color.rs +++ b/crates/io/tests/color.rs @@ -179,3 +179,103 @@ fn rgb_fill_colours_separate_through_the_document_cmyk_profile() { } } } + +/// A two-stop version 1 `grdm` block whose stops are the PSD colour structures `a` and `b`. +fn grdm_block(a: (u16, [u16; 4]), b: (u16, [u16; 4])) -> Vec { + let mut v = vec![0, 1, 0, 0]; + v.extend_from_slice(&1u32.to_be_bytes()); // name: just the terminator + v.extend_from_slice(&[0, 0]); + v.extend_from_slice(&2u16.to_be_bytes()); + for (t, (space, c)) in [(0u32, a), (4096, b)] { + v.extend_from_slice(&t.to_be_bytes()); + v.extend_from_slice(&50u32.to_be_bytes()); + v.extend_from_slice(&space.to_be_bytes()); + for x in c { + v.extend_from_slice(&x.to_be_bytes()); + } + v.extend_from_slice(&[0, 0]); + } + v.extend_from_slice(&2u16.to_be_bytes()); + for t in [0u32, 4096] { + v.extend_from_slice(&t.to_be_bytes()); + v.extend_from_slice(&50u32.to_be_bytes()); + v.extend_from_slice(&255u16.to_be_bytes()); + } + v.extend_from_slice(&2u16.to_be_bytes()); // expansion count + v.extend_from_slice(&0u16.to_be_bytes()); // smoothness + v +} + +fn saved_grdm(bytes: &[u8]) -> Vec { + let f = photocraft_psd::PsdFile::from_bytes(bytes).unwrap(); + f.layers().iter().find_map(|l| l.block(b"grdm")).map(|b| b.data.clone()).expect("grdm block") +} + +#[test] +fn gradient_maps_with_cmyk_lab_gray_stops_import_and_keep_their_space() { + use photocraft_cms::{Intent, Transform, synth}; + use photocraft_doc::{Adjustment, Document, Layer, LayerContent}; + let profile = synth::cmyk_profile(&synth::CmykParams { tvi: [0.26, 0.26, 0.26, 0.3], grid_a2b: 5, grid_b2a: 9, ..Default::default() }); + let profile = Profile::parse(&profile.to_bytes()).unwrap(); + let to_srgb = Transform::new(&profile, Builtin::Srgb.profile(), Intent::RelativeColorimetric, true).unwrap(); + // First stop 60 % cyan + 40 % black (stored inverted), second paper white. + let cmyk_stop = (2u16, [65535 - 39321, 65535, 65535, 65535 - 26214]); + let paper = (2u16, [65535; 4]); + let lab_dark = (7u16, [2000, 0, 0, 0]); + let lab_red = (7u16, [5000, 6000u16, 2000, 0]); + let gray_black = (8u16, [10000, 0, 0, 0]); + let gray_white = (8u16, [0, 0, 0, 0]); + for depth in SampleType::ALL { + for (mode, stops) in [(ColorMode::Cmyk, (cmyk_stop, paper)), (ColorMode::Lab, (lab_dark, lab_red)), (ColorMode::Grayscale, (gray_white, gray_black))] { + let block = grdm_block(stops.0, stops.1); + let mut doc = Document::new("map", photocraft_geom::Size::new(4, 2), mode, depth); + if mode == ColorMode::Cmyk { + doc.icc_profile = Some(profile.to_bytes()); + } + let fmt = doc.pixel_format(); + doc.layers.push(raster("Background", fmt, doc.bounds(), 3, false)); + doc.layers.push(Layer::new("Map", LayerContent::Adjustment(Adjustment::Unsupported { psd_key: "grdm".into(), raw: block.clone() }))); + let bytes = export(&doc, "x.psd", &ExportOptions::default()).unwrap().bytes; + let mut back = import("x.psd", &bytes).unwrap().document; + let LayerContent::Adjustment(Adjustment::GradientMap { stops: got, .. }) = &back.layers[1].content else { + panic!("{mode:?} {depth:?}: {:?}", back.layers[1].content); + }; + let (first, last) = (got[0].1, got[got.len() - 1].1); + match mode { + ColorMode::Cmyk => { + // Through the document's own profile, not the built-in coated CMYK. + let mut expected = [0.0f32; 3]; + to_srgb.eval_fast(&[0.6, 0.0, 0.0, 0.4], &mut expected); + assert!(first.iter().zip(expected).all(|(a, b)| (a - b).abs() < 1e-4), "{depth:?}: {first:?} vs {expected:?}"); + let builtin = photocraft_color::convert::cmyk_to_rgb([0.6, 0.0, 0.0, 0.4]); + assert!(first.iter().zip(builtin).any(|(a, b)| (a - b).abs() > 1e-3), "the document profile is used"); + } + ColorMode::Lab => { + assert!(first.iter().all(|v| (v - first[0]).abs() < 1e-3) && first[0] < 0.3, "{first:?}"); + assert!(last[0] > last[1] + 0.2, "a = +60 is reddish: {last:?}"); + } + // An inverting map, white to black. + _ => assert_eq!((first, last), ([1.0; 3], [0.0; 3])), + } + // The layer now renders: the composite differs from the background alone. + let with_map = photocraft_compose::flatten(&back).px; + let mut hidden = back.clone(); + hidden.layers[1].visible = false; + assert!(max_diff(&with_map, &photocraft_compose::flatten(&hidden).px) > 0.01, "{mode:?} {depth:?}"); + // Saving again keeps the block (and so the stops' colour space) byte for byte... + let again = export(&back, "x.psd", &ExportOptions::default()).unwrap().bytes; + assert_eq!(saved_grdm(&again), block, "{mode:?} {depth:?}"); + // ...and so does toggling Reverse and Dither, which only flip their flags. + if let LayerContent::Adjustment(Adjustment::GradientMap { reverse, dither, .. }) = &mut back.layers[1].content { + *reverse = true; + *dither = true; + } + let flipped_file = export(&back, "x.psd", &ExportOptions::default()).unwrap().bytes; + let flipped = saved_grdm(&flipped_file); + assert_eq!(&flipped[2..4], &[1, 1]); + assert_eq!(flipped[4..], block[4..], "{mode:?} {depth:?}: stops keep their colour space"); + let reopened = import("x.psd", &flipped_file).unwrap().document; + assert_eq!(reopened.layers[1].content, back.layers[1].content); + } + } +}