craft-psd: write SoLd and PlLd from the placement types the reader gives (#1059)

* craft-psd: Photoshop PSD/PSB reader and writer, moved from PhotoCraft

PhotoCraft's `photocraft-psd` crate (crates/psd at storytold/photocraft
63b641b55e54bf8f5c83efd977fdb16a2c255431) as `craft-psd`: the lossless
PSD/PSB model, reader, writer and builder, descriptors, the Photoshop
resource files that share its primitives (.abr, .asl, .aco, .act, .ase,
.grd, .pat, .atn, .csh, .shc) and the Photoshop blocks of a layered TIFF.
The crate had no dependency on any other PhotoCraft crate.

Code is unchanged apart from the crate name, craft-libs' rustfmt width,
comments that named other PhotoCraft crates, issue references (now
`photocraft#N`), and the corpus tests' directory (`CRAFT_PSD_CORPUS` or
`corpus/` at the workspace root). Tests, proptests, fuzz targets and
fixtures move with it.

Part of #778, #786.

* craft-psd: engine_data, the text engine document of type layers

The EngineData reader and writer from PhotoCraft's photocraft-text
(crates/text/src/engine_data.rs at storytold/photocraft 63b641b5): a
tree of values that is written back in Photoshop's layout, with strings
stored as plain bytes kept as plain bytes.

From EffectCraft's effectcraft-psd (crates/psd/src/engine_data.rs at
storytold/effectcraft 072e1af9), ported onto that tree: `first_style`,
the first style run as a typed `TextStyle` with bounded sizes and
spacings, and `minimal`, a one-run document for writers and fixtures.
`Value::index` is new.

Both apps had their own parser for the same syntax.

* craft-psd: engine_data::parse_lossy reads what it can of a damaged document

EffectCraft's EngineData reader was best effort: a truncated or damaged
document still gave the text and style it held. `parse_lossy` does that
on the same parser: a truncated dictionary, array or string keeps what
it has, a byte that starts no key is skipped, a token that is no number
becomes a name, and garbage gives an empty dictionary. `parse` stays
strict and is unchanged in what it accepts.

The parser's `value` is split into one function per kind of value, and
reads with `get` instead of indexing.

* craft-psd: typed views of type layers, smart objects and adjustments

Read-only views EffectCraft's effectcraft-psd had over the blocks this
crate keeps raw (storytold/effectcraft f334bcf3, crates/psd/src/parse.rs
and placed.rs). The blocks stay raw and write back unchanged.

- `text`: `TypeLayer` from `TySh`: text, transform (the identity when not
  finite), bounds, the first style run (read with
  `engine_data::parse_lossy`) and the text descriptor.
  `LayerRecord::type_layer`.
- `smart`: `SmartObject` from `SoLd`/`SoLE` (id, quad, non-affine
  transform, size, warp) and `PlLd`; `Warp` with the named styles and the
  custom quilt mesh; the quad geometry (`map`, `placed_bounds`,
  `placed_size`, `bake_scale`, `placement`). `LinkedFile` and
  `linked_files` for `lnk2`/`lnkD`/`lnk3`/`lnkE`, `PsdFile::linked_files`
  and `PsdFile::linked_data`. `LayerRecord::smart_object`.
- `adjust`: `Adjustment` from `nvrt`, `brit`, `CgEd`, `hue2`/`hue `,
  `levl`, `expA`, and the other adjustment keys by name;
  `LayerRecord::adjustment`, `solid_color` (`SoCo`) and `effects` (`lfx2`).
- `Descriptor`: `object`, `number`, `numbers`, `boolean`, `text`,
  `enumerated`, `raw_data`, `color`; `Value::as_number`.

Linked-file data is a range of the block's data, checked against the
record, instead of a range of the whole file.

* craft-psd: shared descriptor lookups

Descriptor::get_bool, get_number, get_text, get_enum and get_object,
used by asl, grd and slices in place of their own copies.

Ported from storytold/photocraft#9552 (its crates/psd part).

* craft-psd: the typed views use the shared descriptor lookups

One set of Descriptor accessors: the get_* lookups from photocraft#9552.
numbers, raw_data and color move next to them as get_numbers,
get_raw_data and get_color.

* craft-psd: linked-layer record editing, the thumbnail header and brst

Format code that PhotoCraft keeps in crates/io (linked.rs,
psd_thumbnail.rs, blocks.rs at storytold/photocraft f1b5e7d7), moved
here without the document mapping:

- smart: `file_type`, `encode_embedded_file` (a version-7 liFD record),
  `linked_records`, `linked_uuids`, `rebuild_linked_block`,
  `select_linked_records` (exact UTF-8 ids, cut selected records are an
  error), `contains_linked_record`, `copy_linked_record`.
- resources: `Thumbnail` reads the 1036/1033 header, and
  `Thumbnail::jfif_bytes` writes it as Photoshop does.
- tagged: `channel_restrictions` / `channel_restrictions_data` for brst.

PhotoCraft's tests for these functions move with them.

* craft-psd: write SoLd and PlLd from the placement types the reader gives

From PhotoCraft's crates/io/src/smart_map/placed.rs (storytold/photocraft
f1b5e7d7), on craft-psd's `SmartObject` and `Warp`: the caller works out
the quad and the warp from its own geometry.

- `SmartObject::to_placed_layer_data(&PlacedLayerOptions)` writes `SoLd`:
  a fresh descriptor in Photoshop's key order, or the imported one
  (`template`) with only the placed id, the transform (when the quad
  moved), the warp and the filter stack rewritten.
- `SmartObject::to_placed_layer()` writes `PlLd` (plcL version 3).
- `Warp::to_descriptor`, `Warp::quilt_descriptor` (a split custom warp,
  `quiltWarp`) and `Warp::mesh_is_valid`.

The reader now takes `quiltWarp` before `warp`, and patch boundaries
stored as an object array, as Photoshop writes them, so a split warp
reads back.

---------

Co-authored-by: Omar Saidahmed <osaidahm@uwaterloo.ca>
This commit is contained in:
Dylan M. TaylorandOmar Saidahmed authored and GitHub committed 2026-11-11 21:37:35 -05:00
1 parent 34ba6ae4da
commit 4011f9ffd4
2 files changed
+407 -3

No files matched your search

+16 -3
View File
@@ -25,6 +25,9 @@ use crate::file::PsdFile;
use crate::io::{Reader, WriteExt, read_pascal};
use crate::layer::LayerRecord;
mod write;
pub use write::PlacedLayerOptions;
/// The most pixels a baked smart object may have: 2^24 (256 MiB as float RGBA). The size comes
/// from the file's transform, which is untrusted.
pub const MAX_BAKE_PIXELS: u64 = 1 << 24;
@@ -64,6 +67,15 @@ fn quad_from(t: [f64; 8]) -> [[f64; 2]; 4] {
[[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]]
}
/// The patch boundaries of a custom envelope: a list of numbers, or (as Photoshop writes a
/// `quiltWarp`) an object array whose body holds them under the same key.
fn quilt_slices(env: &Descriptor, key: &str) -> Vec<f64> {
match env.get(key) {
Some(Value::ObjectArray(o)) => o.body.get_numbers(key),
_ => env.get_numbers(key),
}
}
impl Warp {
/// Reads a `warp` descriptor; `None` for no warp (the identity, or a custom warp without a
/// usable mesh).
@@ -83,8 +95,8 @@ impl Warp {
style,
};
if let Some(env) = d.get_object("customEnvelopeWarp") {
let cols = env.get_numbers("quiltSliceX").len().saturating_sub(1).max(1);
let rows = env.get_numbers("quiltSliceY").len().saturating_sub(1).max(1);
let cols = quilt_slices(env, "quiltSliceX").len().saturating_sub(1).max(1);
let rows = quilt_slices(env, "quiltSliceY").len().saturating_sub(1).max(1);
if let Some(Value::ObjectArray(pts)) = env.get("meshPoints") {
let xs = pts.body.get_numbers("Hrzn");
let ys = pts.body.get_numbers("Vrtc");
@@ -257,7 +269,8 @@ impl SmartObject {
// The non-affine transform when present (perspective), else the transform.
let t = eight(&d, "nonAffineTransform").unwrap_or(t);
let size = d.get_object("Sz ").and_then(|s| Some([s.get_number("Wdth")?, s.get_number("Hght")?]));
let warp = d.get_object("warp").and_then(Warp::from_descriptor);
// A split warp is in `quiltWarp`, with `warp` holding none.
let warp = d.get_object("quiltWarp").and_then(Warp::from_descriptor).or_else(|| d.get_object("warp").and_then(Warp::from_descriptor));
Ok(SmartObject { uuid, quad: quad_from(t), size, warp })
}
+391
View File
@@ -0,0 +1,391 @@
//! Writing the placed-layer blocks (`SoLd`, `PlLd`) of a [`SmartObject`], in the layout
//! Photoshop writes and [`SmartObject::from_placed_layer_data`] / [`SmartObject::from_placed_layer`]
//! read back. Came from PhotoCraft's `crates/io/src/smart_map/placed.rs`, on this crate's
//! placement types: the caller works out the quad and the warp from its own geometry.
use super::{SmartObject, Warp};
use crate::descriptor::{Descriptor, Id, ObjectArray, UnicodeString, Value, VersionedDescriptor};
use crate::io::WriteExt;
/// What a `SoLd` block holds besides the placement.
#[derive(Debug, Clone, Copy, Default)]
pub struct PlacedLayerOptions<'a> {
/// The placed layer's own id (`placed`), distinct from the linked file's id
/// ([`SmartObject::uuid`]): a duplicated smart object shares the file but not this id.
pub placed: &'a str,
/// The content's resolution in pixels per inch (`Rslt`).
pub resolution: f64,
/// The smart filter stack (`filterFX`), when the layer has filters.
pub filter_fx: Option<&'a Descriptor>,
/// The descriptor the block was read with. Every key it has is kept, and only the placed id,
/// the transform (when the quad moved), the warp and the filter stack are rewritten, so an
/// unchanged layer writes back what it read.
pub template: Option<&'a Descriptor>,
}
fn enumerated(type_id: &str, value: &str) -> Value {
Value::Enumerated { type_id: Id::new(type_id), value: Id::new(value) }
}
fn ratio(n: i32, d: i32) -> Value {
Value::Descriptor(Descriptor::new("null").with("numerator", Value::Integer(n)).with("denominator", Value::Integer(d)))
}
fn pad4(mut v: Vec<u8>) -> Vec<u8> {
v.resize(v.len().next_multiple_of(4), 0);
v
}
/// Replaces the value of `key`, or appends it.
fn set(d: &mut Descriptor, key: &str, v: Value) {
match d.items.iter_mut().find(|(k, _)| k.is(key)) {
Some(item) => item.1 = v,
None => d.items.push((Id::new(key), v)),
}
}
/// Replaces the value of `key`, or inserts it before `before` (appends when that is absent).
fn set_before(d: &mut Descriptor, key: &str, v: Value, before: &str) {
if let Some(item) = d.items.iter_mut().find(|(k, _)| k.is(key)) {
item.1 = v;
return;
}
let at = d.items.iter().position(|(k, _)| k.is(before)).unwrap_or(d.items.len());
d.items.insert(at, (Id::new(key), v));
}
impl Warp {
/// Whether a custom warp's mesh has the (3·cols+1)×(3·rows+1) control points its patches
/// need. Other styles have no mesh and are always valid.
pub fn mesh_is_valid(&self) -> bool {
if self.style != "warpCustom" {
return true;
}
let n = self.cols.checked_mul(3).zip(self.rows.checked_mul(3)).and_then(|(c, r)| c.checked_add(1)?.checked_mul(r.checked_add(1)?));
self.cols > 0 && self.rows > 0 && n == Some(self.mesh.len())
}
/// A custom warp of more than one patch: `SoLd` stores it as `quiltWarp`, `PlLd` cannot.
fn is_split(&self) -> bool {
self.style == "warpCustom" && self.mesh_is_valid() && (self.cols, self.rows) != (1, 1)
}
/// No warp over `bounds`.
fn none(bounds: [f64; 4]) -> Warp {
Warp { style: "warpNone".into(), bounds, cols: 1, rows: 1, ..Warp::default() }
}
/// Style, bend, distortions, axis, bounds and order: the keys a `warp` and a `quiltWarp`
/// share, in Photoshop's order.
fn head(&self, class: &str) -> Descriptor {
let [l, t, r, b] = self.bounds;
let bounds = Descriptor::new("classFloatRect")
.with("Top ", Value::Double(t))
.with("Left", Value::Double(l))
.with("Btom", Value::Double(b))
.with("Rght", Value::Double(r));
Descriptor::new(class)
.with("warpStyle", enumerated("warpStyle", &self.style))
.with("warpValue", Value::Double(self.bend))
.with("warpPerspective", Value::Double(self.horizontal))
.with("warpPerspectiveOther", Value::Double(self.vertical))
.with("warpRotate", enumerated("Ornt", if self.rotate_vertical { "Vrtc" } else { "Hrzn" }))
.with("bounds", Value::Descriptor(bounds))
.with("uOrder", Value::Integer(4))
.with("vOrder", Value::Integer(4))
}
fn mesh_points(&self) -> Value {
let unfl = |values: Vec<f64>| Value::UnitFloats { unit: *b"#Pxl", values };
let body = Descriptor::new("rationalPoint")
.with("Hrzn", unfl(self.mesh.iter().map(|p| p[0]).collect()))
.with("Vrtc", unfl(self.mesh.iter().map(|p| p[1]).collect()));
Value::ObjectArray(ObjectArray { prefix: self.mesh.len() as u32, body })
}
/// The `warp` descriptor. A one-patch custom warp holds its mesh here; a split one is written
/// as no warp (`SoLd` keeps it in [`Self::quilt_descriptor`]), and so is a custom warp whose
/// mesh is not valid ([`Self::mesh_is_valid`]).
pub fn to_descriptor(&self) -> Descriptor {
if self.style == "warpCustom" && (self.is_split() || !self.mesh_is_valid()) {
return Warp::none(self.bounds).head("warp");
}
let d = self.head("warp");
if self.style != "warpCustom" {
return d;
}
d.with("customEnvelopeWarp", Value::Descriptor(Descriptor::new("customEnvelopeWarp").with("meshPoints", self.mesh_points())))
}
/// The `quiltWarp` descriptor of a split warp, in Photoshop's layout: the control points,
/// `deformNumCols` × `deformNumRows` of them, and the patch boundaries (`quiltSliceX` /
/// `quiltSliceY`) evenly across the warp's bounds. `None` for any other warp.
pub fn quilt_descriptor(&self) -> Option<Descriptor> {
if !self.is_split() {
return None;
}
let [l, t, r, b] = self.bounds;
let slices = |key: &str, n: usize, lo: f64, hi: f64| {
let (lo, hi) = if (hi - lo).is_finite() && hi > lo { (lo, hi) } else { (0.0, 1.0) };
let values: Vec<f64> = (0..=n).map(|i| lo + (hi - lo) * i as f64 / n as f64).collect();
let prefix = values.len() as u32;
Value::ObjectArray(ObjectArray { prefix, body: Descriptor::new("UntF").with(key, Value::UnitFloats { unit: *b"#Pxl", values }) })
};
let envelope = Descriptor::new("customEnvelopeWarp")
.with("quiltSliceX", slices("quiltSliceX", self.cols, l, r))
.with("quiltSliceY", slices("quiltSliceY", self.rows, t, b))
.with("meshPoints", self.mesh_points());
let count = |n: usize| Value::Integer(i32::try_from(n.saturating_mul(3).saturating_add(1)).unwrap_or(i32::MAX));
Some(
self.head("quiltWarp")
.with("deformNumRows", count(self.rows))
.with("deformNumCols", count(self.cols))
.with("customEnvelopeWarp", Value::Descriptor(envelope)),
)
}
}
impl SmartObject {
fn quad_value(&self) -> Value {
Value::List(self.quad.iter().flatten().map(|&v| Value::Double(v)).collect())
}
/// Whether the quad differs from the `Trnf` a template stores (beyond rounding).
fn quad_moved(&self, template: &Descriptor) -> bool {
let old = template.get_numbers("Trnf");
old.len() != 8 || old.iter().zip(self.quad.iter().flatten()).any(|(a, b)| (a - b).abs() > 1e-6 * (1.0 + a.abs()))
}
/// The warp written, over the content (or `[0, 0, 0, 0]` without a size) when there is none.
fn warp_or_none(&self) -> Warp {
let [w, h] = self.size.unwrap_or([0.0; 2]);
self.warp.clone().unwrap_or_else(|| Warp::none([0.0, 0.0, w, h]))
}
/// The data of a `SoLd` block (`soLD`, version 4, a version-16 descriptor, padded to 4).
/// `Trnf` and `nonAffineTransform` are both the quad; a split warp goes in `quiltWarp` with
/// `warp` holding no warp, as Photoshop writes it.
pub fn to_placed_layer_data(&self, options: &PlacedLayerOptions<'_>) -> Vec<u8> {
let warp = self.warp_or_none();
let quilt = warp.quilt_descriptor();
let d = match options.template {
Some(t) => {
let mut d = t.clone();
if t.get_text("placed").as_deref() != Some(options.placed) {
set(&mut d, "placed", Value::Text(UnicodeString::new_nul(options.placed)));
}
if self.quad_moved(t) {
set(&mut d, "Trnf", self.quad_value());
set(&mut d, "nonAffineTransform", self.quad_value());
}
set(&mut d, "warp", Value::Descriptor(warp.to_descriptor()));
// The imported `quiltWarp` stays while it still says what the warp is.
let kept = t.get_object("quiltWarp").is_some_and(|q| Warp::from_descriptor(q).as_ref() == self.warp.as_ref());
if !kept {
match quilt {
Some(q) => set_before(&mut d, "quiltWarp", Value::Descriptor(q), "warp"),
None => d.items.retain(|(k, _)| !k.is("quiltWarp")),
}
}
match options.filter_fx {
Some(fx) => set(&mut d, "filterFX", Value::Descriptor(fx.clone())),
None => d.items.retain(|(k, _)| !k.is("filterFX")),
}
d
}
None => {
let mut d = Descriptor::new("null")
.with("Idnt", Value::Text(UnicodeString::new_nul(&self.uuid)))
.with("placed", Value::Text(UnicodeString::new_nul(options.placed)))
.with("PgNm", Value::Integer(1))
.with("totalPages", Value::Integer(1))
.with("Crop", Value::Integer(1))
.with("frameStep", ratio(0, 600))
.with("duration", ratio(0, 600))
.with("frameCount", Value::Integer(1))
.with("Annt", Value::Integer(16))
.with("Type", Value::Integer(2))
.with("Trnf", self.quad_value())
.with("nonAffineTransform", self.quad_value());
if let Some(q) = quilt {
d = d.with("quiltWarp", Value::Descriptor(q));
}
d = d.with("warp", Value::Descriptor(warp.to_descriptor()));
if let Some([w, h]) = self.size {
d = d.with("Sz ", Value::Descriptor(Descriptor::new("Pnt ").with("Wdth", Value::Double(w)).with("Hght", Value::Double(h))));
}
d = d.with("Rslt", Value::UnitFloat { unit: *b"#Rsl", value: options.resolution });
if let Some(fx) = options.filter_fx {
d = d.with("filterFX", Value::Descriptor(fx.clone()));
}
d.with("comp", Value::Integer(-1)).with(
"compInfo",
Value::Descriptor(Descriptor::new("null").with("compID", Value::Integer(-1)).with("originalCompID", Value::Integer(-1))),
)
}
};
let mut out = b"soLD".to_vec();
out.put_u32(4);
VersionedDescriptor::new(d).write(&mut out);
pad4(out)
}
/// The data of a `PlLd` block (`plcL`, version 3), the legacy placed-layer record Photoshop
/// writes next to `SoLd`: the id (cut to 255 bytes), page 1 of 1, anti-aliasing, a raster
/// layer, the quad and the warp. A split warp cannot be stored here and is written as none.
pub fn to_placed_layer(&self) -> Vec<u8> {
let mut warp = self.warp_or_none();
if warp.is_split() {
warp = Warp::none(warp.bounds);
}
let mut out = b"plcL".to_vec();
out.put_u32(3);
let id = self.uuid.as_bytes().get(..self.uuid.len().min(255)).unwrap_or_default();
out.put_u8(id.len() as u8);
out.put(id);
for v in [1i32, 1, 16, 2] {
out.put_i32(v);
}
for &v in self.quad.iter().flatten() {
out.put_f64(v);
}
out.put_u32(0);
VersionedDescriptor::new(warp.to_descriptor()).write(&mut out);
pad4(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tagged::TaggedBlock;
fn grid(cols: usize, rows: usize, w: f64, h: f64) -> Vec<[f64; 2]> {
let (nx, ny) = (3 * cols + 1, 3 * rows + 1);
(0..ny).flat_map(|j| (0..nx).map(move |i| [i as f64 * w / (nx - 1) as f64 + (j % 2) as f64, j as f64 * h / (ny - 1) as f64])).collect()
}
fn placed(warp: Option<Warp>) -> SmartObject {
SmartObject { uuid: "file-id".into(), quad: [[10.0, 20.0], [110.5, 20.0], [110.5, 70.25], [10.0, 70.25]], size: Some([200.0, 100.0]), warp }
}
fn warps() -> Vec<Option<Warp>> {
let bounds = [0.0, 0.0, 200.0, 100.0];
vec![
None,
Some(Warp {
style: "warpArc".into(),
bend: 25.0,
horizontal: -10.0,
vertical: 5.0,
rotate_vertical: true,
bounds,
cols: 1,
rows: 1,
mesh: vec![],
}),
Some(Warp { style: "warpCustom".into(), bounds, cols: 1, rows: 1, mesh: grid(1, 1, 200.0, 100.0), ..Warp::default() }),
Some(Warp { style: "warpCustom".into(), bounds, cols: 3, rows: 2, mesh: grid(3, 2, 200.0, 100.0), ..Warp::default() }),
]
}
#[test]
fn placed_layer_data_reads_back_and_passes_the_strict_check() {
let filter = Descriptor::new("filterFX")
.with("Nm ", Value::Text(UnicodeString::new_nul("Gaussian Blur")))
.with("blendOptions", Value::Descriptor(Descriptor::new("blendOptions")))
.with("enab", Value::Boolean(true))
.with("filterID", Value::Integer(1197632612));
let fx =
Descriptor::new("filterFXStyle").with("enab", Value::Boolean(true)).with("filterFXList", Value::List(vec![Value::Descriptor(filter)]));
for warp in warps() {
let so = placed(warp.clone());
for filter_fx in [None, Some(&fx)] {
let data = so.to_placed_layer_data(&PlacedLayerOptions { placed: "layer-id", resolution: 300.0, filter_fx, template: None });
assert_eq!(data.len() % 4, 0);
assert_eq!(SmartObject::from_placed_layer_data(&data).unwrap(), so, "{warp:?}");
TaggedBlock::new(*b"SoLd", data.clone()).check_structure().unwrap();
let d = VersionedDescriptor::parse_prefix(&data[8..]).unwrap().0.descriptor;
assert_eq!(d.get_text("placed").as_deref(), Some("layer-id"));
assert_eq!(d.get("Rslt"), Some(&Value::UnitFloat { unit: *b"#Rsl", value: 300.0 }));
assert_eq!(d.get("filterFX").is_some(), filter_fx.is_some());
// A split warp is in `quiltWarp`, before a `warp` that holds none.
let split = warp.as_ref().is_some_and(|w| (w.cols, w.rows) != (1, 1));
assert_eq!(d.get("quiltWarp").is_some(), split);
if split {
assert_eq!(d.get_object("warp").and_then(|w| w.get_enum("warpStyle")).as_deref(), Some("warpNone"));
let keys: Vec<&[u8]> = d.items.iter().map(|(k, _)| k.as_bytes()).collect();
assert!(keys.iter().position(|k| *k == b"quiltWarp") < keys.iter().position(|k| *k == b"warp"));
}
}
}
}
#[test]
fn placed_layer_reads_back_without_a_split_warp() {
for warp in warps() {
let so = placed(warp.clone());
let data = so.to_placed_layer();
assert_eq!(data.len() % 4, 0);
let back = SmartObject::from_placed_layer(&data).unwrap();
assert_eq!((back.uuid.as_str(), back.quad), ("file-id", so.quad));
TaggedBlock::new(*b"PlLd", data.clone()).check_structure().unwrap();
// The warp after the record: the same warp, or none for a split one.
// Kind, version, the id, page fields, the quad and the warp version come first.
let at = 4 + 4 + 1 + so.uuid.len() + 16 + 64 + 4;
let stored = Warp::from_descriptor(&VersionedDescriptor::parse_prefix(&data[at..]).unwrap().0.descriptor);
let split = warp.as_ref().is_some_and(|w| (w.cols, w.rows) != (1, 1));
assert_eq!(stored, if split { None } else { warp });
}
let long = SmartObject { uuid: "x".repeat(300), ..placed(None) };
assert_eq!(SmartObject::from_placed_layer(&long.to_placed_layer()).unwrap().uuid, "x".repeat(255));
}
#[test]
fn a_custom_warp_with_a_wrong_sized_mesh_is_written_as_none() {
let bad = Warp {
style: "warpCustom".into(),
bounds: [0.0, 0.0, 200.0, 100.0],
cols: 2,
rows: 1,
mesh: grid(1, 1, 200.0, 100.0),
..Warp::default()
};
assert!(!bad.mesh_is_valid() && bad.quilt_descriptor().is_none());
assert_eq!(bad.to_descriptor().get_enum("warpStyle").as_deref(), Some("warpNone"));
let so = placed(Some(bad));
let data = so.to_placed_layer_data(&PlacedLayerOptions { placed: "p", resolution: 72.0, ..Default::default() });
assert_eq!(SmartObject::from_placed_layer_data(&data).unwrap().warp, None);
assert!(Warp { cols: 0, ..Warp::default() }.mesh_is_valid(), "not custom");
assert!(!Warp { style: "warpCustom".into(), cols: usize::MAX, rows: usize::MAX, ..Warp::default() }.mesh_is_valid());
}
#[test]
fn a_template_keeps_its_keys_and_rewrites_only_what_changed() {
let so = placed(warps()[1].clone());
let fresh = so.to_placed_layer_data(&PlacedLayerOptions { placed: "layer-id", resolution: 300.0, ..Default::default() });
let mut template = VersionedDescriptor::parse_prefix(&fresh[8..]).unwrap().0.descriptor;
template.items.push((Id::new("vendorKey"), Value::Integer(7)));
let same = PlacedLayerOptions { placed: "layer-id", resolution: 300.0, filter_fx: None, template: Some(&template) };
// Unchanged: the template's own bytes come back.
let mut expected = b"soLD".to_vec();
expected.put_u32(4);
VersionedDescriptor::new(template.clone()).write(&mut expected);
assert_eq!(so.to_placed_layer_data(&same), pad4(expected));
// A tiny rounding difference does not rewrite the transform.
let mut nudged = so.clone();
nudged.quad[0][0] += 1e-9;
assert_eq!(nudged.to_placed_layer_data(&same), so.to_placed_layer_data(&same));
// Moved, warped differently, duplicated: those keys change, the vendor key stays.
let mut moved = placed(warps()[3].clone());
moved.quad[1] = [150.0, 25.0];
let data = moved.to_placed_layer_data(&PlacedLayerOptions { placed: "copy-id", ..same });
let d = VersionedDescriptor::parse_prefix(&data[8..]).unwrap().0.descriptor;
assert_eq!(d.get("vendorKey"), Some(&Value::Integer(7)));
assert_eq!(d.get_text("placed").as_deref(), Some("copy-id"));
assert_eq!(SmartObject::from_placed_layer_data(&data).unwrap(), moved);
// Back to no split warp: `quiltWarp` goes.
let unsplit = so.to_placed_layer_data(&PlacedLayerOptions { template: Some(&d), ..same });
assert!(VersionedDescriptor::parse_prefix(&unsplit[8..]).unwrap().0.descriptor.get("quiltWarp").is_none());
}
}