mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-24 09:57:08 -06:00
- io tests/it/psd_structure.rs: main's Duotone test compares the raw block as a slice now that #5815 shares it as Arc<[u8]>. - ru and fr catalogs: main and #6658 both added "Continue" with the same translation; the second copy goes (bundled_catalogs_are_consistent). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
839 lines
42 KiB
Rust
839 lines
42 KiB
Rust
//! PSD (testgen / builder) → Document → PSD structural equality.
|
||
|
||
use photocraft_doc::LayerContent;
|
||
use photocraft_io::*;
|
||
use photocraft_psd::testgen;
|
||
use photocraft_psd::{ColorMode, Compression, LayerNode, PsdFile, SectionType, Version};
|
||
|
||
/// Sample of a layer channel in document coordinates (0 outside the rect).
|
||
fn sample(f: &PsdFile, li: usize, id: i16, x: i32, y: i32, cache: &mut std::collections::HashMap<(usize, i16), Vec<u8>>) -> Vec<u8> {
|
||
let rec = &f.layers()[li];
|
||
let r = rec.channel_rect(id);
|
||
let bps = usize::from(f.header.depth / 8);
|
||
if x < r.left || x >= r.right || y < r.top || y >= r.bottom || rec.channel(id).is_none() {
|
||
let default = if id == -2 { rec.layer_mask().map_or(0, |m| m.default_color) } else { 0 };
|
||
return if default == 0 || bps != 1 { vec![0; bps] } else { vec![default] };
|
||
}
|
||
let plane = cache.entry((li, id)).or_insert_with(|| rec.decode_channel(id, f.header.depth, f.header.version).unwrap_or_default());
|
||
let w = (r.right - r.left) as usize;
|
||
let i = ((y - r.top) as usize * w + (x - r.left) as usize) * bps;
|
||
plane.get(i..i + bps).map(<[u8]>::to_vec).unwrap_or(vec![0; bps])
|
||
}
|
||
|
||
fn shape(f: &PsdFile, nodes: &[LayerNode]) -> String {
|
||
nodes
|
||
.iter()
|
||
.map(|n| {
|
||
let r = &f.layers()[n.index()];
|
||
let st = r.section_type();
|
||
let kids = if n.is_group() { format!("[{}]", shape(f, n.children())) } else { String::new() };
|
||
format!("{}{}{}", r.name(), if st == SectionType::ClosedFolder { "(c)" } else { "" }, kids)
|
||
})
|
||
.collect::<Vec<_>>()
|
||
.join(",")
|
||
}
|
||
|
||
fn leaves(nodes: &[LayerNode], out: &mut Vec<usize>) {
|
||
for n in nodes {
|
||
out.push(n.index());
|
||
leaves(n.children(), out);
|
||
}
|
||
}
|
||
|
||
fn assert_structurally_equal(a: &PsdFile, b: &PsdFile) {
|
||
assert_eq!(a.header.width, b.header.width);
|
||
assert_eq!(a.header.height, b.header.height);
|
||
assert_eq!(a.header.depth, b.header.depth);
|
||
assert_eq!(a.header.color_mode, b.header.color_mode);
|
||
let (ta, tb) = (a.layer_tree(), b.layer_tree());
|
||
assert_eq!(shape(a, &ta), shape(b, &tb), "tree shape");
|
||
let (mut la, mut lb) = (Vec::new(), Vec::new());
|
||
leaves(&ta, &mut la);
|
||
leaves(&tb, &mut lb);
|
||
let cc = testgen::mode_channels(a.header.color_mode) as i16;
|
||
let (mut ca, mut cb) = (Default::default(), Default::default());
|
||
for (&ia, &ib) in la.iter().zip(&lb) {
|
||
let (ra, rb) = (&a.layers()[ia], &b.layers()[ib]);
|
||
let n = ra.name();
|
||
assert_eq!(n, rb.name());
|
||
let blend = if matches!(ra.blend_mode, photocraft_psd::BlendMode::Unknown(_)) { photocraft_psd::BlendMode::Normal } else { ra.blend_mode };
|
||
if ra.section_type().is_folder() {
|
||
assert_eq!(ra.section_divider().and_then(|s| s.blend_mode), rb.section_divider().and_then(|s| s.blend_mode), "{n}");
|
||
} else {
|
||
assert_eq!(blend, rb.blend_mode, "{n}: blend");
|
||
}
|
||
assert_eq!(ra.opacity, rb.opacity, "{n}: opacity");
|
||
assert_eq!(ra.fill_opacity(), rb.fill_opacity(), "{n}: fill");
|
||
assert_eq!(ra.is_visible(), rb.is_visible(), "{n}: visible");
|
||
assert_eq!(ra.clipping, rb.clipping, "{n}: clipping");
|
||
assert_eq!(ra.block(b"lfx2").map(|x| &x.data), rb.block(b"lfx2").map(|x| &x.data), "{n}: lfx2");
|
||
if ra.section_type().is_folder() {
|
||
continue;
|
||
}
|
||
// Pixels: compare every channel over the union of both rects.
|
||
let u = |r: photocraft_psd::Rect, s: photocraft_psd::Rect| (r.left.min(s.left), r.top.min(s.top), r.right.max(s.right), r.bottom.max(s.bottom));
|
||
let mut ids: Vec<i16> = (-1..cc).collect();
|
||
if ra.layer_mask().is_some() && (ra.channel(-2).is_some() || ra.channel(-3).is_some()) {
|
||
ids.push(-2);
|
||
}
|
||
for id in ids {
|
||
// The exporter writes the real mask (-3) as the user mask (-2).
|
||
let src_id = if id == -2 && ra.channel(-3).is_some() && ra.layer_mask().is_some_and(|m| m.real.is_some()) { -3 } else { id };
|
||
let (x0, y0, x1, y1) = u(ra.channel_rect(src_id), rb.channel_rect(id));
|
||
for y in y0..y1 {
|
||
for x in x0..x1 {
|
||
let pa = sample(a, ia, src_id, x, y, &mut ca);
|
||
let pb = sample(b, ib, id, x, y, &mut cb);
|
||
assert_eq!(pa, pb, "{n}: channel {id} at ({x},{y})");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fn via_doc(f: &PsdFile) -> PsdFile {
|
||
let bytes = f.to_bytes().unwrap();
|
||
let imp = import("t.psd", &bytes).unwrap();
|
||
let out = export(&imp.document, "t.psd", &ExportOptions::default()).unwrap();
|
||
PsdFile::from_bytes(&out.bytes).unwrap()
|
||
}
|
||
|
||
macro_rules! tg {
|
||
($name:ident, $v:expr, $mode:expr, $depth:expr, $c:expr) => {
|
||
#[test]
|
||
fn $name() {
|
||
let f = testgen::layered($v, $mode, $depth, $c);
|
||
let g = via_doc(&f);
|
||
assert_structurally_equal(&f, &g);
|
||
}
|
||
};
|
||
}
|
||
|
||
tg!(testgen_rgb8_rle, Version::Psd, ColorMode::Rgb, 8, Compression::Rle);
|
||
tg!(testgen_rgb8_raw, Version::Psd, ColorMode::Rgb, 8, Compression::Raw);
|
||
tg!(testgen_rgb16_zip, Version::Psd, ColorMode::Rgb, 16, Compression::Zip);
|
||
tg!(testgen_rgb32_zipp, Version::Psd, ColorMode::Rgb, 32, Compression::ZipPrediction);
|
||
tg!(testgen_gray8_zipp, Version::Psd, ColorMode::Grayscale, 8, Compression::ZipPrediction);
|
||
tg!(testgen_gray16_rle, Version::Psd, ColorMode::Grayscale, 16, Compression::Rle);
|
||
tg!(testgen_gray32_raw, Version::Psd, ColorMode::Grayscale, 32, Compression::Raw);
|
||
tg!(testgen_cmyk8_rle, Version::Psd, ColorMode::Cmyk, 8, Compression::Rle);
|
||
tg!(testgen_cmyk16_zip, Version::Psd, ColorMode::Cmyk, 16, Compression::Zip);
|
||
tg!(testgen_lab8_rle, Version::Psd, ColorMode::Lab, 8, Compression::Rle);
|
||
tg!(testgen_psb_rgb8_rle, Version::Psb, ColorMode::Rgb, 8, Compression::Rle);
|
||
tg!(testgen_psb_cmyk16_zipp, Version::Psb, ColorMode::Cmyk, 16, Compression::ZipPrediction);
|
||
|
||
#[test]
|
||
fn testgen_small_all_compressions() {
|
||
for v in [Version::Psd, Version::Psb] {
|
||
for c in Compression::ALL {
|
||
let f = testgen::small(v, c);
|
||
assert_structurally_equal(&f, &via_doc(&f));
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn testgen_import_details() {
|
||
let f = testgen::layered(Version::Psd, ColorMode::Rgb, 8, Compression::Rle);
|
||
let (d, warnings) = psd_to_document(&f);
|
||
// Unknown blend mode warned about.
|
||
assert!(warnings.iter().any(|w| w.contains("unknown blend mode")), "{warnings:?}");
|
||
// Groups: Outer (open) containing Inner (closed).
|
||
let outer = d.layers.iter().find(|l| l.name == "Outer").expect("outer");
|
||
let LayerContent::Group(g) = &outer.content else { panic!() };
|
||
assert!(g.expanded);
|
||
assert_eq!(outer.blend, photocraft_color::BlendMode::PassThrough);
|
||
let inner = g.children.iter().find(|l| l.name == "Inner").unwrap();
|
||
let LayerContent::Group(gi) = &inner.content else { panic!() };
|
||
assert!(!gi.expanded);
|
||
// Negative offsets preserved.
|
||
let neg = d.layers.iter().find(|l| l.name.starts_with("Neg offset")).unwrap();
|
||
let b = neg.surface().unwrap().content_bounds();
|
||
assert!(b.x0 < 0 && b.y0 < 0);
|
||
assert!(neg.mask.is_some());
|
||
assert!(d.icc_profile.is_some());
|
||
assert!((d.resolution_dpi - 72.0).abs() < 1e-3);
|
||
assert!(d.metadata.xmp.is_some());
|
||
// Named saved-path resource (2000) becomes a document path, raw bytes kept for export.
|
||
assert!(d.paths.iter().any(|p| p.name == "Path 1" && p.psd_raw.as_deref() == Some(&vec![1u8, 2, 3])));
|
||
// Global blocks preserved.
|
||
assert!(d.metadata.psd_global_blocks.iter().any(|b| &b.1 == b"Patt"));
|
||
// lfx2 kept on effects.
|
||
assert!(d.walk().iter().any(|(_, _, l)| l.effects.psd_raw.is_some()));
|
||
}
|
||
|
||
#[test]
|
||
fn fallback_modes_import_flattened() {
|
||
for mode in [ColorMode::Indexed, ColorMode::Bitmap, ColorMode::Duotone] {
|
||
let depth = testgen::mode_depths(mode)[0];
|
||
let f = testgen::merged_only(Version::Psd, mode, depth, Compression::Rle, 9, 5);
|
||
let (d, w) = psd_to_document(&f);
|
||
assert_eq!(d.layers.len(), 1, "{mode:?}");
|
||
assert!(!w.is_empty(), "{mode:?} should warn");
|
||
assert_eq!(d.depth, photocraft_color::SampleType::U8);
|
||
// Re-export produces a valid PSD.
|
||
let out = export(&d, "x.psd", &ExportOptions::default()).unwrap();
|
||
assert!(PsdFile::from_bytes(&out.bytes).is_ok());
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn palette_and_duotone_modes_are_written_in_their_own_mode() {
|
||
// Indexed Color and Bitmap are written flat in their own mode (palette / 1 bit); a Duotone
|
||
// keeps the ink data it was imported with over its grey plate. Same image, same mode data.
|
||
for mode in [ColorMode::Indexed, ColorMode::Bitmap, ColorMode::Duotone] {
|
||
let depth = testgen::mode_depths(mode)[0];
|
||
let f = testgen::merged_only(Version::Psd, mode, depth, Compression::Rle, 9, 5);
|
||
let (d, _) = psd_to_document(&f);
|
||
let out = export(&d, "x.psd", &ExportOptions::default()).unwrap();
|
||
assert!(out.warnings.iter().all(|w| !w.contains("written as")), "{mode:?}: {:?}", out.warnings);
|
||
let back = PsdFile::from_bytes(&out.bytes).unwrap();
|
||
assert_eq!((back.header.color_mode, back.header.depth, back.header.channels), (mode, f.header.depth, 1), "{mode:?}");
|
||
assert_eq!(back.composite_rgba8().unwrap(), f.composite_rgba8().unwrap(), "{mode:?}");
|
||
if mode != ColorMode::Bitmap {
|
||
assert_eq!(back.color_mode_data, f.color_mode_data, "{mode:?}");
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_duotone_made_in_photocraft_is_written_with_its_inks() {
|
||
use photocraft_doc::{Duotone, DuotoneInk};
|
||
let mut d = photocraft_doc::Document::with_background(
|
||
"d",
|
||
photocraft_doc::Size::new(4, 2),
|
||
photocraft_color::ColorMode::Grayscale,
|
||
photocraft_color::SampleType::U8,
|
||
photocraft_color::Color::rgba(0.5, 0.5, 0.5, 1.0),
|
||
);
|
||
d.mode = photocraft_color::ColorMode::Duotone;
|
||
d.duotone = Some(Duotone { inks: vec![DuotoneInk::new("Black", [0.0; 3]), DuotoneInk::new("Orange", [1.0, 0.5, 0.0])], psd_raw: None });
|
||
let out = export(&d, "x.psd", &ExportOptions::default()).unwrap();
|
||
assert!(out.warnings.iter().all(|w| !w.contains("written as")), "{:?}", out.warnings);
|
||
let f = PsdFile::from_bytes(&out.bytes).unwrap();
|
||
assert_eq!((f.header.color_mode, f.color_mode_data.len()), (ColorMode::Duotone, 524));
|
||
let (back, w) = psd_to_document(&f);
|
||
assert!(w.iter().all(|w| !w.contains("duotone")), "{w:?}");
|
||
let inks = &back.duotone.as_ref().unwrap().inks;
|
||
assert_eq!(inks.iter().map(|i| i.name.as_str()).collect::<Vec<_>>(), ["Black", "Orange"]);
|
||
assert!((inks[1].color[1] - 0.5).abs() < 1e-4, "{:?}", inks[1].color);
|
||
// Re-exported unedited, the block is the one read.
|
||
let again = PsdFile::from_bytes(&export(&back, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||
assert_eq!(again.color_mode_data, f.color_mode_data);
|
||
}
|
||
|
||
fn duotone_doc(sample: photocraft_color::SampleType) -> photocraft_doc::Document {
|
||
use photocraft_doc::{Duotone, DuotoneInk};
|
||
let mut d = photocraft_doc::Document::with_background(
|
||
"d",
|
||
photocraft_doc::Size::new(4, 2),
|
||
photocraft_color::ColorMode::Grayscale,
|
||
sample,
|
||
photocraft_color::Color::rgba(0.5, 0.5, 0.5, 1.0),
|
||
);
|
||
d.mode = photocraft_color::ColorMode::Duotone;
|
||
d.duotone = Some(Duotone { inks: vec![DuotoneInk::new("Black", [0.0; 3]), DuotoneInk::new("Orange", [1.0, 0.5, 0.0])], psd_raw: None });
|
||
d
|
||
}
|
||
|
||
#[test]
|
||
fn a_16_bit_duotone_is_written_as_grayscale() {
|
||
// Photoshop has only 8-bit Duotone and refuses to open a 16-bit Duotone PSD.
|
||
let out = export(&duotone_doc(photocraft_color::SampleType::U16), "x.psd", &ExportOptions::default()).unwrap();
|
||
let f = PsdFile::from_bytes(&out.bytes).unwrap();
|
||
assert_eq!((f.header.color_mode, f.header.depth), (ColorMode::Grayscale, 16));
|
||
assert!(out.warnings.iter().any(|w| w == "Duotone document written as Grayscale"), "{:?}", out.warnings);
|
||
}
|
||
|
||
#[test]
|
||
fn a_layered_duotone_keeps_its_image_without_maximize_compatibility() {
|
||
// A Duotone opens flattened from its merged image (its layer records are not read), so that
|
||
// image is written in full even when Maximize Compatibility is off, never the placeholder.
|
||
let mut d = duotone_doc(photocraft_color::SampleType::U8);
|
||
let mut top = photocraft_doc::Layer::new("Dark", LayerContent::Raster(photocraft_raster::Surface::new(d.pixel_format())));
|
||
if let LayerContent::Raster(s) = &mut top.content {
|
||
s.fill_rect(photocraft_geom::Rect::new(0, 0, 2, 2), &photocraft_raster::from_rgba(&d.pixel_format(), [0.0, 0.0, 0.0, 1.0]));
|
||
}
|
||
d.layers.push(top);
|
||
let opts = ExportOptions { psd_maximize_compatibility: false, ..Default::default() };
|
||
let f = PsdFile::from_bytes(&export(&d, "x.psd", &opts).unwrap().bytes).unwrap();
|
||
assert_eq!(f.header.color_mode, ColorMode::Duotone);
|
||
assert!(!f.layers().is_empty(), "the layers are still written");
|
||
// The grey plate: the left half black (the top layer), the right half the 50% grey background.
|
||
let plate: Vec<u8> = f.decode_merged().unwrap();
|
||
assert_eq!(plate.len(), 8);
|
||
assert!(plate[..2].iter().chain(&plate[4..6]).all(|&v| v == 0), "{plate:?}");
|
||
assert!(plate[2..4].iter().chain(&plate[6..]).all(|&v| (127..=128).contains(&v)), "{plate:?}");
|
||
let (back, _) = psd_to_document(&f);
|
||
let maxed = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||
assert_eq!(f.decode_merged().unwrap(), maxed.decode_merged().unwrap());
|
||
assert_eq!(back.duotone.as_ref().map(|x| x.inks.len()), Some(2));
|
||
}
|
||
|
||
#[test]
|
||
fn a_colour_book_ink_shows_its_lab_equivalent_or_the_grey_plate() {
|
||
let out = export(&duotone_doc(photocraft_color::SampleType::U8), "x.psd", &ExportOptions::default()).unwrap();
|
||
let mut f = PsdFile::from_bytes(&out.bytes).unwrap();
|
||
// The second ink becomes PANTONE 327 CVC, stored by its book key as Photoshop does.
|
||
f.color_mode_data[14..24].copy_from_slice(&[0, 3, 0x20, 0x20, 0x33, 0x32, 0x37, 0x43, 0, 0]);
|
||
// Without resource 1066 its colour is unknown: the grey plate, not a black ink.
|
||
let (d, w) = psd_to_document(&f);
|
||
let inks = &d.duotone.as_ref().unwrap().inks;
|
||
assert_eq!((inks.len(), inks[0].color), (1, [0.0; 3]), "{inks:?}");
|
||
assert!(w.iter().any(|w| w.contains("shown as grayscale")), "{w:?}");
|
||
// With Photoshop's Lab equivalents (black, then the PANTONE teal) it shows in colour.
|
||
let lab = vec![0, 1, 0, 2, 0, 7, 0x03, 0xB9, 0x01, 0xF2, 0x01, 0xA9, 0, 0, 0, 7, 0x11, 0xC5, 0xE6, 0x38, 0xFE, 0x0C, 0, 0];
|
||
f.resources.push(photocraft_psd::ImageResource::new(1066, lab));
|
||
let (d, w) = psd_to_document(&f);
|
||
assert!(w.iter().all(|w| !w.contains("duotone")), "{w:?}");
|
||
let duo = d.duotone.as_ref().unwrap();
|
||
assert_eq!(duo.inks.len(), 2);
|
||
let [r, g, b] = duo.inks[1].color;
|
||
assert!(r < 0.1 && g > 0.4 && b > 0.35, "{:?}", duo.inks[1].color);
|
||
assert_eq!(duo.psd_raw.as_deref(), Some(f.color_mode_data.as_slice()), "the block is kept raw");
|
||
}
|
||
|
||
#[test]
|
||
fn a_layered_duotone_tiff_says_its_inks_are_dropped() {
|
||
// TIFF has no Duotone: a layered one is written in its grey plate, and says so.
|
||
let mut d = duotone_doc(photocraft_color::SampleType::U8);
|
||
let adj = d.duotone.as_ref().unwrap().display_adjustment();
|
||
d.layers.push(photocraft_doc::Layer::new("Map", LayerContent::Adjustment(adj)));
|
||
let out = export(&d, "x.tif", &ExportOptions { tiff_layers: true, ..Default::default() }).unwrap();
|
||
assert!(out.warnings.iter().any(|w| w == "Duotone document written as Grayscale"), "{:?}", out.warnings);
|
||
}
|
||
|
||
#[test]
|
||
fn indexed_psd_keeps_the_table_length_and_transparent_entry() {
|
||
use photocraft_doc::ColorTable;
|
||
let mut d = photocraft_doc::Document::with_background(
|
||
"i",
|
||
photocraft_doc::Size::new(4, 2),
|
||
photocraft_color::ColorMode::Rgb,
|
||
photocraft_color::SampleType::U8,
|
||
photocraft_color::Color::rgba(1.0, 0.0, 0.0, 1.0),
|
||
);
|
||
d.mode = photocraft_color::ColorMode::Indexed;
|
||
d.color_table = Some(ColorTable { colors: vec![[0, 0, 0], [255, 0, 0], [0, 0, 255]], transparent: Some(2) });
|
||
let out = export(&d, "x.psd", &ExportOptions::default()).unwrap();
|
||
let f = PsdFile::from_bytes(&out.bytes).unwrap();
|
||
assert_eq!(f.header.color_mode, ColorMode::Indexed);
|
||
assert_eq!(f.resource(1046).map(|r| r.data.clone()), Some(vec![0, 3]));
|
||
assert_eq!(f.resource(1047).map(|r| r.data.clone()), Some(vec![0, 2]));
|
||
assert_eq!(f.decode_merged().unwrap(), vec![1; 8], "every pixel is entry 1 (red)");
|
||
let (back, _) = psd_to_document(&f);
|
||
assert_eq!(back.color_table, d.color_table);
|
||
}
|
||
|
||
/// A 4x2 document filled with `fill` in Indexed Color mode with `colors` and `transparent`.
|
||
fn indexed_doc(fill: photocraft_color::Color, colors: Vec<[u8; 3]>, transparent: Option<u8>) -> photocraft_doc::Document {
|
||
let mut d = photocraft_doc::Document::with_background(
|
||
"i",
|
||
photocraft_doc::Size::new(4, 2),
|
||
photocraft_color::ColorMode::Rgb,
|
||
photocraft_color::SampleType::U8,
|
||
fill,
|
||
);
|
||
d.mode = photocraft_color::ColorMode::Indexed;
|
||
d.color_table = Some(photocraft_doc::ColorTable { colors, transparent });
|
||
d
|
||
}
|
||
|
||
#[test]
|
||
fn indexed_psd_never_writes_an_opaque_pixel_as_the_transparent_entry() {
|
||
// The transparent entry duplicates opaque black (common in PNG-8 files) and comes first.
|
||
let d = indexed_doc(photocraft_color::Color::rgba(0.0, 0.0, 0.0, 1.0), vec![[0, 0, 0], [0, 0, 0], [255, 255, 255]], Some(0));
|
||
let f = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||
assert_eq!(f.decode_merged().unwrap(), vec![1; 8], "opaque black is entry 1, not the transparent entry 0");
|
||
}
|
||
|
||
#[test]
|
||
fn indexed_psd_transparency_survives_a_round_trip() {
|
||
let d = indexed_doc(photocraft_color::Color::rgba(1.0, 0.0, 0.0, 0.0), vec![[255, 0, 0], [255, 255, 255]], Some(1));
|
||
let out = export(&d, "x.psd", &ExportOptions::default()).unwrap();
|
||
let f = PsdFile::from_bytes(&out.bytes).unwrap();
|
||
assert_eq!(f.decode_merged().unwrap(), vec![1; 8]);
|
||
let back = import("x.psd", &out.bytes).unwrap().document;
|
||
let s = back.layers[0].surface().unwrap();
|
||
let px = s.read_region(back.bounds());
|
||
assert!(px.chunks_exact(s.channels()).all(|p| p.last() == Some(&0.0)), "transparent pixels came back opaque");
|
||
assert!(!back.layers[0].locks.transparency);
|
||
}
|
||
|
||
#[test]
|
||
fn indexed_psd_without_a_transparent_entry_flattens_over_white() {
|
||
let d = indexed_doc(photocraft_color::Color::rgba(0.0, 0.0, 0.0, 0.0), vec![[0, 0, 0], [255, 255, 255]], None);
|
||
let f = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||
assert_eq!(f.decode_merged().unwrap(), vec![1; 8], "empty areas are white, as Photoshop flattens them");
|
||
}
|
||
|
||
#[test]
|
||
fn indexed_psd_keeps_the_icc_profile() {
|
||
// Bytes 16..20 of a profile name its colour space; only an RGB one describes the table.
|
||
let profile = |space: &[u8; 4]| {
|
||
let mut p = vec![7u8; 132];
|
||
p[16..20].copy_from_slice(space);
|
||
p
|
||
};
|
||
for (space, kept) in [(b"RGB ", true), (b"GRAY", false)] {
|
||
let mut d = indexed_doc(photocraft_color::Color::rgba(1.0, 0.0, 0.0, 1.0), vec![[255, 0, 0]], None);
|
||
d.icc_profile = Some(std::sync::Arc::new(profile(space)));
|
||
let f = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||
assert_eq!(f.header.color_mode, ColorMode::Indexed);
|
||
assert_eq!(f.resource(1039).map(|r| r.data.clone()), kept.then(|| profile(space)));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn indexed_document_whose_only_entry_is_transparent_is_not_written_as_indexed() {
|
||
// Every opaque pixel would have to take the transparent entry and vanish: written as RGB.
|
||
let d = indexed_doc(photocraft_color::Color::rgba(1.0, 0.0, 0.0, 1.0), vec![[255, 0, 0]], Some(0));
|
||
let out = export(&d, "x.psd", &ExportOptions::default()).unwrap();
|
||
let f = PsdFile::from_bytes(&out.bytes).unwrap();
|
||
assert_eq!(f.header.color_mode, ColorMode::Rgb, "{:?}", out.warnings);
|
||
assert_eq!(&f.composite_rgba8().unwrap().data[..4], &[255, 0, 0, 255]);
|
||
}
|
||
|
||
#[test]
|
||
fn indexed_save_warns_whenever_flattening_loses_a_layer() {
|
||
// One plain visible pixel layer is the merged image: no warning. A single hidden, masked,
|
||
// half-opaque or type layer, or a second layer, is lost in the flat file, so the save says so.
|
||
let flat = |d: &photocraft_doc::Document| {
|
||
let out = export(d, "x.psd", &ExportOptions::default()).unwrap();
|
||
assert_eq!(PsdFile::from_bytes(&out.bytes).unwrap().header.color_mode, ColorMode::Indexed);
|
||
out.warnings.iter().any(|w| w.contains("written flattened"))
|
||
};
|
||
let plain = indexed_doc(photocraft_color::Color::rgba(1.0, 1.0, 1.0, 1.0), vec![[255, 255, 255], [0, 0, 0]], None);
|
||
assert!(!flat(&plain), "a single plain layer flattens losslessly");
|
||
let changes: [fn(&mut photocraft_doc::Layer); 4] = [
|
||
|l| l.visible = false,
|
||
|l| l.opacity = 0.5,
|
||
|l| l.mask = Some(photocraft_doc::LayerMask::hide_all()),
|
||
|l| l.content = LayerContent::Text(photocraft_doc::TextLayer::default()),
|
||
];
|
||
for (i, change) in changes.iter().enumerate() {
|
||
let mut d = plain.clone();
|
||
change(&mut d.layers[0]);
|
||
assert!(flat(&d), "change {i} is lost when flattened, so the save warns");
|
||
}
|
||
let mut two = plain.clone();
|
||
two.layers.push(two.layers[0].clone());
|
||
assert!(flat(&two));
|
||
}
|
||
|
||
#[test]
|
||
fn indexed_and_bitmap_documents_with_channels_keep_them() {
|
||
// PhotoCraft does not read extra channels of a palette-mode PSD back, so these documents keep
|
||
// the RGB / Grayscale file that holds their alpha channels and Quick Mask.
|
||
for mode in [photocraft_color::ColorMode::Indexed, photocraft_color::ColorMode::Bitmap] {
|
||
let mut d = indexed_doc(photocraft_color::Color::rgba(1.0, 1.0, 1.0, 1.0), vec![[255, 255, 255]], None);
|
||
d.mode = mode;
|
||
let alpha = || photocraft_doc::AlphaChannel::new("Alpha 1", photocraft_raster::Surface::new(photocraft_color::PixelFormat::GRAY8));
|
||
d.channels.push(alpha());
|
||
d.quick_mask = Some(alpha());
|
||
let out = export(&d, "x.psd", &ExportOptions::default()).unwrap();
|
||
let back = import("x.psd", &out.bytes).unwrap().document;
|
||
assert_eq!((back.channels.len(), back.quick_mask.is_some()), (1, true), "{mode:?}: {:?}", out.warnings);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn layerless_psd_never_keeps_psb_only_block_signatures() {
|
||
for mode in [photocraft_color::ColorMode::Indexed, photocraft_color::ColorMode::Bitmap, photocraft_color::ColorMode::Multichannel] {
|
||
let mut d = indexed_doc(photocraft_color::Color::rgba(1.0, 1.0, 1.0, 1.0), vec![[255, 255, 255]], None);
|
||
d.mode = mode;
|
||
d.metadata.psd_global_blocks.push((*b"8B64", *b"FMsk", std::sync::Arc::new(vec![0; 12])));
|
||
let f = PsdFile::from_bytes(&export(&d, "x.psd", &ExportOptions::default()).unwrap().bytes).unwrap();
|
||
assert!(f.global_blocks.iter().any(|b| &b.key == b"FMsk"), "{mode:?}");
|
||
assert!(f.global_blocks.iter().all(|b| &b.signature == b"8BIM"), "{mode:?}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn multichannel_imports_ink_channels() {
|
||
for depth in testgen::mode_depths(ColorMode::Multichannel) {
|
||
let f = testgen::merged_only(Version::Psd, ColorMode::Multichannel, *depth, Compression::Rle, 9, 5);
|
||
let (d, _) = psd_to_document(&f);
|
||
assert_eq!(d.mode, photocraft_color::ColorMode::Multichannel);
|
||
assert!(d.layers.is_empty());
|
||
assert_eq!(d.channels.len(), usize::from(f.header.channels));
|
||
assert!(d.channels.iter().all(|c| c.spot.is_some()));
|
||
// Stored dark = ink: the channel value is 1 − the stored sample.
|
||
let merged = f.decode_merged().unwrap();
|
||
let first = if *depth == 8 { f32::from(merged[0]) / 255.0 } else { f32::from(u16::from_be_bytes([merged[0], merged[1]])) / 65535.0 };
|
||
assert!((d.channels[0].surface.pixel(0, 0)[0] - (1.0 - first)).abs() < 1e-3);
|
||
// Re-export writes the same planes.
|
||
let out = export(&d, "x.psd", &ExportOptions::default()).unwrap();
|
||
let back = PsdFile::from_bytes(&out.bytes).unwrap();
|
||
assert_eq!(back.header.color_mode, ColorMode::Multichannel);
|
||
assert_eq!(back.decode_merged().unwrap(), merged, "{depth}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn indexed_colors_match_palette() {
|
||
let f = testgen::merged_only(Version::Psd, ColorMode::Indexed, 8, Compression::Raw, 4, 2);
|
||
let (d, _) = psd_to_document(&f);
|
||
let s = d.layers[0].surface().unwrap();
|
||
let rgba = f.composite_rgba8().unwrap();
|
||
for y in 0..2 {
|
||
for x in 0..4 {
|
||
let p = s.pixel(x, y);
|
||
let i = ((y * 4 + x) * 4) as usize;
|
||
for (c, v) in p.iter().take(3).enumerate() {
|
||
assert_eq!((v * 255.0).round() as u8, rgba.data[i + c]);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn flattened_psd_becomes_background() {
|
||
let f = testgen::merged_only(Version::Psd, ColorMode::Rgb, 16, Compression::ZipPrediction, 7, 3);
|
||
let (d, _) = psd_to_document(&f);
|
||
assert_eq!(d.layers.len(), 1);
|
||
assert_eq!(d.layers[0].name, "Background");
|
||
assert_eq!(d.depth, photocraft_color::SampleType::U16);
|
||
let s = d.layers[0].surface().unwrap();
|
||
let merged = f.decode_merged().unwrap();
|
||
let v = u16::from_be_bytes([merged[0], merged[1]]);
|
||
assert_eq!((s.pixel(0, 0)[0] * 65535.0).round() as u16, v);
|
||
}
|
||
|
||
#[test]
|
||
fn builder_psd_imports() {
|
||
use photocraft_psd::{GroupSpec, LayerSpec, MaskSpec, PixelData, PsdBuilder, Rect};
|
||
let mut b = PsdBuilder::new(4, 4);
|
||
let mut s = LayerSpec::new("a", 0, 0, 2, 2, PixelData::Rgba8(vec![255, 0, 0, 255, 0, 255, 0, 128, 0, 0, 255, 0, 9, 9, 9, 9]));
|
||
s.mask = Some(MaskSpec { rect: Rect::from_xywh(0, 0, 1, 1), data: vec![100], default_color: 255, disabled: true });
|
||
s.fill_opacity = Some(77);
|
||
b.push_layer(s);
|
||
b.begin_group(GroupSpec { open: false, ..GroupSpec::new("G") });
|
||
b.push_layer(LayerSpec::new("b", 1, 1, 1, 1, PixelData::Rgba8(vec![1, 2, 3, 4])));
|
||
b.end_group().unwrap();
|
||
let bytes = b.to_bytes().unwrap();
|
||
let d = import("b.psd", &bytes).unwrap().document;
|
||
assert_eq!(d.layers.len(), 2);
|
||
let a = &d.layers[0];
|
||
assert_eq!(a.fill_opacity, 77.0 / 255.0);
|
||
let m = a.mask.as_ref().unwrap();
|
||
assert!(!m.enabled);
|
||
assert_eq!(m.surface.pixel(0, 0), vec![100.0 / 255.0]);
|
||
assert_eq!(m.surface.pixel(3, 3), vec![1.0]);
|
||
let s = a.surface().unwrap();
|
||
assert_eq!(s.pixel(1, 0), vec![0.0, 1.0, 0.0, 128.0 / 255.0]);
|
||
assert!(matches!(&d.layers[1].content, LayerContent::Group(g) if !g.expanded && g.children.len() == 1));
|
||
}
|
||
|
||
#[test]
|
||
fn group_nesting_past_the_cap_is_rejected() {
|
||
use photocraft_psd::{GroupSpec, LayerSpec, PixelData, PsdBuilder};
|
||
let mut b = PsdBuilder::new(4, 4);
|
||
for _ in 0..101 {
|
||
b.begin_group(GroupSpec::new("g"));
|
||
}
|
||
b.push_layer(LayerSpec::new("deep", 0, 0, 1, 1, PixelData::Rgba8(vec![1, 2, 3, 4])));
|
||
for _ in 0..101 {
|
||
b.end_group().unwrap();
|
||
}
|
||
let bytes = b.to_bytes().unwrap();
|
||
let err = import("deep.psd", &bytes).unwrap_err();
|
||
assert!(err.to_string().contains("nested deeper than 100"), "{err}");
|
||
// The never-fail converter still returns a document — capped, with a warning
|
||
// about the part that was not imported (it used to recurse to the file's depth).
|
||
let (d, warnings) = psd_to_document(&photocraft_psd::PsdFile::from_bytes(&bytes).unwrap());
|
||
assert_eq!(d.max_group_depth(), 100);
|
||
assert!(warnings.iter().any(|w| w.contains("deeper than 100")), "{warnings:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn group_nesting_at_the_cap_imports() {
|
||
use photocraft_psd::{GroupSpec, LayerSpec, PixelData, PsdBuilder};
|
||
let mut b = PsdBuilder::new(4, 4);
|
||
for _ in 0..100 {
|
||
b.begin_group(GroupSpec::new("g"));
|
||
}
|
||
b.push_layer(LayerSpec::new("deep", 0, 0, 1, 1, PixelData::Rgba8(vec![1, 2, 3, 4])));
|
||
for _ in 0..100 {
|
||
b.end_group().unwrap();
|
||
}
|
||
let r = import("hundred.psd", &b.to_bytes().unwrap()).unwrap();
|
||
assert_eq!(r.document.max_group_depth(), 100);
|
||
assert!(!r.warnings.iter().any(|w| w.contains("deeper")), "{:?}", r.warnings);
|
||
}
|
||
|
||
#[test]
|
||
fn layered_import_skips_the_unused_merged_composite() {
|
||
use photocraft_psd::{LayerSpec, PixelData, PsdBuilder};
|
||
// A layered file whose merged composite carries nothing behind the colour channels never
|
||
// decodes it - that composite is roughly half a Photoshop save's bytes. Here its data is
|
||
// garbage: the layered import does not touch it, while a file with an extra channel does
|
||
// decode (and warn), which is what makes the skip observable.
|
||
let mut b = PsdBuilder::new(4, 4);
|
||
b.push_layer(LayerSpec::new("a", 0, 0, 2, 2, PixelData::Rgba8(vec![7u8; 16])));
|
||
b.composite(PixelData::Rgba8(vec![128; 64]));
|
||
let mut f = photocraft_psd::PsdFile::from_bytes(&b.to_bytes().unwrap()).unwrap();
|
||
f.image_data.data.clear();
|
||
f.layer_info.as_mut().unwrap().merged_alpha = true;
|
||
let (d, warnings) = psd_to_document(&f);
|
||
assert_eq!(d.layers.len(), 1);
|
||
assert!(!warnings.iter().any(|w| w.contains("merged image")), "an unused composite is not decoded: {warnings:?}");
|
||
// A real extra channel behind the colour ones is part of the composite: it is decoded,
|
||
// and the garbage above surfaces as a warning.
|
||
f.layer_info.as_mut().unwrap().merged_alpha = false;
|
||
let (_, warnings) = psd_to_document(&f);
|
||
assert!(warnings.iter().any(|w| w.contains("merged image")), "{warnings:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn locks_and_labels_from_psd() {
|
||
use photocraft_psd::TaggedBlock;
|
||
let mut f = testgen::small(Version::Psd, Compression::Raw);
|
||
f.layers_mut()[0].blocks.push(TaggedBlock::protection(0b101));
|
||
f.layers_mut()[0].blocks.push(TaggedBlock::sheet_color(3));
|
||
let (d, _) = psd_to_document(&f);
|
||
let l = &d.layers[0];
|
||
assert!(l.locks.transparency && l.locks.position && !l.locks.pixels);
|
||
assert_eq!(l.label, photocraft_doc::LabelColor::Yellow);
|
||
}
|
||
|
||
#[test]
|
||
fn artboard_lock_round_trips_byte_for_byte() {
|
||
// Reference files write a Background layer's locks as lspf = 0x0D (transparency + position +
|
||
// the bit-3 lock). PhotoCraft read the artboard lock at bit 4, so 0x0D came back as 0x05
|
||
// and the lock bytes drifted on every re-save.
|
||
use photocraft_psd::TaggedBlock;
|
||
let mut f = testgen::small(Version::Psd, Compression::Raw);
|
||
f.layers_mut()[0].blocks.push(TaggedBlock::protection(0x0D));
|
||
let g = via_doc(&f);
|
||
let lspf = g.layers().iter().find_map(|l| l.block(b"lspf")).expect("exported layer should carry an lspf block");
|
||
assert_eq!(lspf.data, 0x0Du32.to_be_bytes());
|
||
}
|
||
|
||
#[test]
|
||
fn adjustment_and_fill_layers_from_psd() {
|
||
use photocraft_psd::{LayerRecord, TaggedBlock};
|
||
let mut f = testgen::small(Version::Psd, Compression::Raw);
|
||
let mk = |name: &str, key: &[u8; 4], data: Vec<u8>| LayerRecord {
|
||
name: name.as_bytes().to_vec(),
|
||
blocks: vec![TaggedBlock::unicode_name(name), TaggedBlock::new(*key, data)],
|
||
..Default::default()
|
||
};
|
||
let soco = {
|
||
use photocraft_psd::descriptor::*;
|
||
let c = Descriptor::new("RGBC").with("Rd ", Value::Double(255.0)).with("Grn ", Value::Double(0.0)).with("Bl ", Value::Double(0.0));
|
||
VersionedDescriptor::new(Descriptor::new("null").with("Clr ", Value::Descriptor(c))).to_bytes()
|
||
};
|
||
f.layers_mut().push(mk("inv", b"nvrt", vec![]));
|
||
f.layers_mut().push(mk("post", b"post", vec![0, 4, 0, 0]));
|
||
f.layers_mut().push(mk("sel", b"selc", vec![1, 2, 3]));
|
||
f.layers_mut().push(mk("red", b"SoCo", soco));
|
||
let (d, _) = psd_to_document(&f);
|
||
let n = d.layers.len();
|
||
use photocraft_doc::{Adjustment, Fill};
|
||
assert!(matches!(d.layers[n - 4].content, LayerContent::Adjustment(Adjustment::Invert)));
|
||
assert!(matches!(d.layers[n - 3].content, LayerContent::Adjustment(Adjustment::Posterize { levels: 4 })));
|
||
assert!(matches!(&d.layers[n - 2].content, LayerContent::Adjustment(Adjustment::Unsupported { psd_key, raw }) if psd_key == "selc" && **raw == [1, 2, 3]));
|
||
assert!(matches!(&d.layers[n - 1].content, LayerContent::Fill(Fill::Solid(c)) if c.c[0] == 1.0));
|
||
}
|
||
|
||
#[test]
|
||
fn unmodelled_blocks_are_preserved() {
|
||
use photocraft_psd::TaggedBlock;
|
||
let mut f = testgen::small(Version::Psd, Compression::Raw);
|
||
f.layers_mut()[0].blocks.push(TaggedBlock::new(*b"vmsk", vec![0; 8]));
|
||
let (d, _) = psd_to_document(&f);
|
||
assert!(d.layers[0].psd_blocks.iter().any(|b| &b.0 == b"vmsk"));
|
||
assert!(!d.layers[0].psd_blocks.iter().any(|b| &b.0 == b"luni" || &b.0 == b"lyid"));
|
||
assert_eq!(d.layers[0].psd_id, f.layers()[0].layer_id());
|
||
}
|
||
|
||
#[test]
|
||
fn fill_layer_keeps_photoshop_pixels() {
|
||
use photocraft_psd::{ChannelData, LayerRecord, TaggedBlock};
|
||
let mut f = testgen::small(Version::Psd, Compression::Raw);
|
||
let soco = {
|
||
use photocraft_psd::descriptor::*;
|
||
let c = Descriptor::new("RGBC").with("Rd ", Value::Double(255.0)).with("Grn ", Value::Double(0.0)).with("Bl ", Value::Double(0.0));
|
||
VersionedDescriptor::new(Descriptor::new("null").with("Clr ", Value::Descriptor(c))).to_bytes()
|
||
};
|
||
// Photoshop-rendered pixels deliberately differ from the parametric fill.
|
||
let plane = |v: u8| ChannelData { id: 0, compression: Some(Compression::Raw), data: vec![v; 4] };
|
||
let mut chans = vec![plane(255), plane(10), plane(20), plane(30)];
|
||
for (i, c) in chans.iter_mut().enumerate() {
|
||
c.id = i as i16 - 1;
|
||
}
|
||
f.layers_mut().push(LayerRecord {
|
||
rect: photocraft_psd::Rect::from_xywh(0, 0, 2, 2),
|
||
name: b"fill".to_vec(),
|
||
channels: chans,
|
||
blocks: vec![TaggedBlock::unicode_name("fill"), TaggedBlock::new(*b"SoCo", soco)],
|
||
..Default::default()
|
||
});
|
||
let (d, _) = psd_to_document(&f);
|
||
let l = d.layers.last().unwrap();
|
||
assert!(matches!(l.content, LayerContent::Fill(_)));
|
||
let fc = l.fill_cache.as_ref().expect("cache");
|
||
assert_eq!(fc.surface.pixel(1, 1), vec![10.0 / 255.0, 20.0 / 255.0, 30.0 / 255.0, 1.0]);
|
||
// The compositor uses the cache while the fill is unchanged.
|
||
let flat = photocraft_compose::flatten(&d);
|
||
let p = flat.get(1, 1);
|
||
assert!((p[0] - 10.0 / 255.0).abs() < 1e-3, "{p:?}");
|
||
// Editing the fill invalidates the cache.
|
||
let mut d2 = d.clone();
|
||
let n = d2.layers.len();
|
||
d2.layers[n - 1].content = LayerContent::Fill(photocraft_doc::Fill::Solid(photocraft_color::Color::rgb(0.0, 0.0, 1.0)));
|
||
let p = photocraft_compose::flatten(&d2).get(1, 1);
|
||
assert!(p[2] > 0.99 && p[0] < 0.01, "{p:?}");
|
||
}
|
||
|
||
#[test]
|
||
fn text_and_smart_detected() {
|
||
use photocraft_psd::{LayerRecord, TaggedBlock};
|
||
let mut f = testgen::small(Version::Psd, Compression::Raw);
|
||
for (name, key) in [("t", b"TySh"), ("s", b"PlLd"), ("sh", b"vscg")] {
|
||
// Type identification needs readable type data, not an eight-byte malformed stub.
|
||
let data = if key == b"TySh" { photocraft_text::psd::build_tysh(&photocraft_doc::TextLayer::default(), 72.0, None) } else { vec![0; 8] };
|
||
f.layers_mut().push(LayerRecord {
|
||
name: name.as_bytes().to_vec(),
|
||
blocks: vec![TaggedBlock::unicode_name(name), TaggedBlock::new(*key, data)],
|
||
..Default::default()
|
||
});
|
||
}
|
||
let (d, _) = psd_to_document(&f);
|
||
let n = d.layers.len();
|
||
assert_eq!(d.layers[n - 3].content.kind_name(), "Type");
|
||
assert_eq!(d.layers[n - 2].content.kind_name(), "Smart Object");
|
||
assert_eq!(d.layers[n - 1].content.kind_name(), "Shape");
|
||
}
|
||
|
||
#[test]
|
||
fn guides_and_alpha_names_roundtrip() {
|
||
let mut d = photocraft_doc::Document::new("g", photocraft_geom::Size::new(8, 8), photocraft_color::ColorMode::Rgb, photocraft_color::SampleType::U8);
|
||
d.guides.horizontal = vec![1.0, 2.5];
|
||
d.guides.vertical = vec![7.03125];
|
||
for name in ["Spot \u{e9}", "Alpha 2"] {
|
||
d.channels.push(photocraft_doc::AlphaChannel::new(
|
||
name,
|
||
photocraft_raster::Surface::new(photocraft_color::PixelFormat::new(
|
||
photocraft_color::ColorMode::Grayscale,
|
||
photocraft_color::SampleType::U8,
|
||
false,
|
||
)),
|
||
));
|
||
}
|
||
let f = document_to_psd(&d);
|
||
// RGB + merged transparency (the empty document is transparent) + 2 alpha channels.
|
||
assert_eq!(f.header.channels, 6);
|
||
let (d2, _) = psd_to_document(&f);
|
||
assert_eq!(d2.guides, d.guides);
|
||
let names: Vec<_> = d2.channels.iter().map(|c| c.name.as_str()).collect();
|
||
assert_eq!(names, ["Spot \u{e9}", "Alpha 2"]);
|
||
}
|
||
|
||
#[test]
|
||
fn resolution_info_cm_display_unit_stays_pixels_per_inch() {
|
||
let mut f = testgen::small(Version::Psd, Compression::Raw);
|
||
let mut ri = photocraft_psd::ResolutionInfo::from_dpi(300.0);
|
||
ri.h_res_unit = 2;
|
||
ri.v_res_unit = 2;
|
||
f.resources[0] = photocraft_psd::ImageResource::new(1005, ri.to_bytes());
|
||
let (d, _) = psd_to_document(&f);
|
||
assert!((d.resolution_dpi - 300.0).abs() < 1e-3, "{}", d.resolution_dpi);
|
||
let out = document_to_psd(&d);
|
||
let written = out.resolution().unwrap();
|
||
assert!((written.h_res() - 300.0).abs() < 1e-3, "{}", written.h_res());
|
||
assert!((written.v_res() - 300.0).abs() < 1e-3, "{}", written.v_res());
|
||
}
|
||
|
||
#[test]
|
||
fn stale_index_resources_dropped() {
|
||
let mut f = testgen::small(Version::Psd, Compression::Raw);
|
||
for id in [1024u16, 1026, 1036, 1057, 1069, 1072] {
|
||
f.resources.push(photocraft_psd::ImageResource::new(id, vec![0, 0]));
|
||
}
|
||
let (d, _) = psd_to_document(&f);
|
||
assert!(d.metadata.psd_resources.iter().all(|r| ![1024u16, 1026, 1036, 1057, 1069, 1072].contains(&r.0)));
|
||
}
|
||
|
||
#[test]
|
||
fn merged_composite_unmattes_photoshop_white() {
|
||
// A 1x1 RGB+alpha flattened file with color matted against white.
|
||
let mut f = testgen::merged_only(Version::Psd, ColorMode::Rgb, 8, Compression::Raw, 1, 1);
|
||
f.header.channels = 4;
|
||
// straight red at alpha 0.5, matted: r = 1, g = b = 0.5
|
||
f.image_data = photocraft_psd::ImageData { compression: Compression::Raw, data: vec![255, 128, 128, 128] };
|
||
let m = merged_composite(&f).unwrap();
|
||
assert!((m[0][0] - 1.0).abs() < 0.01 && m[0][1] < 0.01 && m[0][2] < 0.01, "{:?}", m[0]);
|
||
let (d, _) = psd_to_document(&f);
|
||
let p = d.layers[0].surface().unwrap().pixel(0, 0);
|
||
assert!(p[1] < 0.01 && (p[3] - 128.0 / 255.0).abs() < 1e-6, "{p:?}");
|
||
|
||
// HDR and negative straight samples must survive the actual F32 PSD import boundary.
|
||
let mut hdr = testgen::merged_only(Version::Psd, ColorMode::Rgb, 32, Compression::Raw, 1, 1);
|
||
hdr.header.channels = 4;
|
||
hdr.image_data =
|
||
photocraft_psd::ImageData { compression: Compression::Raw, data: [1.5_f32, 0.25, 0.5, 0.5].into_iter().flat_map(f32::to_be_bytes).collect() };
|
||
assert_eq!(merged_composite(&hdr).unwrap(), vec![[2.0, -0.5, 0.0, 0.5]]);
|
||
let mut deep = testgen::merged_only(Version::Psd, ColorMode::Rgb, 16, Compression::Raw, 1, 1);
|
||
deep.header.channels = 4;
|
||
deep.image_data.data = [65535_u16, 65535, 65535, 65500].into_iter().flat_map(u16::to_be_bytes).collect();
|
||
assert_eq!(merged_composite(&deep).unwrap()[0][3], 65500.0 / 65535.0);
|
||
let imported = import("hdr.psd", &hdr.to_bytes().unwrap()).unwrap().document;
|
||
assert_eq!(imported.depth, photocraft_color::SampleType::F32);
|
||
assert_eq!(imported.layers[0].surface().unwrap().pixel(0, 0), vec![2.0, -0.5, 0.0, 0.5]);
|
||
}
|
||
|
||
#[test]
|
||
fn vector_rendered_mask_not_doubled_on_shapes() {
|
||
use photocraft_psd::{LayerRecord, TaggedBlock};
|
||
let mut f = testgen::small(Version::Psd, Compression::Raw);
|
||
let mut mask = photocraft_psd::LayerMask::new(photocraft_psd::Rect::default(), 0, 8);
|
||
mask.flags = 8;
|
||
f.layers_mut().push(LayerRecord {
|
||
name: b"shape".to_vec(),
|
||
mask: photocraft_psd::MaskData::Mask(mask),
|
||
channels: vec![photocraft_psd::ChannelData { id: -2, compression: Some(Compression::Raw), data: vec![] }],
|
||
blocks: vec![TaggedBlock::unicode_name("shape"), TaggedBlock::new(*b"SoCo", vec![]), TaggedBlock::new(*b"vmsk", vec![0; 8])],
|
||
..Default::default()
|
||
});
|
||
let (d, _) = psd_to_document(&f);
|
||
let l = d.layers.last().unwrap();
|
||
assert_eq!(l.content.kind_name(), "Shape");
|
||
assert!(l.mask.is_none());
|
||
|
||
let record = f.layers_mut().last_mut().unwrap();
|
||
let photocraft_psd::MaskData::Mask(mask) = &mut record.mask else { panic!("fixture has a mask") };
|
||
mask.real = Some(photocraft_psd::RealMask { flags: 0, background: 255, rect: photocraft_psd::Rect { top: 0, left: 0, bottom: 1, right: 1 } });
|
||
record.channels.push(photocraft_psd::ChannelData { id: -3, compression: Some(Compression::Raw), data: vec![64] });
|
||
let (imported, _) = psd_to_document(&f);
|
||
assert_eq!(imported.layers.last().unwrap().content.kind_name(), "Shape");
|
||
let mask = imported.layers.last().unwrap().mask.as_ref().expect("selected real mask must survive synthetic cleanup");
|
||
assert!((mask.surface.sample_channel(0, 0, 0) - 64.0 / 255.0).abs() < 1e-6);
|
||
let exported = export(&imported, "real-mask.psd", &Default::default()).unwrap();
|
||
let reopened = import("real-mask.psd", &exported.bytes).unwrap().document;
|
||
assert_eq!(reopened.layers.last().unwrap().mask.as_ref().unwrap().surface.pixel(0, 0), mask.surface.pixel(0, 0));
|
||
}
|
||
|
||
#[test]
|
||
fn every_blend_mode_maps_both_ways() {
|
||
for m in std::iter::once(photocraft_color::BlendMode::PassThrough).chain(photocraft_color::BlendMode::LAYER_MODES) {
|
||
let k = photocraft_psd::BlendMode::from_key(m.psd_key());
|
||
assert!(!matches!(k, photocraft_psd::BlendMode::Unknown(_)), "{m:?}");
|
||
assert_eq!(photocraft_color::BlendMode::from_psd_key(k.key()), Some(m));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn layer_bounds_without_channel_data_allocate_nothing() {
|
||
// A record can declare a 300000x300000 rectangle in a few bytes. With no channel data to
|
||
// back it, import used to interleave a ~360 GB buffer and abort on the allocation (#755).
|
||
use photocraft_psd::LayerRecord;
|
||
let mut f = testgen::small(Version::Psd, Compression::Raw);
|
||
f.layers_mut().push(LayerRecord { rect: photocraft_psd::Rect::from_xywh(0, 0, 300_000, 300_000), name: b"huge".to_vec(), ..Default::default() });
|
||
let (d, warnings) = psd_to_document(&f);
|
||
let l = d.layers.last().unwrap();
|
||
assert!(matches!(&l.content, LayerContent::Raster(s) if s.tile_count() == 0));
|
||
assert!(warnings.iter().any(|w| w.contains("\"huge\"")), "{warnings:?}");
|
||
}
|