Files
photocraft/crates/engine/tests/rasterize_psd_reopen.rs
T
Brandon ThomasandClaude Opus 5.5 0d6e658e73 PSD: rasterized fill and smart layers stay rasterized after save and reopen (#4034)
* fix(psd): rasterized fill and smart layers stay rasterized after save and reopen

Layer > Rasterize > Fill Content / Smart Object swapped the layer's content
to pixels but kept the fill (SoCo/GdFl/PtFl) or placed-layer (SoLd/PlLd/SoLE)
blocks read from the PSD. Written back, they made the reopened file a fill
layer or smart object again and discarded the pixels. Photoshop writes none
of them for a pixel layer.

- Rasterize drops those blocks from the layer.
- The PSD exporter never writes fill, placed, type (TySh), shape-content
  (vscg/vstk), adjustment or CgEd blocks for a raster layer, whichever
  command made it one; a vector mask (vmsk/vsms with its vogk) stays.

Tests: synthetic fill + smart object rasterize/save/reopen, the exporter
backstop with stale blocks, and Photoshop-authored corpus files.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(psd): keep a pixel layer's vstk when it has no vector mask, as Photoshop does

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

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

116 lines
5.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Layer › Rasterize › Fill Content / Smart Object stays rasterized after a PSD save and reopen.
//! A fill or smart layer opened from a PSD keeps its file blocks (`SoCo`/`GdFl`/`PtFl`,
//! `SoLd`/`PlLd`); left on the rasterized layer, they made the reopened file a fill layer or
//! smart object again and threw the pixels away. Photoshop writes none of them for a pixel layer.
use photocraft_doc::{Document, LayerContent, LayerId};
use photocraft_engine::Session;
use serde_json::{Value, json};
fn id_of(r: Value) -> LayerId {
LayerId(r["layer"].as_u64().unwrap())
}
fn kind(l: &photocraft_doc::Layer) -> &'static str {
match &l.content {
LayerContent::Fill(_) => "fill",
LayerContent::Smart(_) => "smart",
LayerContent::Raster(_) => "raster",
_ => "other",
}
}
fn kind_named(doc: &Document, name: &str) -> &'static str {
let l = doc.walk().into_iter().map(|(_, _, l)| l).find(|l| l.name == name).unwrap_or_else(|| panic!("no layer {name}"));
kind(l)
}
fn reopen(doc: &Document) -> Document {
let out = photocraft_io::export(doc, "psd", &Default::default()).unwrap();
photocraft_io::import("x.psd", &out.bytes).unwrap().document
}
/// Whether the PSD written for `doc` gives the layer `name` any of `keys`.
fn has_block(doc: &Document, name: &str, keys: &[&[u8; 4]]) -> bool {
let out = photocraft_io::export(doc, "psd", &Default::default()).unwrap();
let f = photocraft_psd::PsdFile::from_bytes(&out.bytes).unwrap();
let rec = f.layers().iter().find(|r| r.name() == name).unwrap();
keys.iter().any(|k| rec.block(k).is_some())
}
/// Rasterizes every layer named in `names` of a session opened on `doc`, then saves and reopens.
fn rasterize_and_reopen(doc: Document, names: &[&str]) -> Document {
let mut s = Session::new();
s.add_document(doc, None);
for name in names {
let id = s.active().unwrap().doc.walk().into_iter().map(|(_, _, l)| l).find(|l| l.name == *name).unwrap().id;
s.execute("layer.rasterize.layer", json!({"layer": id.0})).unwrap();
assert_eq!(kind_named(&s.active().unwrap().doc, name), "raster");
}
reopen(&s.active().unwrap().doc)
}
#[test]
fn rasterized_fill_and_smart_object_reopen_as_pixels() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 48, "height": 32})).unwrap();
s.execute("layer.new.layer", json!({"name": "Source"})).unwrap();
s.execute("paint.stroke", json!({"points": [[20, 16]], "size": 12})).unwrap();
let smart = id_of(s.execute("layer.smartObjects.convertToSmartObject", json!({})).unwrap());
let fill = id_of(s.execute("layer.newFillLayer.solidColor", json!({"color": "#00ff00"})).unwrap());
let doc = s.active().unwrap().doc.clone();
let smart_name = doc.layer(smart).unwrap().name.clone();
let fill_name = doc.layer(fill).unwrap().name.clone();
assert_ne!(smart_name, fill_name);
// Opened from a PSD, both layers carry their file blocks.
let opened = reopen(&doc);
assert_eq!(kind_named(&opened, &fill_name), "fill");
assert_eq!(kind_named(&opened, &smart_name), "smart");
let back = rasterize_and_reopen(opened, &[&fill_name, &smart_name]);
assert_eq!(kind_named(&back, &fill_name), "raster");
assert_eq!(kind_named(&back, &smart_name), "raster");
assert!(!has_block(&back, &fill_name, &[b"SoCo", b"GdFl", b"PtFl"]));
assert!(!has_block(&back, &smart_name, &[b"SoLd", b"PlLd", b"SoLE"]));
}
/// The exporter never writes content-defining blocks for a pixel layer, whichever command made it
/// one (a backstop for paths that swap the content without pruning `psd_blocks`).
#[test]
fn raster_layer_with_stale_blocks_writes_none() {
let mut s = Session::new();
s.execute("file.new", json!({"width": 16, "height": 16})).unwrap();
s.execute("layer.newFillLayer.solidColor", json!({"color": "#ff0000"})).unwrap();
let mut doc = reopen(&s.active().unwrap().doc);
let (id, name) = doc.walk().into_iter().map(|(_, _, l)| l).find(|l| kind(l) == "fill").map(|l| (l.id, l.name.clone())).unwrap();
let fmt = doc.pixel_format();
let l = doc.layer_mut(id).unwrap();
assert!(l.psd_blocks.iter().any(|(k, _)| k == b"SoCo"));
l.content = LayerContent::Raster(photocraft_raster::Surface::new(fmt));
l.psd_blocks.push((*b"TySh", std::sync::Arc::new(vec![0; 8])));
l.psd_blocks.push((*b"vscg", std::sync::Arc::new(vec![0; 8])));
l.psd_blocks.push((*b"levl", std::sync::Arc::new(vec![0; 8])));
assert!(!has_block(&doc, &name, &[b"SoCo", b"TySh", b"vstk", b"vscg", b"SoLd", b"PlLd", b"levl"]));
assert_eq!(kind_named(&reopen(&doc), &name), "raster");
}
/// The same through files Photoshop wrote: a gradient fill layer and a placed smart object.
#[cfg(feature = "corpus")]
#[test]
fn photoshop_fill_and_smart_layers_stay_rasterized() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus");
for (file, want) in [("psd-tools/gradient-fill.psd", "fill"), ("photoshop/smart-filters/sf-none-plain.psd", "smart")] {
let path = root.join(file);
let bytes = std::fs::read(&path).unwrap_or_else(|e| panic!("{}: {e} (run `cargo xtask corpus --all`)", path.display()));
let doc = photocraft_io::import(file, &bytes).unwrap().document;
let names: Vec<String> = doc.walk().into_iter().map(|(_, _, l)| l).filter(|l| kind(l) == want).map(|l| l.name.clone()).collect();
assert!(!names.is_empty(), "{file}: no {want} layer");
let refs: Vec<&str> = names.iter().map(String::as_str).collect();
let back = rasterize_and_reopen(doc, &refs);
for n in &names {
assert_eq!(kind_named(&back, n), "raster", "{file}: {n}");
}
}
}