From 4011f9ffd4b54a71c452fc4229ebe03d7b539af5 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Wed, 11 Nov 2026 21:37:35 -0500 Subject: [PATCH] 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 --- crates/psd/src/smart.rs | 19 +- crates/psd/src/smart/write.rs | 391 ++++++++++++++++++++++++++++++++++ 2 files changed, 407 insertions(+), 3 deletions(-) create mode 100644 crates/psd/src/smart/write.rs diff --git a/crates/psd/src/smart.rs b/crates/psd/src/smart.rs index 6781c7156..a8f778ccd 100644 --- a/crates/psd/src/smart.rs +++ b/crates/psd/src/smart.rs @@ -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 { + 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 }) } diff --git a/crates/psd/src/smart/write.rs b/crates/psd/src/smart/write.rs new file mode 100644 index 000000000..d409d726c --- /dev/null +++ b/crates/psd/src/smart/write.rs @@ -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) -> Vec { + 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| 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 { + 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 = (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 { + 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 { + 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) -> 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> { + 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()); + } +}