fix(psd): Multichannel alpha channels stay alpha; a save keeps resources and global blocks (#4031)

- DisplayInfo kinds 0/1 (selected / masked areas) now import as alpha channels instead of black
  spot inks; a Multichannel composite still prints them as black ink, so the look is unchanged.
- Saving a Multichannel PSD writes global light, grid and guides, saved / work / clipping paths,
  EXIF and every other imported resource (print settings, IPTC, layer-id seed, alpha ids while
  they match the channels), except the ones that only describe layers, plus the document-level
  tagged blocks (4-byte padded). Path writing and global-block padding are shared with the
  layered writer.

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 authored and GitHub committed 2026-10-12 07:02:10 -04:00
1 parent 4705fc5980
commit 6547ca0b84
5 files changed
+284 -70

No files matched your search

+12 -3
View File
@@ -3,7 +3,8 @@
//! Ink density `v` (the channel value, 1 = solid) of an ink with display colour `c` and solidity
//! `s` turns the colour underneath `p` into `(1 − s·v)·p·(1 − v + v·c) + s·v·c`: transparent inks
//! (solidity 0) multiply like overprinted process inks, opaque ones cover what is below, which is
//! how Photoshop previews spot channels.
//! 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.
use photocraft_doc::Document;
use photocraft_geom::Rect;
@@ -15,7 +16,7 @@ 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 Some((ink, solidity)) = ch.spot else { continue };
let (ink, solidity) = ch.spot.unwrap_or((photocraft_color::Color::BLACK, 0.0));
if ch.surface.content_bounds().intersect(&rect).is_empty() {
continue;
}
@@ -65,7 +66,15 @@ 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]);
// Alpha channels and other modes are not inks.
// An alpha-type channel prints as a black ink.
let mut k = Surface::new(fmt);
k.write_region(Rect::new(0, 0, 4, 1), &[0.5, 0.0, 0.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]);
// Other modes have no inks.
doc.mode = ColorMode::Rgb;
assert_eq!(crate::flatten(&doc).px[1][3], 0.0);
}
+6 -4
View File
@@ -1,10 +1,10 @@
//! Alpha / spot channel options <-> PSD `DisplayInfo` image resources (1077, and the older 1007).
//!
//! Layout (Adobe PSD spec, "DisplayInfo"): resource 1077 starts with a 4-byte version (1), then
//! one 14-byte record per extra channel: colour space (u16), four u16 colour components, opacity
//! (u16, 0..100), kind (u8: 0 = colour indicates selected areas, 1 = masked ("protected") areas,
//! 2 = spot channel) and one padding byte. Resource 1007 has the same records without the
//! version and without the spot kind.
//! one 13-byte record per extra channel: colour space (u16), four u16 colour components, opacity
//! (u16, 0..100) and kind (u8: 0 = colour indicates selected areas, 1 = masked ("protected")
//! areas, 2 = spot channel). Resource 1007 has the same records without the version and the spot
//! kind, each followed by a padding byte (14 bytes).
use photocraft_color::{Color, ColorMode};
use photocraft_doc::{AlphaChannel, ColorIndicates};
@@ -59,6 +59,8 @@ pub(crate) fn apply_display_info(data: &[u8], versioned: bool, skip: usize, chan
match rec[12] {
2 => c.spot = Some((color, opacity)),
k => {
// An alpha channel, not an ink (a Multichannel import starts every channel as one).
c.spot = None;
c.color = color;
c.opacity = opacity;
c.indicates = if k == 0 { ColorIndicates::SelectedAreas } else { ColorIndicates::MaskedAreas };
+53 -7
View File
@@ -9,13 +9,21 @@
use photocraft_color::{Color, ColorMode, PixelFormat, SampleType};
use photocraft_doc::{AlphaChannel, Document};
use photocraft_geom::Rect;
use photocraft_psd::file::LayerInfoPlacement;
use photocraft_psd::file::{GlobalLayerMask, LayerInfoPlacement};
use photocraft_psd::resources::{ImageResource, ResolutionInfo, ids, version_info_resource};
use photocraft_psd::{ColorMode as PsdMode, Compression, Header, ImageData, PsdFile};
use photocraft_psd::{ColorMode as PsdMode, Compression, Header, ImageData, PsdFile, TaggedBlock};
use photocraft_raster::Surface;
use crate::pixels::{encode_be, psd_depth};
/// Grid and guides.
const GRID_AND_GUIDES: u16 = 1032;
/// Alpha identifiers: one u32 per extra channel.
const ALPHA_IDENTIFIERS: u16 = 1053;
/// Resources that only describe layers: target layer (1024), layer groups (1026), layer
/// selection ids (1069) and layer groups enabled (1072). A Multichannel document has no layers.
const LAYER_ONLY_RESOURCES: [u16; 4] = [ids::LAYER_STATE, ids::LAYER_GROUP_INFO, 1069, 1072];
/// Value of sample `i` of a big-endian plane.
fn decode(plane: &[u8], i: usize, s: SampleType) -> f32 {
match s {
@@ -44,7 +52,8 @@ pub(crate) fn import(file: &PsdFile, all: &[u8], doc: &mut Document, names: &[St
s.write_region(canvas, &vals);
s.prune();
let name = names.get(k).cloned().filter(|s| !s.is_empty()).unwrap_or_else(|| format!("Channel {}", k + 1));
// Without DisplayInfo the channels print in black, as Photoshop shows them.
// Without DisplayInfo the channels print in black, as Photoshop shows them. DisplayInfo
// turns alpha-type channels (selected / masked areas) back into alpha channels.
doc.channels.push(AlphaChannel { spot: Some((Color::BLACK, 0.0)), ..AlphaChannel::new(name, s) });
}
if let Some(r) = file.resource(crate::channel_map::DISPLAY_INFO) {
@@ -97,17 +106,54 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec<
let image_data = ImageData::encode(Compression::Rle, &planes, &header)
.or_else(|_| ImageData::encode(Compression::Raw, &planes, &header))
.unwrap_or(ImageData { compression: Compression::Raw, data: planes });
let mut resources = vec![ImageResource::new(ids::RESOLUTION_INFO, ResolutionInfo::from_dpi(f64::from(doc.resolution_dpi)).to_bytes())];
let mut resources = vec![
ImageResource::new(ids::RESOLUTION_INFO, ResolutionInfo::from_dpi(f64::from(doc.resolution_dpi)).to_bytes()),
ImageResource::new(ids::GLOBAL_ANGLE, (doc.global_light.angle.round() as i32).to_be_bytes().to_vec()),
ImageResource::new(ids::GLOBAL_ALTITUDE, (doc.global_light.altitude.round() as i32).to_be_bytes().to_vec()),
];
// Grid and guides: Photoshop writes it in every file, guides or not.
resources.push(ImageResource::new(GRID_AND_GUIDES, crate::psd_export::guides_resource(doc)));
if !channels.is_empty() {
let names: Vec<&str> = channels.iter().map(|c| c.name.as_str()).collect();
resources.push(ImageResource::new(1006, crate::psd_export::pascal_names_resource(&names)));
resources.push(ImageResource::new(1045, crate::psd_export::unicode_names_resource(&names)));
resources.push(ImageResource::new(crate::channel_map::DISPLAY_INFO, crate::channel_map::display_info(&channels, false)));
}
resources.extend(crate::psd_export::path_resources(doc, &mut warnings));
// The XMP and EXIF resolution follow the ResolutionInfo resource, so no copy contradicts it.
let ppi = Some((doc.resolution_dpi, doc.resolution_dpi)).filter(|d| d.0 > 0.0);
if let Some(x) = &doc.metadata.xmp {
resources.push(ImageResource::new(ids::XMP, x.as_bytes().to_vec()));
resources.push(ImageResource::new(ids::XMP, photocraft_codecs::export_xmp(x, ppi).as_bytes().to_vec()));
}
if let Some(e) = &doc.metadata.exif {
resources.push(ImageResource::new(ids::EXIF, photocraft_codecs::export_exif(e, ppi).into_owned()));
}
// Every other imported resource (print settings, IPTC, the layer-id seed, ...), except the
// ones that only describe layers and any already written above.
let written: Vec<u16> = resources.iter().map(|r| r.id).collect();
for (id, name, data) in &doc.metadata.psd_resources {
if LAYER_ONLY_RESOURCES.contains(id) || written.contains(id) || !crate::psd_export::keep_imported_resource(doc, *id, data) {
continue;
}
// Alpha identifiers: one u32 per channel, only while they still match the channels.
if *id == ALPHA_IDENTIFIERS && data.len() != channels.len().saturating_mul(4) {
continue;
}
let mut r = ImageResource::new(*id, data.to_vec());
r.name = crate::psd_export::legacy_name(name);
resources.push(r);
}
if let Some(data) = crate::annotations_map::fresh_scale_resource(doc) {
resources.push(ImageResource::new(crate::annotations_map::MEASUREMENT_SCALE, data));
}
resources.push(version_info_resource(true));
// Document-level tagged blocks (patterns, notes, ...) are valid without a layer section; a
// kept layer block would describe layers the document doesn't have.
let global_blocks: Vec<TaggedBlock> = crate::annotations_map::export_blocks(doc, crate::pattern_map::export_global_blocks(doc))
.into_iter()
.filter(|(_, key, _)| !matches!(key, b"Layr" | b"Lr16" | b"Lr32"))
.map(|(sig, key, data)| crate::psd_export::global_block(sig, key, &data))
.collect();
let color_mode_data = photocraft_psd::hdr::color_mode_data_for_depth(header.depth);
let file = PsdFile {
header,
@@ -115,8 +161,8 @@ pub(crate) fn document_to_psd(doc: &Document, force_psb: bool) -> (PsdFile, Vec<
resources,
layer_info: None,
layer_info_placement: LayerInfoPlacement::Section,
global_layer_mask: None,
global_blocks: Vec::new(),
global_layer_mask: (!global_blocks.is_empty()).then(GlobalLayerMask::default),
global_blocks,
layer_mask_trailing: Vec::new(),
image_data,
};
+71 -56
View File
@@ -1053,7 +1053,7 @@ fn effects_unchanged(l: &Layer) -> Option<bool> {
Some(m == l.effects.enabled && items == l.effects.items)
}
fn legacy_name(s: &str) -> Vec<u8> {
pub(crate) fn legacy_name(s: &str) -> Vec<u8> {
s.chars().map(|c| if c.is_ascii() && !c.is_ascii_control() { c as u8 } else { b'?' }).take(255).collect()
}
@@ -1079,7 +1079,7 @@ pub(crate) fn pascal_names_resource(names: &[&str]) -> Vec<u8> {
v
}
fn guides_resource(doc: &Document) -> Vec<u8> {
pub(crate) fn guides_resource(doc: &Document) -> Vec<u8> {
let mut v = Vec::new();
v.extend_from_slice(&1u32.to_be_bytes());
v.extend_from_slice(&576u32.to_be_bytes());
@@ -1095,6 +1095,72 @@ fn guides_resource(doc: &Document) -> Vec<u8> {
v
}
/// Path resources: saved paths (2000+), the work path (1025) and the clipping path name (2999),
/// the last only when the imported one doesn't already name the document's clipping path.
pub(crate) fn path_resources(doc: &Document, warnings: &mut Vec<String>) -> Vec<ImageResource> {
use crate::vector_map::{CLIPPING_PATH, WORK_PATH, path_from_resource, path_to_records};
let mut out = Vec::new();
let (w, h) = (doc.size.width, doc.size.height);
let max = (*crate::vector_map::SAVED_PATHS.end() - *crate::vector_map::SAVED_PATHS.start() + 1) as usize;
if doc.paths.len() > max {
warnings.push(format!("only {max} saved paths fit in PSD; the rest were dropped"));
}
for (i, p) in doc.paths.iter().take(max).enumerate() {
let data = match &p.psd_raw {
Some(r) if path_from_resource(r, w, h).as_ref() == Some(&p.path) => r.to_vec(),
_ => path_to_records(&p.path, w, h).to_bytes(),
};
let mut r = ImageResource::new(crate::vector_map::SAVED_PATHS.start() + i as u16, data);
r.name = legacy_name(&p.name);
out.push(r);
}
if let Some(wp) = &doc.work_path {
out.push(ImageResource::new(WORK_PATH, path_to_records(wp, w, h).to_bytes()));
}
let raw_clip = doc.metadata.psd_resources.iter().find(|(id, _, _)| *id == CLIPPING_PATH);
let raw_name = raw_clip.map(|(_, _, d)| {
let n = usize::from(d.first().copied().unwrap_or(0));
d.get(1..1 + n).map(|b| String::from_utf8_lossy(b).into_owned()).unwrap_or_default()
});
if let Some(c) = &doc.clipping_path
&& raw_name.as_deref() != Some(c.name.as_str())
{
// Pascal name, then flatness as 16.16 fixed (best effort; see vector_map docs).
let mut data = vec![0u8];
let name = legacy_name(&c.name);
data[0] = name.len() as u8;
data.extend_from_slice(&name);
data.extend_from_slice(&((c.flatness.max(0.0) * 65536.0) as u32).to_be_bytes());
out.push(ImageResource::new(CLIPPING_PATH, data));
}
out
}
/// Whether an imported (unmodelled) resource still describes the document and is written back.
pub(crate) fn keep_imported_resource(doc: &Document, id: u16, data: &[u8]) -> bool {
// Measurement scale / count resources: only while they still describe the document.
if !crate::annotations_map::keep_resource(doc, id) {
return false;
}
// A preserved clipping-path resource is only valid while it names the current one.
if id == crate::vector_map::CLIPPING_PATH {
let n = usize::from(data.first().copied().unwrap_or(0));
let raw_name = data.get(1..1 + n).map(|b| String::from_utf8_lossy(b).into_owned()).unwrap_or_default();
return doc.clipping_path.as_ref().is_some_and(|c| c.name == raw_name);
}
true
}
/// A document-level (global) tagged block. Photoshop pads these to a multiple of 4, and readers
/// such as psd-tools step to the next block that way: an even pad after `CAI ` (77 bytes)
/// misaligned every block after it (#200).
pub(crate) fn global_block(signature: [u8; 4], key: [u8; 4], data: &[u8]) -> TaggedBlock {
let mut tb = TaggedBlock::new(key, data.to_vec());
tb.signature = signature;
tb.padding = Some(vec![0; (4 - data.len() % 4) % 4]);
tb
}
fn cmyk_of(fmt: &PixelFormat) -> bool {
fmt.mode == ColorMode::Cmyk
}
@@ -1467,43 +1533,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
data.push(0);
resources.push(ImageResource::new(crate::channel_map::QUICK_MASK_INFO, data));
}
// Paths: saved paths (2000+), the work path (1025), the clipping path name (2999).
{
use crate::vector_map::{CLIPPING_PATH, WORK_PATH, path_from_resource, path_to_records};
let (w, h) = (doc.size.width, doc.size.height);
let max = (*crate::vector_map::SAVED_PATHS.end() - *crate::vector_map::SAVED_PATHS.start() + 1) as usize;
if doc.paths.len() > max {
ex.warnings.push(format!("only {max} saved paths fit in PSD; the rest were dropped"));
}
for (i, p) in doc.paths.iter().take(max).enumerate() {
let data = match &p.psd_raw {
Some(r) if path_from_resource(r, w, h).as_ref() == Some(&p.path) => r.to_vec(),
_ => path_to_records(&p.path, w, h).to_bytes(),
};
let mut r = ImageResource::new(crate::vector_map::SAVED_PATHS.start() + i as u16, data);
r.name = legacy_name(&p.name);
resources.push(r);
}
if let Some(wp) = &doc.work_path {
resources.push(ImageResource::new(WORK_PATH, path_to_records(wp, w, h).to_bytes()));
}
let raw_clip = doc.metadata.psd_resources.iter().find(|(id, _, _)| *id == CLIPPING_PATH);
let raw_name = raw_clip.map(|(_, _, d)| {
let n = usize::from(d.first().copied().unwrap_or(0));
d.get(1..1 + n).map(|b| String::from_utf8_lossy(b).into_owned()).unwrap_or_default()
});
if let Some(c) = &doc.clipping_path
&& raw_name.as_deref() != Some(c.name.as_str())
{
// Pascal name, then flatness as 16.16 fixed (best effort; see vector_map docs).
let mut data = vec![0u8];
let name = legacy_name(&c.name);
data[0] = name.len() as u8;
data.extend_from_slice(&name);
data.extend_from_slice(&((c.flatness.max(0.0) * 65536.0) as u32).to_be_bytes());
resources.push(ImageResource::new(CLIPPING_PATH, data));
}
}
resources.extend(path_resources(doc, &mut ex.warnings));
if let Some(groups) = link_group_resource(&doc.layers) {
resources.push(ImageResource::new(ids::LAYER_GROUP_INFO, groups));
}
@@ -1548,13 +1578,7 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
if !ex.records.is_empty() && matches!(&key, b"Lr16" | b"Lr32" | b"Layr") {
continue;
}
let mut tb = TaggedBlock::new(key, data.to_vec());
tb.signature = sig;
// Photoshop pads document-level (global) blocks to a multiple of 4, and readers such as
// psd-tools step to the next block that way: an even pad after `CAI ` (77 bytes)
// misaligned every block after it (#200).
tb.padding = Some(vec![0; (4 - data.len() % 4) % 4]);
global_blocks.push(tb);
global_blocks.push(global_block(sig, key, &data));
}
if let Some(txt2) = txt2 {
let mut tb = TaggedBlock::new(*b"Txt2", txt2.clone());
@@ -1576,18 +1600,9 @@ fn document_to_psd_nested(doc: &Document, opts: &PsdExportOptions, depth: u32, c
if *id == crate::slices_map::SLICES && slices_resource.is_some() {
continue;
}
// Measurement scale / count resources: only while they still describe the document.
if !crate::annotations_map::keep_resource(doc, *id) {
if !keep_imported_resource(doc, *id, data) {
continue;
}
// A preserved clipping-path resource is only valid while it names the current one.
if *id == crate::vector_map::CLIPPING_PATH {
let n = usize::from(data.first().copied().unwrap_or(0));
let raw_name = data.get(1..1 + n).map(|b| String::from_utf8_lossy(b).into_owned()).unwrap_or_default();
if doc.clipping_path.as_ref().is_none_or(|c| c.name != raw_name) {
continue;
}
}
let mut r = ImageResource::new(*id, data.to_vec());
r.name = legacy_name(name);
resources.push(r);
+142
View File
@@ -0,0 +1,142 @@
//! Multichannel PSDs: alpha-type channels and the document's resources and global blocks survive
//! an import → export → import round trip.
use photocraft_doc::{ColorIndicates, Knot, NamedPath, Path, PathOp, Subpath};
use photocraft_io::*;
use photocraft_psd::resources::ImageResource;
use photocraft_psd::{ColorMode, Compression, PsdFile, TaggedBlock, Version, testgen};
/// DisplayInfo (1077) record: RGB colour, opacity (0..100) and kind (0/1 alpha, 2 spot).
fn display_record(out: &mut Vec<u8>, rgb: [u16; 3], opacity: u16, kind: u8) {
out.extend_from_slice(&0u16.to_be_bytes());
for v in [rgb[0], rgb[1], rgb[2], 0] {
out.extend_from_slice(&v.to_be_bytes());
}
out.extend_from_slice(&opacity.to_be_bytes());
out.push(kind);
}
/// A two-channel Multichannel file: a cyan spot ink and an alpha channel (masked areas, red 50 %).
fn file(depth: u16) -> PsdFile {
let mut f = testgen::merged_only(Version::Psd, ColorMode::Multichannel, depth, Compression::Rle, 9, 5);
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, 1);
f.resources.push(ImageResource::new(1077, info));
f
}
fn roundtrip(d: &photocraft_doc::Document) -> (PsdFile, photocraft_doc::Document) {
let out = export(d, "x.psd", &ExportOptions::default()).unwrap();
let back = PsdFile::from_bytes(&out.bytes).unwrap();
let (doc, _) = psd_to_document(&back);
(back, doc)
}
#[test]
fn alpha_type_channels_import_as_alpha_and_round_trip() {
for depth in [8, 16] {
let f = file(depth);
let (d, _) = psd_to_document(&f);
assert_eq!(d.channels.len(), 2);
assert_eq!(d.channels[0].spot.map(|(c, _)| c.to_rgb()), Some([0.0, 1.0, 1.0]));
let a = &d.channels[1];
assert_eq!(a.spot, None, "an alpha-type channel is not a spot ink ({depth}-bit)");
assert_eq!(a.color.to_rgb(), [1.0, 0.0, 0.0]);
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.
let mut as_ink = d.clone();
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);
// Saved back unchanged: same DisplayInfo, same planes, same channels on reopening.
let (back, d2) = roundtrip(&d);
assert_eq!(back.resource(1077).map(|r| r.data.clone()), f.resource(1077).map(|r| r.data.clone()));
assert_eq!(back.decode_merged().unwrap(), f.decode_merged().unwrap());
assert_eq!(d2.channels[1].spot, None);
assert_eq!(d2.channels[1].indicates, ColorIndicates::MaskedAreas);
assert!((d2.channels[1].opacity - 0.5).abs() < 1e-6);
assert!(d2.channels[0].spot.is_some());
}
}
#[test]
fn resources_and_global_blocks_survive_a_save() {
let mut f = file(8);
let iptc = vec![0x1c, 2, 0, 0, 2, 0, 4];
let print_flags = vec![0, 0, 0, 0, 0, 0, 0, 1, 0];
let seed = 7u32.to_be_bytes().to_vec();
let alpha_ids = [0u32, 1].iter().flat_map(|v| v.to_be_bytes()).collect::<Vec<u8>>();
f.resources.push(ImageResource::new(1028, iptc.clone()));
f.resources.push(ImageResource::new(1011, print_flags.clone()));
f.resources.push(ImageResource::new(1044, seed.clone()));
f.resources.push(ImageResource::new(1053, alpha_ids.clone()));
// A target-layer index describes layers, which a Multichannel document doesn't have.
f.resources.push(ImageResource::new(1024, vec![0, 0]));
let fmsk = vec![0, 0, 0, 0, 0, 50];
f.global_blocks.push(TaggedBlock::new(*b"FMsk", fmsk.clone()));
let (mut d, _) = psd_to_document(&f);
d.guides.horizontal.push(2.0);
d.guides.vertical.push(3.0);
d.global_light.angle = 60.0;
let path =
Path::new(vec![Subpath { closed: true, op: PathOp::Combine, knots: vec![Knot::corner(1.0, 1.0), Knot::corner(8.0, 1.0), Knot::corner(4.0, 4.0)] }]);
d.paths.push(NamedPath { name: "Outline".into(), path, psd_raw: None });
let (back, d2) = roundtrip(&d);
let res = |id: u16| back.resource(id).map(|r| r.data.clone());
assert_eq!(res(1028), Some(iptc));
assert_eq!(res(1011), Some(print_flags));
assert_eq!(res(1044), Some(seed));
assert_eq!(res(1053), Some(alpha_ids));
assert_eq!(res(1024), None);
let blocks: Vec<_> = back.global_blocks.iter().filter(|b| &b.key == b"FMsk").collect();
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0].data, fmsk);
assert_eq!(d2.guides.horizontal, vec![2.0]);
assert_eq!(d2.guides.vertical, vec![3.0]);
assert_eq!(d2.global_light.angle, 60.0);
// The file's own saved path and the new one.
let names: Vec<&str> = d2.paths.iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["Path 1", "Outline"]);
let anchors = |p: &Path| p.subpaths.iter().flat_map(|s| s.knots.iter().map(|k| (k.anchor.x, k.anchor.y))).collect::<Vec<_>>();
let (got, want) = (anchors(&d2.paths[1].path), anchors(&d.paths[1].path));
assert_eq!(got.len(), want.len());
assert!(got.iter().zip(&want).all(|(a, b)| (a.0 - b.0).abs() < 1e-3 && (a.1 - b.1).abs() < 1e-3), "{got:?}");
assert_eq!(back.decode_merged().unwrap(), f.decode_merged().unwrap());
// Alpha identifiers that no longer match the channels are not written.
d.channels.pop();
let (back, _) = roundtrip(&d);
assert!(back.resource(1053).is_none());
}
/// A Photoshop Multichannel file: its alpha-type channel stays an alpha channel and a save keeps
/// every resource except the thumbnail (regenerated) and every document-level block.
#[cfg(feature = "corpus")]
#[test]
fn corpus_multichannel_round_trip_keeps_channels_and_resources() {
let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus/psd/psd-tools/colormodes/4x4_16bit_multichannel.psd");
assert!(path.is_file(), "{} is missing: run `cargo xtask corpus --all`", path.display());
let bytes = std::fs::read(&path).unwrap();
let f = PsdFile::from_bytes(&bytes).unwrap();
let (d, _) = psd_to_document(&f);
let alpha: Vec<_> = d.channels.iter().filter(|c| c.spot.is_none()).collect();
assert!(!alpha.is_empty(), "the file's alpha-type channel imports as an alpha channel");
for a in &alpha {
assert_eq!(a.indicates, ColorIndicates::MaskedAreas);
assert_eq!(a.color.to_rgb(), [1.0, 0.0, 0.0]);
assert!((a.opacity - 0.5).abs() < 1e-6);
}
let (back, d2) = roundtrip(&d);
assert_eq!(back.resource(1077).map(|r| r.data.clone()), f.resource(1077).map(|r| r.data.clone()));
assert_eq!(back.decode_merged().unwrap(), f.decode_merged().unwrap());
assert_eq!(d2.channels.iter().filter(|c| c.spot.is_none()).count(), alpha.len());
let mut lost: Vec<u16> = f.resources.iter().map(|r| r.id).filter(|id| back.resource(*id).is_none()).collect();
lost.retain(|id| *id != 1036);
assert!(lost.is_empty(), "resources lost on save: {lost:?}");
let keys = |f: &PsdFile| f.global_blocks.iter().map(|b| b.key).collect::<Vec<_>>();
assert_eq!(keys(&back), keys(&f));
}