From 6547ca0b8459ef37e1dfb373cc00a81740ed74a4 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 12 Oct 2026 07:02:10 -0400 Subject: [PATCH] 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 --- crates/compose/src/multichannel.rs | 15 ++- crates/io/src/channel_map.rs | 10 +- crates/io/src/multichannel_map.rs | 60 ++++++++++-- crates/io/src/psd_export.rs | 127 ++++++++++++++------------ crates/io/tests/multichannel.rs | 142 +++++++++++++++++++++++++++++ 5 files changed, 284 insertions(+), 70 deletions(-) create mode 100644 crates/io/tests/multichannel.rs diff --git a/crates/compose/src/multichannel.rs b/crates/compose/src/multichannel.rs index 63bec04bf6..88c0b2f359 100644 --- a/crates/compose/src/multichannel.rs +++ b/crates/compose/src/multichannel.rs @@ -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); } diff --git a/crates/io/src/channel_map.rs b/crates/io/src/channel_map.rs index d3630bf35a..bafde26f30 100644 --- a/crates/io/src/channel_map.rs +++ b/crates/io/src/channel_map.rs @@ -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 }; diff --git a/crates/io/src/multichannel_map.rs b/crates/io/src/multichannel_map.rs index d20a70efcd..db31e743ef 100644 --- a/crates/io/src/multichannel_map.rs +++ b/crates/io/src/multichannel_map.rs @@ -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 = 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 = 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, }; diff --git a/crates/io/src/psd_export.rs b/crates/io/src/psd_export.rs index 1c43a36457..3d8d637eb4 100644 --- a/crates/io/src/psd_export.rs +++ b/crates/io/src/psd_export.rs @@ -1053,7 +1053,7 @@ fn effects_unchanged(l: &Layer) -> Option { Some(m == l.effects.enabled && items == l.effects.items) } -fn legacy_name(s: &str) -> Vec { +pub(crate) fn legacy_name(s: &str) -> Vec { 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 { v } -fn guides_resource(doc: &Document) -> Vec { +pub(crate) fn guides_resource(doc: &Document) -> Vec { 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 { 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) -> Vec { + 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); diff --git a/crates/io/tests/multichannel.rs b/crates/io/tests/multichannel.rs new file mode 100644 index 0000000000..d6016070d1 --- /dev/null +++ b/crates/io/tests/multichannel.rs @@ -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, 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::>(); + 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::>(); + 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 = 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::>(); + assert_eq!(keys(&back), keys(&f)); +}