Merge PR #5791: PSD Multichannel: alpha channels are stored as-is, not as ink (#5790)

This commit is contained in:
aiongg committed 2026-10-21 01:06:14 +00:00
commit 68f8be3967
3 files changed
+108 -9

No files matched your search

+9 -4
View File
@@ -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);
+14 -3
View File
@@ -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<f32> = 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;
+85 -2
View File
@@ -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<f32> = 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<u8> {
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<f32> {
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::<Vec<_>>(), [[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);