mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-14 19:26:51 -04:00
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:
1 parent
4705fc5980
commit
6547ca0b84
5 files changed
+284
-70
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
Reference in new issue
Block a user