Files
photocraft/crates/engine/tests/smart_psd.rs
T
590bec7998 feat(io): import Photoshop Displace smart filters with their embedded map (#3680)
A Displace smart filter (Fltr class `Dspl`, filterID 1148416108) was not in
the modelled filter list, so it became psd.unsupportedFilter and rendered
as a pass-through. Mockup PSDs that bend a replaceable design over fabric
(a design smart object with Displace then Gaussian Blur) rendered flat.

Photoshop stores the displacement map inside the filter: `DspD` (raw data)
holds u32 LE height, u32 LE width, u16 LE mode 3, then two 8-bit planes
(horizontal, vertical) and padding, next to HrzS/VrtS (scale %), DspM
(StrF stretch / Tile), UndA (RptE repeat / WrpA wrap), EmbF and DspF (an
alias to the author's map file, usually unavailable).

The filter now maps to filter.distort.displace. As for Camera Raw, the
whole item (with the map) is kept as hex in `__displacePsd`, so it
re-renders, survives .pcraft and is written back to PSD with only the
editable settings and blending replaced. params_for resolves the embedded
map, so the smart filter re-render applies it, stretched over the document
canvas (the bounds smart filters already pass). Another mode, a missing
or truncated map, or unknown enum values keep the item opaque as before.
The map parser uses checked sizes and caps maps at 2^26 pixels.

Tests: displace_map unit tests (layout, every truncation, hostile and
corrupted headers, round trip, edited settings, opaque fallbacks, bad
exports) and smart_psd::displace_psd_filter_renders_with_its_embedded_map
(8/16/32-bit: renders displaced, PSD export/import and .pcraft keep it).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 06:28:55 -04:00

471 lines
27 KiB
Rust

//! Smart objects and smart filters made in PhotoCraft survive a PSD save and open (#215): the
//! layer comes back as a live smart object (not pixels) with the same nested document (embedded
//! in `lnk2` as a PSB), transform, filter list and filter mask, and re-renders like before.
//!
//! Set `PHOTOCRAFT_SMART_PSD_OUT=<dir>` to keep the exported files (for opening in Photoshop).
use photocraft_doc::{Document, LayerContent, SmartObject, SmartSource};
use photocraft_engine::Session;
use photocraft_engine::smart_cmds::{decode_source, refresh_layer, source_bytes};
use photocraft_geom::Rect;
use serde_json::json;
const W: i32 = 96;
const H: i32 = 64;
fn session(depth: u64) -> Session {
let mut s = Session::new();
s.execute("file.new", json!({"width": W, "height": H, "depth": depth})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.edit("paint", |doc, active| {
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
let fmt = surf.format();
let r = Rect::new(8, 6, 80, 58);
let mut data = Vec::new();
for y in r.y0..r.y1 {
for x in r.x0..r.x1 {
let v = ((x * 7 + y * 13) % 23) as f32 / 22.0;
data.extend(photocraft_raster::from_rgba(&fmt, [v, 1.0 - v, ((x * y) % 7) as f32 / 6.0, 1.0]));
}
}
surf.write_region(r, &data);
Ok(())
})
.unwrap();
s
}
fn smart(doc: &Document) -> (u64, SmartObject) {
doc.walk()
.into_iter()
.find_map(|(_, _, l)| match &l.content {
LayerContent::Smart(sm) => Some((l.id.0, sm.clone())),
_ => None,
})
.expect("a smart object")
}
fn max_diff(a: &[[f32; 4]], b: &[[f32; 4]]) -> f32 {
assert_eq!(a.len(), b.len());
a.iter().zip(b).flat_map(|(p, q)| (0..4).map(move |c| (p[c] * p[3].max(0.0) - q[c] * q[3].max(0.0)).abs())).fold(0.0, f32::max)
}
/// Every block with inner structure re-parses strictly (`TaggedBlock::check_structure`).
fn structure_errors(bytes: &[u8]) -> Vec<String> {
let f = photocraft_psd::PsdFile::from_bytes(bytes).unwrap();
let mut errs = Vec::new();
for b in &f.global_blocks {
if let Err(e) = b.check_structure() {
errs.push(format!("global {}: {e}", b.key_str()));
}
}
for l in f.layers() {
for b in &l.blocks {
if let Err(e) = b.check_structure() {
errs.push(format!("layer {}: {e}", b.key_str()));
}
}
}
errs
}
fn keep(name: &str, bytes: &[u8]) {
if let Ok(dir) = std::env::var("PHOTOCRAFT_SMART_PSD_OUT") {
std::fs::write(std::path::Path::new(&dir).join(name), bytes).unwrap();
}
}
#[test]
fn smart_objects_and_filters_survive_psd() {
for depth in [8, 16, 32] {
let mut s = session(depth);
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap();
s.execute("select.rect", json!({"x": 0, "y": 0, "width": 40, "height": 64})).unwrap();
s.execute("filter.noise.addNoise", json!({"amount": 20, "seed": 3, "distribution": "gaussian"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
s.execute("layer.smartFilter.blendingOptions", json!({"opacity": 0.5, "blend": "screen"})).unwrap();
s.execute("filter.stylize.emboss", json!({"angle": 120, "height": 2, "amount": 80})).unwrap();
s.execute("layer.smartFilter.setVisible", json!({"index": 2, "visible": false})).unwrap();
let id = s.active().unwrap().active_layer.unwrap().0;
s.execute("edit.transform", json!({"layer": id, "matrix": [0.75, 0, 0, 0.75, 10, 4]})).unwrap();
let doc = s.active().unwrap().doc.clone();
let (_, before) = smart(&doc);
let SmartSource::Embedded { bytes: pcraft, .. } = &before.source else { panic!("embedded") };
let nested = decode_source("x.pcraft", pcraft).unwrap();
let out = photocraft_io::export(&doc, "psd", &Default::default()).unwrap();
assert!(!out.warnings.iter().any(|w| w.contains("pixels")), "{:?}", out.warnings);
assert_eq!(structure_errors(&out.bytes), Vec::<String>::new());
keep(&format!("smart-{depth}.psd"), &out.bytes);
let back = photocraft_io::import("x.psd", &out.bytes).unwrap().document;
let (bid, after) = smart(&back);
assert!(matches!(after.source, SmartSource::Linked { .. }), "depth {depth}: source in lnk2");
// Same filters, blending and settings (numbers may come back as floats, defaults spelled out).
assert_eq!(after.smart_filters.len(), before.smart_filters.len());
for (a, b) in after.smart_filters.iter().zip(&before.smart_filters) {
assert_eq!((&a.command, a.blend, a.opacity, a.visible), (&b.command, b.blend, b.opacity, b.visible), "depth {depth}");
for (k, v) in b.params.as_object().unwrap() {
let w = &a.params[k];
assert!(w == v || w.as_f64().zip(v.as_f64()).is_some_and(|(x, y)| x == y), "{}: {k} {v} came back as {w}", b.command);
}
}
assert_eq!(after.filters_enabled, before.filters_enabled);
// The filter mask covers the same pixels (PSD extends it with white past its bounds).
let (ma, mb) = (after.filter_mask.as_ref().unwrap(), before.filter_mask.as_ref().unwrap());
assert_eq!((ma.enabled, ma.linked), (mb.enabled, mb.linked));
let area = doc.bounds().union(&before.cache.as_ref().unwrap().content_bounds());
let (mut va, mut vb) = (Vec::new(), Vec::new());
ma.values_into(area, &mut va);
mb.values_into(area, &mut vb);
assert_eq!(va, vb, "depth {depth}: filter mask");
for (a, b) in after.transform.m.iter().zip(before.transform.m) {
assert!((a - b).abs() < 1e-9, "depth {depth}: transform {:?} vs {:?}", after.transform, before.transform);
}
// The nested document came back (as the embedded PSB), layer for layer.
let (name, src) = source_bytes(&back.metadata, &after.source).unwrap();
assert!(name.ends_with(".psb"), "{name}");
let inner = decode_source(&name, &src).unwrap();
assert_eq!((inner.size, inner.layers.len(), inner.depth), (nested.size, nested.layers.len(), nested.depth));
assert!(max_diff(&photocraft_compose::flatten(&inner).px, &photocraft_compose::flatten(&nested).px) <= 1.0 / 255.0 + 1e-6);
// And it re-renders from that source like the original did.
let mut l = back.layer(photocraft_doc::LayerId(bid)).unwrap().clone();
assert!(refresh_layer(&back, &mut l).unwrap());
let mut rerendered = back.clone();
*rerendered.layer_mut(photocraft_doc::LayerId(bid)).unwrap() = l;
let d = max_diff(&photocraft_compose::flatten(&rerendered).px, &photocraft_compose::flatten(&doc).px);
assert!(d <= 2.0 / 255.0, "depth {depth}: re-render differs by {d}");
// Saving the re-imported file again keeps the same embedded file (no growth).
let again = photocraft_io::export(&back, "psd", &Default::default()).unwrap();
assert_eq!(structure_errors(&again.bytes), Vec::<String>::new());
assert!(again.bytes.len() <= out.bytes.len() + 64, "{} vs {}", again.bytes.len(), out.bytes.len());
}
}
#[test]
fn camera_raw_psd_filters_survive_re_editing_native_save_and_undo() {
for depth in [8, 16, 32] {
let mut s = session(depth);
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
s.execute("filter.blur.gaussianBlur", json!({"radius": 1})).unwrap();
s.execute("select.rect", json!({"x": 10, "y": 8, "width": 50, "height": 40})).unwrap();
s.execute(
"filter.cameraRaw",
json!({
"exposure": 1.15, "contrast": -38, "highlights": 35, "shadows": -50, "whites": 49, "blacks": -34,
"curveDarks": -21, "curveLights": 38, "hslHue": [-50, 0, 29, 1, 0, 0, 0, 0],
"temperature": -26, "tint": -25, "texture": -31, "clarity": 30, "dehaze": -34, "vibrance": 33, "saturation": 33,
"sharpenAmount": 43, "sharpenRadius": 1.2, "sharpenDetail": 36, "sharpenMasking": 30,
"noiseLuminance": 47, "noiseLuminanceDetail": 50, "noiseColor": 25, "noiseColorDetail": 50,
"gradeShadows": {"hue": 117, "sat": 52, "lum": -24}, "gradeMidtones": {"hue": 51, "sat": 51, "lum": 41},
"gradeHighlights": {"hue": 316, "sat": 70, "lum": 28}, "gradeGlobal": {"hue": 20, "sat": 61, "lum": 45},
"gradeBlending": 36, "gradeBalance": 35, "grainAmount": 45, "grainSize": 25, "grainRoughness": 50,
"pointCurve": [[0, 0], [146, 102], [255, 255]], "pointCurveRed": [[0, 0], [90, 174], [255, 255]],
"pointCurveGreen": [[0, 0], [162, 122], [255, 255]], "pointCurveBlue": [[0, 0], [129, 202], [255, 255]]
}),
)
.unwrap();
s.execute("select.deselect", json!({})).unwrap();
s.execute("filter.blur.gaussianBlur", json!({"radius": 2})).unwrap();
let before = s.active().unwrap().doc.clone();
let out = photocraft_io::export(&before, "psd", &Default::default()).unwrap();
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
assert!(structure_errors(&out.bytes).is_empty());
let back = photocraft_io::import("camera-raw.psd", &out.bytes).unwrap().document;
let (layer, imported) = smart(&back);
let (_, original) = smart(&before);
let mask_values = |sm: &SmartObject| {
let mut values = Vec::new();
sm.filter_mask.as_ref().unwrap().values_into(before.bounds(), &mut values);
values
};
assert_eq!(imported.smart_filters.len(), 3);
assert_eq!(imported.smart_filters[1].command, "filter.cameraRaw");
assert_eq!(mask_values(&imported), mask_values(&original));
let expected = photocraft_engine::lens_cmds::raw_params("filter.cameraRaw", &original.smart_filters[1].params).unwrap();
let actual = photocraft_engine::lens_cmds::raw_params("filter.cameraRaw", &imported.smart_filters[1].params).unwrap();
assert_eq!(actual, expected, "{depth}-bit");
let template = imported.smart_filters[1].params["__cameraRawPsd"].clone();
let mut edited = Session::new();
edited.add_document(back, None);
edited.execute("layer.smartFilter.setParams", json!({"layer": layer, "index": 1, "params": {"exposure": 0, "curveLights": 0}})).unwrap();
assert_eq!(smart(&edited.active().unwrap().doc).1.smart_filters[1].params["__cameraRawPsd"], template);
let changed = edited.active().unwrap().doc.clone();
edited.undo();
assert_eq!(smart(&edited.active().unwrap().doc).1.smart_filters[1].params["exposure"], imported.smart_filters[1].params["exposure"]);
edited.redo();
assert_eq!(*edited.active().unwrap().doc, *changed);
let native = photocraft_format::save_to_bytes(&changed, &Default::default()).unwrap();
let native_back = photocraft_format::load_from_bytes(&native).unwrap();
assert_eq!(smart(&native_back).1.smart_filters, smart(&changed).1.smart_filters);
let out = photocraft_io::export(&native_back, "psd", &Default::default()).unwrap();
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
assert!(structure_errors(&out.bytes).is_empty());
let back = photocraft_io::import("edited.psd", &out.bytes).unwrap().document;
let (_, after) = smart(&back);
assert_eq!(after.smart_filters.len(), 3);
let params = photocraft_engine::lens_cmds::raw_params("filter.cameraRaw", &after.smart_filters[1].params).unwrap();
assert_eq!((params.exposure, params.curve_lights), (0.0, 0.0));
assert_eq!(params.hsl_hue, expected.hsl_hue);
assert_eq!(mask_values(&after), mask_values(&original));
let mut unfiltered = after.clone();
unfiltered.smart_filters.clear();
assert!(photocraft_engine::smart_cmds::render(&back, &unfiltered).unwrap().is_some());
}
}
/// Private, user-supplied files remain outside the repository. Set PHOTOCRAFT_CAMERA_RAW_PSD
/// to check a Camera Raw fixture and PHOTOCRAFT_SMART_PSD_OUT to retain exports for manual checks.
#[test]
#[ignore = "requires a user-supplied Camera Raw PSD"]
fn camera_raw_psd_fixture() {
let path = std::env::var("PHOTOCRAFT_CAMERA_RAW_PSD").expect("set PHOTOCRAFT_CAMERA_RAW_PSD");
let bytes = std::fs::read(&path).unwrap();
let imported = photocraft_io::import("fixture.psd", &bytes).unwrap();
let (_, sm) = smart(&imported.document);
let index = sm
.smart_filters
.iter()
.position(|f| {
photocraft_io::smart_map::item_for_filter(f).ok().is_some_and(
|item| matches!(item.get("Fltr"), Some(photocraft_psd::descriptor::Value::Descriptor(d)) if d.class_id.is("Adobe Camera Raw Filter")),
)
})
.expect("a Camera Raw filter");
let original_item = photocraft_io::smart_map::item_for_filter(&sm.smart_filters[index]).unwrap();
let out = photocraft_io::export(&imported.document, "psd", &Default::default()).unwrap();
assert!(!out.warnings.iter().any(|w| w.contains("smart filter")), "{:?}", out.warnings);
assert!(structure_errors(&out.bytes).is_empty());
keep("camera-raw-roundtrip.psd", &out.bytes);
let back = photocraft_io::import("roundtrip.psd", &out.bytes).unwrap().document;
let (layer, _) = smart(&back);
assert_eq!(photocraft_io::smart_map::item_for_filter(&smart(&back).1.smart_filters[index]).unwrap(), original_item);
let native = photocraft_format::save_to_bytes(&back, &Default::default()).unwrap();
assert_eq!(smart(&photocraft_format::load_from_bytes(&native).unwrap()).1.smart_filters, smart(&back).1.smart_filters);
if sm.smart_filters[index].command == photocraft_io::smart_map::UNSUPPORTED_FILTER {
assert_eq!(smart(&back).1.smart_filters[index].command, photocraft_io::smart_map::UNSUPPORTED_FILTER);
println!("Camera Raw settings survive PSD and native saves; active unmapped controls keep this filter opaque");
return;
}
let mut s = Session::new();
s.add_document(back, None);
s.execute("layer.smartFilter.setParams", json!({"layer": layer, "index": index, "params": {"exposure": 0.5}})).unwrap();
let out = photocraft_io::export(&s.active().unwrap().doc, "psd", &Default::default()).unwrap();
assert!(!out.warnings.iter().any(|w| w.contains("smart filter")), "{:?}", out.warnings);
assert!(structure_errors(&out.bytes).is_empty());
keep("camera-raw-edited.psd", &out.bytes);
let back = photocraft_io::import("edited.psd", &out.bytes).unwrap().document;
let after = &smart(&back).1.smart_filters[index];
assert_eq!(after.params["exposure"], 0.5);
let mut expected = original_item;
let mut fltr = match expected.get("Fltr").unwrap() {
photocraft_psd::descriptor::Value::Descriptor(d) => d.clone(),
_ => panic!("Fltr must be a descriptor"),
};
fltr.items.iter_mut().find(|(key, _)| key.is("Ex12")).unwrap().1 = photocraft_psd::descriptor::Value::Double(0.5);
expected.items.iter_mut().find(|(key, _)| key.is("Fltr")).unwrap().1 = photocraft_psd::descriptor::Value::Descriptor(fltr);
assert_eq!(photocraft_io::smart_map::item_for_filter(after).unwrap(), expected, "only Exposure changes");
}
/// Every colour model: the smart object, its filters and the filter cache's unfiltered pixels
/// (in the document's model, CMYK inverted and 16-bit Lab scaled as in layer channels) survive.
#[test]
fn smart_filters_survive_psd_in_every_colour_model() {
for (mode, depth) in [("cmyk", 8), ("cmyk", 16), ("grayscale", 8), ("lab", 8), ("lab", 16)] {
let mut s = Session::new();
s.execute("file.new", json!({"width": 32, "height": 24, "mode": mode, "depth": depth})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("select.rect", json!({"x": 4, "y": 4, "width": 20, "height": 12})).unwrap();
s.execute("edit.fill", json!({"color": "#c83c28"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
s.execute("filter.blur.gaussianBlur", json!({"radius": 1.5})).unwrap();
let doc = s.active().unwrap().doc.clone();
let out = photocraft_io::export(&doc, "psd", &Default::default()).unwrap();
assert!(!out.warnings.iter().any(|w| w.contains("smart")), "{mode} {depth}: {:?}", out.warnings);
assert_eq!(structure_errors(&out.bytes), Vec::<String>::new());
let back = photocraft_io::import("x.psd", &out.bytes).unwrap().document;
let ((_, a), (_, b)) = (smart(&doc), smart(&back));
assert_eq!(b.smart_filters.len(), 1, "{mode} {depth}");
assert_eq!(b.smart_filters[0].command, a.smart_filters[0].command);
// The cache's unfiltered planes decode back to the unfiltered rendering.
let f = photocraft_psd::PsdFile::from_bytes(&out.bytes).unwrap();
let fx = f.global_blocks.iter().find(|g| &g.key == b"FEid").map(|g| photocraft_psd::filter_effects::FilterEffects::parse(&g.data).unwrap()).unwrap();
let item = &fx.items[0];
let (w, h) = item.rect.size().unwrap();
let bits = u16::try_from(item.depth).unwrap();
let first = item.slots[0].as_ref().unwrap().decode(w, h, bits).unwrap();
let mut unfiltered = a.clone();
unfiltered.smart_filters.clear();
let px = photocraft_engine::smart_cmds::render(&doc, &unfiltered).unwrap().unwrap();
let (x, y) = (10 - item.rect.left, 8 - item.rect.top);
let at = (y as usize * w + x as usize) * usize::from(bits / 8);
let stored = if bits == 8 { f32::from(first[at]) / 255.0 } else { f32::from(u16::from_be_bytes([first[at], first[at + 1]])) / 65535.0 };
let v = px.sample_channel(10, 8, 0);
let expect = if mode == "cmyk" { 1.0 - v } else { v };
assert!((stored - expect).abs() < 0.01, "{mode} {depth}: slot 0 holds {stored}, expected {expect}");
}
}
/// Writes sample files for checking in Photoshop (`PHOTOCRAFT_SMART_PSD_OUT`): per depth, a
/// plain layer, a smart object without filters, and one with filters.
#[test]
#[ignore = "writes files for a manual Photoshop check"]
fn photoshop_samples() {
for (mode, depth) in [("rgb", 8), ("rgb", 16), ("rgb", 32), ("cmyk", 8), ("lab", 16), ("grayscale", 8)] {
let mut s = Session::new();
s.execute("file.new", json!({"width": W, "height": H, "mode": mode, "depth": depth})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.execute("select.rect", json!({"x": 10, "y": 8, "width": 50, "height": 40})).unwrap();
s.execute("edit.fill", json!({"color": "#c83c28"})).unwrap();
s.execute("select.deselect", json!({})).unwrap();
let tag = format!("{mode}{depth}");
keep(&format!("plain-{tag}.psd"), &photocraft_io::export(&s.active().unwrap().doc, "psd", &Default::default()).unwrap().bytes);
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
keep(&format!("nofilter-{tag}.psd"), &photocraft_io::export(&s.active().unwrap().doc, "psd", &Default::default()).unwrap().bytes);
s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap();
keep(&format!("gauss-{tag}.psd"), &photocraft_io::export(&s.active().unwrap().doc, "psd", &Default::default()).unwrap().bytes);
}
}
/// PSD export of a 24 MP smart object versus the same layer as pixels (release:
/// `cargo test --release -p photocraft-engine --test smart_psd -- --ignored --nocapture perf`).
#[test]
#[ignore = "benchmark"]
fn perf_24mp_smart_object_export() {
let (w, h) = (6000, 4000);
let mut s = Session::new();
s.execute("file.new", json!({"width": w, "height": h, "depth": 8})).unwrap();
s.execute("layer.new.layer", json!({})).unwrap();
s.edit("paint", |doc, active| {
let surf = doc.layer_mut(active.unwrap()).unwrap().surface_mut().unwrap();
let fmt = surf.format();
let r = Rect::new(0, 0, w, h);
let mut data = Vec::with_capacity((w * h * 4) as usize);
for y in r.y0..r.y1 {
for x in r.x0..r.x1 {
// Photo-like: smooth gradients plus a little texture (RLE can't collapse it).
let v = (x as f32 / w as f32) * 0.7 + (((x * 31 + y * 17) % 29) as f32 / 29.0) * 0.1;
data.extend(photocraft_raster::from_rgba(&fmt, [v, (y as f32 / h as f32) * 0.8, 1.0 - v, 1.0]));
}
}
surf.write_region(r, &data);
Ok(())
})
.unwrap();
let time = |doc: &Document| {
let t = std::time::Instant::now();
let out = photocraft_io::export(doc, "psd", &Default::default()).unwrap();
(t.elapsed().as_secs_f64(), out.bytes.len(), out.warnings)
};
let (tf, sf, _) = time(&s.active().unwrap().doc);
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
let (ts, ss, ws) = time(&s.active().unwrap().doc);
assert!(ws.is_empty(), "{ws:?}");
s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap();
let (tg, sg, wg) = time(&s.active().unwrap().doc);
assert!(wg.is_empty(), "{wg:?}");
let mb = |b: usize| b as f64 / 1e6;
eprintln!(
"24 MP PSD export: pixels {tf:.2} s {:.1} MB | smart object {ts:.2} s {:.1} MB | + Gaussian Blur smart filter {tg:.2} s {:.1} MB",
mb(sf),
mb(ss),
mb(sg)
);
// The embedded file is about one more copy of the layer; the filter cache one more again.
assert!(ss < sf * 5 / 2, "smart object {ss} vs pixels {sf}");
assert!(sg < sf * 4, "with filters {sg} vs pixels {sf}");
}
#[test]
fn edited_psd_smart_objects_drop_their_old_file() {
let mut s = session(8);
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
let doc = s.active().unwrap().doc.clone();
let first = photocraft_io::export(&doc, "psd", &Default::default()).unwrap().bytes;
// Re-open, edit the contents (a new embedded file), save: one embedded file, the new one.
let mut s2 = Session::new();
s2.add_document(photocraft_io::import("a.psd", &first).unwrap().document, None);
let (id, _) = smart(&s2.active().unwrap().doc);
s2.select_layer(photocraft_doc::LayerId(id)).unwrap();
s2.execute("layer.smartObjects.editContents", json!({})).unwrap();
s2.execute("image.adjustments.invert", json!({})).unwrap();
s2.execute("layer.smartObjects.saveContents", json!({})).unwrap();
let parent = s2.documents().iter().position(|d| d.doc.layer(photocraft_doc::LayerId(id)).is_some()).unwrap();
let doc2 = s2.documents()[parent].doc.clone();
let second = photocraft_io::export(&doc2, "psd", &Default::default()).unwrap().bytes;
let f = photocraft_psd::PsdFile::from_bytes(&second).unwrap();
let uuids: Vec<String> = f.global_blocks.iter().filter(|b| &b.key == b"lnk2").flat_map(|b| photocraft_io::linked::block_uuids(&b.data)).collect();
let back = photocraft_io::import("b.psd", &second).unwrap().document;
let (_, sm) = smart(&back);
let SmartSource::Linked { path } = &sm.source else { panic!() };
assert_eq!(uuids, vec![path.clone()]);
}
/// A Displace smart filter from a Photoshop mockup PSD carries its displacement map embedded
/// (`DspD`, stretched over the canvas): it re-renders displaced instead of passing the pixels
/// through, and keeps that map through PSD export, `.pcraft` and a PSD re-import.
#[test]
fn displace_psd_filter_renders_with_its_embedded_map() {
use photocraft_psd::descriptor::{Descriptor, Id, UnicodeString, Value};
for depth in [8, 16, 32] {
let mut s = session(depth);
s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap();
let id = photocraft_doc::LayerId(s.active().unwrap().active_layer.unwrap().0);
// Photoshop's layout: height, width, mode 3, horizontal plane (all right), vertical plane
// (neutral), padding. 128/255 is a hair over mid-grey: a sub-pixel vertical shift.
let (mw, mh) = (12u32, 8u32);
let n = (mw * mh) as usize;
let mut tdta = [mh.to_le_bytes().as_slice(), &mw.to_le_bytes(), &3u16.to_le_bytes()].concat();
tdta.extend(std::iter::repeat_n(255, n).chain(std::iter::repeat_n(128, n)).chain(std::iter::repeat_n(0, mw as usize)));
let e = |t: &str, v: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(v) };
let fltr = Descriptor::new("Dspl")
.with("HrzS", Value::Integer(10))
.with("VrtS", Value::Integer(10))
.with("DspM", e("DspM", "StrF"))
.with("UndA", e("UndA", "RptE"))
.with("DspF", Value::Alias(b"Macintosh HD:displace.psd".to_vec()))
.with("EmbF", Value::Boolean(true))
.with("DspD", Value::RawData(tdta));
let item = Descriptor::new("filterFX")
.with("Nm ", Value::Text(UnicodeString::new_nul("Displace...")))
.with("enab", Value::Boolean(true))
.with("hasoptions", Value::Boolean(true))
.with("Fltr", Value::Descriptor(fltr))
.with("filterID", Value::Integer(1148416108));
let filter = photocraft_io::smart_map::filter_from_item(&item);
assert_eq!(filter.command, "filter.distort.displace");
s.edit("displace", |doc, _| {
let mut l = doc.layer(id).unwrap().clone();
let LayerContent::Smart(sm) = &mut l.content else { panic!("smart") };
sm.smart_filters.push(filter.clone());
assert!(refresh_layer(doc, &mut l).unwrap());
*doc.layer_mut(id).unwrap() = l;
Ok(())
})
.unwrap();
let doc = s.active().unwrap().doc.clone();
let alpha = |doc: &Document, x: i32| smart(doc).1.cache.unwrap().pixel(x, 30)[3];
// 10 % of 128 px at full map contrast: the content (x 8..80) is read from 12.8 px to the
// right, so it moves left over the canvas edge and leaves its right edge.
assert!(alpha(&doc, 2) > 0.99 && alpha(&doc, 75) < 0.01, "depth {depth}: {} {}", alpha(&doc, 2), alpha(&doc, 75));
let out = photocraft_io::export(&doc, "psd", &Default::default()).unwrap();
assert!(out.warnings.is_empty(), "{:?}", out.warnings);
assert_eq!(structure_errors(&out.bytes), Vec::<String>::new());
let back = photocraft_io::import("displace.psd", &out.bytes).unwrap().document;
let native = photocraft_format::load_from_bytes(&photocraft_format::save_to_bytes(&back, &Default::default()).unwrap()).unwrap();
for doc in [&back, &native] {
let (bid, after) = smart(doc);
assert_eq!(after.smart_filters, vec![filter.clone()], "depth {depth}");
let mut l = doc.layer(photocraft_doc::LayerId(bid)).unwrap().clone();
assert!(refresh_layer(doc, &mut l).unwrap());
let LayerContent::Smart(sm) = &l.content else { panic!("smart") };
let (a, b) = (sm.cache.as_ref().unwrap(), smart(&s.active().unwrap().doc).1.cache.unwrap());
assert_eq!((a.pixel(2, 30)[3], a.pixel(75, 30)[3]), (b.pixel(2, 30)[3], b.pixel(75, 30)[3]), "depth {depth}: re-render");
}
}
}