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 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-12 02:22:59 -04:00
parent 6a852f8852
commit d549069569
5 changed files with 260 additions and 9 deletions

View File

@ -267,7 +267,10 @@ fn be32(d: &[u8], at: usize) -> Option<u32> {
/// 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<Adjustment> {
let version = be16(d, 0)?;
let reverse = *d.get(2)? != 0;
@ -285,11 +288,9 @@ fn parse_gradient_map(d: &[u8]) -> Option<Adjustment> {
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<Adjustment> {
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<u8> {
@ -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<u8> {
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:?}");

View File

@ -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<Color> {
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)));
}
}

View File

@ -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);

View File

@ -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)

View File

@ -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<u8> {
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<u8> {
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);
}
}
}