From 667a14d9452786c566125060a32546c348ac5570 Mon Sep 17 00:00:00 2001 From: David Mann Date: Wed, 14 Oct 2026 23:20:06 +0200 Subject: [PATCH] PSD Multichannel: alpha channels are stored as-is, not as ink (#5790) A Multichannel PSD stores ink planes as tone (0 = solid ink), but the planes DisplayInfo (1077) marks as alpha-type (kind 0/1) are stored as-is, like alpha channels in every other mode. Import inverted every plane and export wrote every channel inverted, so a white (selected) alpha imported as 0 and RGB -> Multichannel -> save wrote alpha channels black. Measured with a file Photoshop saved itself: a new 8x4 8-bit white RGB document plus a "selected areas" alpha channel, white left half / black right half, converted to Multichannel and saved as PSD. Merged planes: Cyan, Magenta, Yellow = 255 everywhere (ink-inverted: no ink on white paper); alpha = 255 255 255 255 0 0 0 0 per row (as-is, 255 = selected). DisplayInfo kinds: 2, 2, 2, 0. - io: after DisplayInfo, flip alpha-type channels back; on export invert only spot channels. - compose: an alpha-type channel prints as black ink of 1 - value, so the composite of a Multichannel file is unchanged. Tests: a kind-0 alpha imports as stored (white = 1.0) with the spot plane still read as ink, the composite and the byte-for-byte round trip hold (8 and 16 bit); a full alpha exports as a max plane next to a solid ink stored 0. The #4031 composite check now compares against a black ink of the inverted alpha, within f32 rounding; the compose unit test uses the as-is polarity for its alpha channel. Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/compose/src/multichannel.rs | 13 +++-- crates/io/src/multichannel_map.rs | 17 ++++-- crates/io/tests/multichannel.rs | 87 +++++++++++++++++++++++++++++- 3 files changed, 108 insertions(+), 9 deletions(-) diff --git a/crates/compose/src/multichannel.rs b/crates/compose/src/multichannel.rs index 88c0b2f359..4eedc88619 100644 --- a/crates/compose/src/multichannel.rs +++ b/crates/compose/src/multichannel.rs @@ -5,6 +5,7 @@ //! (solidity 0) multiply like overprinted process inks, opaque ones cover what is below, which is //! how Photoshop previews spot channels. An alpha-type channel of a Multichannel document //! (selected / masked areas) still prints, as a transparent black ink, like Photoshop shows it. +//! Its value is stored as-is (1 = selected, white in the Channels panel), so its ink is `1 − value`. use photocraft_doc::Document; use photocraft_geom::Rect; @@ -16,8 +17,10 @@ pub fn inks(doc: &Document, rect: Rect) -> Buffer { let mut buf = Buffer::filled(rect, [1.0, 1.0, 1.0, 1.0]); let mut vals = Vec::new(); for ch in &doc.channels { + let alpha = ch.spot.is_none(); let (ink, solidity) = ch.spot.unwrap_or((photocraft_color::Color::BLACK, 0.0)); - if ch.surface.content_bounds().intersect(&rect).is_empty() { + // An empty alpha channel (all black) is solid black ink, so it can't be skipped. + if !alpha && ch.surface.content_bounds().intersect(&rect).is_empty() { continue; } let c = ink.to_rgb(); @@ -25,7 +28,7 @@ pub fn inks(doc: &Document, rect: Rect) -> Buffer { let n = ch.surface.channels(); ch.surface.read_region_into(rect, &mut vals); for (p, v) in buf.px.iter_mut().zip(vals.chunks_exact(n)) { - let v = v[0].clamp(0.0, 1.0); + let v = if alpha { 1.0 - v[0] } else { v[0] }.clamp(0.0, 1.0); if v <= 0.0 { continue; } @@ -66,14 +69,16 @@ mod tests { assert_eq!(r.px[1], [0.0, 1.0, 1.0, 1.0]); assert_eq!(r.px[2], [0.0, 0.0, 1.0, 1.0]); assert_eq!(r.px[3], [1.0, 0.0, 1.0, 1.0]); - // An alpha-type channel prints as a black ink. + // An alpha-type channel (stored as-is: 1 = selected) prints as a black ink where it is + // unselected, as Photoshop shows it. let mut k = Surface::new(fmt); - k.write_region(Rect::new(0, 0, 4, 1), &[0.5, 0.0, 0.0, 0.0]); + k.write_region(Rect::new(0, 0, 4, 1), &[0.5, 1.0, 1.0, 0.0]); doc.channels.push(AlphaChannel::new("Alpha", k)); let r = crate::flatten(&doc); // 0.5 is stored as 128/255 in an 8-bit channel: within one 8-bit step. assert!((r.px[0][0] - 0.5).abs() <= 1.0 / 255.0 && (r.px[0][1] - 0.5).abs() <= 1.0 / 255.0, "{:?}", r.px[0]); assert_eq!(r.px[1], [0.0, 1.0, 1.0, 1.0]); + assert_eq!(r.px[3], [0.0, 0.0, 0.0, 1.0]); // Other modes have no inks. doc.mode = ColorMode::Rgb; assert_eq!(crate::flatten(&doc).px[1][3], 0.0); diff --git a/crates/io/src/multichannel_map.rs b/crates/io/src/multichannel_map.rs index db31e743ef..0b2c7e6c63 100644 --- a/crates/io/src/multichannel_map.rs +++ b/crates/io/src/multichannel_map.rs @@ -3,8 +3,9 @@ //! A Multichannel document has no layers: its image is a set of ink (spot) channels, stored in //! [`Document::channels`] with ink density as the value (1 = solid ink, like every spot channel). //! A PSD stores the same planes the way Photoshop shows them in the Channels panel: 0 (black) = -//! solid ink, max = paper. Channel names come from resources 1006 / 1045 and ink colours from -//! DisplayInfo (1077), exactly as for spot channels in the other modes. +//! solid ink, max = paper. Alpha-type channels (DisplayInfo kind 0 / 1) are stored as-is, like the +//! alpha channels of every other mode (max = selected). Channel names come from resources 1006 / +//! 1045 and ink colours from DisplayInfo (1077), exactly as for spot channels in the other modes. use photocraft_color::{Color, ColorMode, PixelFormat, SampleType}; use photocraft_doc::{AlphaChannel, Document}; @@ -61,6 +62,14 @@ pub(crate) fn import(file: &PsdFile, all: &[u8], doc: &mut Document, names: &[St } else if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO_OLD) { crate::channel_map::apply_display_info(&r.data, false, 0, &mut doc.channels); } + // Alpha-type planes are stored as-is, not as ink: undo the inversion for them. + for ch in doc.channels.iter_mut().filter(|c| c.spot.is_none()) { + let vals: Vec = ch.surface.read_region(canvas).chunks_exact(ch.surface.channels()).map(|v| 1.0 - v[0]).collect(); + let mut s = Surface::new(fmt); + s.write_region(canvas, &vals); + s.prune(); + ch.surface = s; + } } /// Writes a Multichannel document as a mode-7 PSD (no layer section; every channel a plane). @@ -90,8 +99,10 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec< } } for ch in &channels { + // Inks are stored as tone (0 = solid ink), alpha-type channels as-is. + let ink = ch.spot.is_some(); for v in ch.surface.read_region(canvas).chunks_exact(ch.surface.channels()) { - encode_be(1.0 - v[0], sample, &mut planes); + encode_be(if ink { 1.0 - v[0] } else { v[0] }, sample, &mut planes); } } let big = doc.size.width > 30_000 || doc.size.height > 30_000; diff --git a/crates/io/tests/multichannel.rs b/crates/io/tests/multichannel.rs index d6016070d1..fd8f8448c3 100644 --- a/crates/io/tests/multichannel.rs +++ b/crates/io/tests/multichannel.rs @@ -46,10 +46,21 @@ fn alpha_type_channels_import_as_alpha_and_round_trip() { assert!((a.opacity - 0.5).abs() < 1e-6); assert_eq!(a.indicates, ColorIndicates::MaskedAreas); - // It still prints as black ink, so the composite is what a black spot channel gives. + // It still prints as black ink, so the composite is what a black spot channel gives. The + // alpha is stored as-is (1 = selected), so that ink is its inverse, as on the PSD plane. let mut as_ink = d.clone(); + let canvas = d.bounds(); + let ink: Vec = d.channels[1].surface.read_region(canvas).iter().map(|v| 1.0 - v).collect(); + let mut s = photocraft_raster::Surface::new(d.channels[1].surface.format()); + s.write_region(canvas, &ink); + as_ink.channels[1].surface = s; as_ink.channels[1].spot = Some((photocraft_color::Color::BLACK, 0.0)); - assert_eq!(photocraft_compose::flatten(&d).px, photocraft_compose::flatten(&as_ink).px); + // Within f32 rounding: the inverted copy is re-quantised to the channel depth. + let (shown, inked) = (photocraft_compose::flatten(&d).px, photocraft_compose::flatten(&as_ink).px); + assert_eq!(shown.len(), inked.len()); + for (a, b) in shown.iter().zip(&inked) { + assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-5), "{a:?} vs {b:?} ({depth}-bit)"); + } // Saved back unchanged: same DisplayInfo, same planes, same channels on reopening. let (back, d2) = roundtrip(&d); @@ -62,6 +73,78 @@ fn alpha_type_channels_import_as_alpha_and_round_trip() { } } +/// Stored samples of one big-endian plane at `depth` (8 or 16), from 0..=1 tone values. +fn plane(vals: &[f32], depth: u16) -> Vec { + let max = if depth == 16 { 65535.0 } else { 255.0 }; + vals.iter() + .flat_map(|v| { + let s = (v * max).round() as u16; + if depth == 16 { s.to_be_bytes().to_vec() } else { vec![s as u8] } + }) + .collect() +} + +/// A 4×1 Multichannel file laid out like Photoshop writes one (measured with a file Photoshop saved +/// after RGB → Multichannel): a cyan spot plane stored as ink-inverted tone (0 = solid ink) and a +/// kind-0 alpha plane stored as-is (max = selected), selected in the left half. +fn ink_and_alpha_file(depth: u16) -> PsdFile { + let mut f = testgen::merged_only(Version::Psd, ColorMode::Multichannel, depth, Compression::Rle, 4, 1); + let mut planes = plane(&[0.0, 1.0, 0.0, 1.0], depth); + planes.extend(plane(&[1.0, 1.0, 0.0, 0.0], depth)); + f.image_data = photocraft_psd::ImageData::encode(Compression::Rle, &planes, &f.header).unwrap(); + let mut info = 1u32.to_be_bytes().to_vec(); + display_record(&mut info, [0, 65535, 65535], 0, 2); + display_record(&mut info, [65535, 0, 0], 50, 0); + f.resources.push(ImageResource::new(1077, info)); + f +} + +fn values(ch: &photocraft_doc::AlphaChannel) -> Vec { + ch.surface.read_region(photocraft_geom::Rect::new(0, 0, 4, 1)).chunks_exact(ch.surface.channels()).map(|v| v[0]).collect() +} + +#[test] +fn alpha_type_channels_are_stored_as_is_not_as_ink() { + for depth in [8, 16] { + let f = ink_and_alpha_file(depth); + let (d, _) = psd_to_document(&f); + // A white (selected) alpha plane imports as 1.0, like an alpha channel of any other mode; + // the spot plane is still read as ink density (stored 0 = solid ink). + assert_eq!(d.channels[1].spot, None); + assert_eq!(values(&d.channels[1]), [1.0, 1.0, 0.0, 0.0], "alpha channel ({depth}-bit)"); + assert_eq!(values(&d.channels[0]), [1.0, 0.0, 1.0, 0.0], "spot channel ({depth}-bit)"); + + // The composite is what Photoshop shows: the alpha prints as black ink where unselected. + let px = photocraft_compose::flatten(&d).px; + assert_eq!(px.iter().map(|p| [p[0], p[1], p[2]]).collect::>(), [[0.0, 1.0, 1.0], [1.0, 1.0, 1.0], [0.0, 0.0, 0.0], [0.0, 0.0, 0.0]]); + + // Saved back byte for byte. + let (back, d2) = roundtrip(&d); + assert_eq!(back.decode_merged().unwrap(), f.decode_merged().unwrap(), "{depth}-bit"); + assert_eq!(values(&d2.channels[1]), [1.0, 1.0, 0.0, 0.0]); + } +} + +#[test] +fn a_full_alpha_channel_exports_as_a_full_plane() { + use photocraft_color::{Color, ColorMode as Mode, PixelFormat, SampleType}; + for (sample, depth) in [(SampleType::U8, 8u16), (SampleType::U16, 16)] { + let mut doc = photocraft_doc::Document::new("m", photocraft_doc::Size::new(4, 1), Mode::Multichannel, sample); + let fmt = PixelFormat::new(Mode::Grayscale, sample, false); + let surface = |v: f32| { + let mut s = photocraft_raster::Surface::new(fmt); + s.write_region(photocraft_geom::Rect::new(0, 0, 4, 1), &[v; 4]); + s + }; + doc.channels.push(photocraft_doc::AlphaChannel { spot: Some((Color::BLACK, 0.0)), ..photocraft_doc::AlphaChannel::new("Black", surface(1.0)) }); + doc.channels.push(photocraft_doc::AlphaChannel::new("Alpha 1", surface(1.0))); + let (back, _) = roundtrip(&doc); + let mut want = plane(&[0.0; 4], depth); + want.extend(plane(&[1.0; 4], depth)); + assert_eq!(back.decode_merged().unwrap(), want, "solid ink is stored 0, a full alpha max ({depth}-bit)"); + } +} + #[test] fn resources_and_global_blocks_survive_a_save() { let mut f = file(8);