From aaf86e2a9cc64223fbb8a192b3f1b49aa76d5ea5 Mon Sep 17 00:00:00 2001 From: Thang Nguyen Date: Fri, 16 Oct 2026 07:39:12 +0700 Subject: [PATCH] fix(psd): read the split ("quilt") warp of placed layers Photoshop writes a split custom warp as `quiltWarp` in the placed layer's SoLd/SoLE descriptor: a grid of Bezier patches (deformNumCols x deformNumRows control points, patch boundaries in quiltSliceX/Y). Only a single 4x4 `warp` mesh was read, so such smart objects rendered unwarped and in the wrong place. Read the quilt grid into a multi-patch BezierMesh; anything inconsistent is still refused. On two real mockups (a sneaker and a V-neck jersey), the mean difference from another PSD renderer over the design area drops from 14.4 to 0.66/255 and from 13.0 to 0.64/255. Refs #4398. --- crates/io/src/blocks.rs | 201 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 187 insertions(+), 14 deletions(-) diff --git a/crates/io/src/blocks.rs b/crates/io/src/blocks.rs index 87fda102fe..c03d7a6b25 100644 --- a/crates/io/src/blocks.rs +++ b/crates/io/src/blocks.rs @@ -525,21 +525,24 @@ pub(crate) fn with_pattern_placement(mut d: Descriptor, angle: f32, link: bool, ) } -/// The warp of a placed layer (`SoLd`/`SoLE` `warp` descriptor): style, bend, distortions, -/// axis, bounds and, for `warpCustom`, the 4 × 4 `customEnvelopeWarp` mesh. Read-only: the raw -/// block is what gets written back. `None` when absent or `warpNone` with no mesh. A `PlLd` +/// The warp of a placed layer (`SoLd`/`SoLE` `quiltWarp` or `warp` descriptor): style, bend, +/// distortions, axis, bounds and, for `warpCustom`, the `customEnvelopeWarp` mesh: one 4 × 4 +/// patch, or the multi-patch grid Photoshop's split warp writes as `quiltWarp`. Read-only: the +/// raw block is what gets written back. `None` when absent or `warpNone` with no mesh. A `PlLd` /// record keeps its warp elsewhere: see [`parse_plld_warp`]. pub fn parse_placed_warp(key: &[u8; 4], data: &[u8]) -> Option { if key != b"SoLd" && key != b"SoLE" { return None; } let d = data.get(8..).and_then(parse_prefix_versioned)?; - warp_of(get_desc(&d, "warp")?).flatten() + // A split (quilt) warp is stored under `quiltWarp`; `warp` then holds no usable mesh. + warp_of(get_desc(&d, "quiltWarp").or_else(|| get_desc(&d, "warp"))?).flatten() } -/// A `warp` descriptor (the `SoLd` key, or the whole descriptor after a `PlLd` record's corners) -/// as a warp: `Some(None)` for no warp (`warpNone`, or a warp that moves nothing), `None` when it -/// can't be read (an unknown style, missing bounds, a mesh that isn't 4 × 4). +/// A `warp` or `quiltWarp` descriptor (the `SoLd` key, or the whole descriptor after a `PlLd` +/// record's corners) as a warp: `Some(None)` for no warp (`warpNone`, or a warp that moves +/// nothing), `None` when it can't be read (an unknown style, missing bounds, a mesh that is +/// neither one 4 × 4 patch nor a consistent quilt grid). fn warp_of(w: &Descriptor) -> Option> { use photocraft_geom::warp::{BezierMesh, Warp, WarpStyle}; let style = enum_of(w, "warpStyle").and_then(|v| WarpStyle::parse(&String::from_utf8_lossy(v)))?; @@ -561,16 +564,76 @@ fn warp_of(w: &Descriptor) -> Option> { _ => None, }; let (xs, ys) = (vals("Hrzn")?, vals("Vrtc")?); - if xs.len() != 16 || ys.len() != 16 { + if xs.len() != ys.len() { return None; } - let mesh = BezierMesh { us: vec![0.0, 1.0], vs: vec![0.0, 1.0], points: xs.iter().zip(&ys).map(|(x, y)| [*x, *y]).collect() }; + let points: Vec<[f64; 2]> = xs.iter().zip(&ys).map(|(x, y)| [*x, *y]).collect(); + let mesh = if points.len() == 16 && env.get("quiltSliceX").is_none() { + BezierMesh { us: vec![0.0, 1.0], vs: vec![0.0, 1.0], points } + } else { + quilt_mesh(w, env, points)? + }; warp.mesh = mesh.is_valid().then_some(mesh); warp.mesh.as_ref()?; } Some((!warp.is_identity()).then_some(warp)) } +/// The control-point grid of a split ("quilt") custom warp. `deformNumCols` × `deformNumRows` +/// control points, three per patch side plus one, in row-major order; `quiltSliceX` and +/// `quiltSliceY` are the patch boundaries in the mesh's own units, first and last at its edges, +/// which become the knots on the unit square. `None` for anything inconsistent. +fn quilt_mesh(w: &Descriptor, env: &Descriptor, points: Vec<[f64; 2]>) -> Option { + // Photoshop's split grid tops out far below this; it only bounds what a file can claim. + const MAX_SIDE: usize = 3 * 64 + 1; + let count = |k: &str| -> Option { + let n = num(w.get(k))?; + (n.is_finite() && n >= 4.0 && n <= MAX_SIDE as f64 && n.fract() == 0.0).then_some(n as usize) + }; + let (cols, rows) = (count("deformNumCols")?, count("deformNumRows")?); + if (cols - 1) % 3 != 0 || (rows - 1) % 3 != 0 || points.len() != cols.checked_mul(rows)? { + return None; + } + // Stored as an object array holding one unit-float list under the same key; a plain list + // of numbers is taken too. + let slices = |k: &str| -> Option> { + match env.get(k)? { + Value::ObjectArray(arr) => match arr.body.get(k)? { + Value::UnitFloats { values, .. } => Some(values.clone()), + Value::List(l) => l.iter().map(|v| num(Some(v))).collect(), + _ => None, + }, + Value::List(l) => l.iter().map(|v| num(Some(v))).collect(), + _ => None, + } + }; + let knots = |s: Vec, patches: usize| -> Option> { + let (first, last) = (*s.first()?, *s.last()?); + let span = last - first; + if s.len() != patches + 1 || !(span.is_finite() && span > 0.0) || !s.windows(2).all(|p| p[1] > p[0]) { + return None; + } + let n = s.len() - 1; + Some( + s.iter() + .enumerate() + .map(|(i, v)| { + if i == 0 { + 0.0 + } else if i == n { + 1.0 + } else { + (v - first) / span + } + }) + .collect(), + ) + }; + let us = knots(slices("quiltSliceX")?, (cols - 1) / 3)?; + let vs = knots(slices("quiltSliceY")?, (rows - 1) / 3)?; + Some(photocraft_geom::warp::BezierMesh { us, vs, points }) +} + /// Text content and transform from a `TySh` block (best effort). pub fn parse_tysh(data: &[u8]) -> Option<(String, Affine)> { let f = |at: usize| data.get(at..at + 8).map(|b| f64::from_be_bytes(b.try_into().unwrap_or([0; 8]))); @@ -593,13 +656,12 @@ pub(crate) fn parse_prefix_versioned(data: &[u8]) -> Option { } /// The source-space box Photoshop maps onto a placed layer's `Trnf` quad: the bounding box of a -/// single-patch custom warp's 16 control points, which may spill past the source, else the source -/// rect `0..w × 0..h`. Mapping the source rect instead counts the overhang twice and stretches the -/// placement. A degenerate or non-finite box falls back to the source rect. +/// custom warp's control points (one patch or a split grid), which may spill past the source, +/// else the source rect `0..w × 0..h`. Mapping the source rect instead counts the overhang twice +/// and stretches the placement. A degenerate or non-finite box falls back to the source rect. pub(crate) fn placed_frame(warp: Option<&photocraft_geom::warp::Warp>, w: f64, h: f64) -> [f64; 4] { let rect = [0.0, 0.0, w, h]; - let Some(m) = warp.filter(|wp| wp.style == photocraft_geom::warp::WarpStyle::Custom).and_then(|wp| wp.mesh.as_ref()).filter(|m| m.points.len() == 16) - else { + let Some(m) = warp.filter(|wp| wp.style == photocraft_geom::warp::WarpStyle::Custom).and_then(|wp| wp.mesh.as_ref()).filter(|m| m.is_valid()) else { return rect; }; let inf = f64::INFINITY; @@ -1212,6 +1274,117 @@ mod more_tests { data } + /// `SoLd` data with a split (quilt) custom warp, as Photoshop writes it: a `quiltWarp` + /// descriptor with `deformNumCols` × `deformNumRows` control points and the patch boundaries + /// in `quiltSliceX` / `quiltSliceY`. With `plain_warp`, a `warpNone` `warp` key sits next to it. + fn sold_with_quilt(trnf: [f64; 8], cols: f64, rows: f64, sx: &[f64], sy: &[f64], pts: &[[f64; 2]], plain_warp: bool) -> Vec { + use photocraft_psd::descriptor::ObjectArray; + let e = |t: &str, v: &str| Value::Enumerated { type_id: Id::new(t), value: Id::new(v) }; + let bounds = || { + Descriptor::new("Rctn") + .with("Top ", Value::Double(0.0)) + .with("Left", Value::Double(0.0)) + .with("Btom", Value::Double(25.0)) + .with("Rght", Value::Double(50.0)) + }; + let slices = |k: &str, v: &[f64]| { + let body = Descriptor::new("null").with(k, Value::UnitFloats { unit: *b"#Pxl", values: v.to_vec() }); + Value::ObjectArray(ObjectArray { prefix: v.len() as u32, body }) + }; + let mesh = Descriptor::new("null") + .with("Hrzn", Value::UnitFloats { unit: *b"#Pxl", values: pts.iter().map(|p| p[0]).collect() }) + .with("Vrtc", Value::UnitFloats { unit: *b"#Pxl", values: pts.iter().map(|p| p[1]).collect() }); + let envelope = Descriptor::new("customEnvelopeWarp") + .with("quiltSliceX", slices("quiltSliceX", sx)) + .with("quiltSliceY", slices("quiltSliceY", sy)) + .with("meshPoints", Value::ObjectArray(ObjectArray { prefix: 16, body: mesh })); + let quilt = Descriptor::new("quiltWarp") + .with("warpStyle", e("warpStyle", "warpCustom")) + .with("bounds", Value::Descriptor(bounds())) + .with("deformNumRows", Value::Integer(rows as i32)) + .with("deformNumCols", Value::Double(cols)) + .with("customEnvelopeWarp", Value::Descriptor(envelope)); + let mut d = Descriptor::new("null") + .with("Idnt", Value::Text(UnicodeString::new_nul("uuid-1"))) + .with("Trnf", Value::List(trnf.map(Value::Double).to_vec())) + .with("Sz ", Value::Descriptor(Descriptor::new("Pnt ").with("Wdth", Value::Double(50.0)).with("Hght", Value::Double(25.0)))); + if plain_warp { + d = d.with( + "warp", + Value::Descriptor(Descriptor::new("warp").with("warpStyle", e("warpStyle", "warpNone")).with("bounds", Value::Descriptor(bounds()))), + ); + } + d = d.with("quiltWarp", Value::Descriptor(quilt)); + let mut data = b"soLD".to_vec(); + data.extend_from_slice(&4u32.to_be_bytes()); + data.extend(VersionedDescriptor::new(d).to_bytes()); + data + } + + /// A 3 × 2 patch identity grid over the 50 × 25 source with one interior control point pulled. + fn quilt_points() -> Vec<[f64; 2]> { + let mut pts = photocraft_geom::warp::BezierMesh::identity([0.0, 0.0, 50.0, 25.0], 3, 2).points; + if let Some(p) = pts.get_mut(10 * 3 + 4) { + p[1] += 3.0; + } + pts + } + + /// Photoshop's split warp (`quiltWarp`, mockups' usual warp) reads as a multi-patch mesh: the + /// patch boundaries become the knots, the control points stay in order, and the quilt wins over + /// the `warpNone` `warp` key written next to it. Before, any mesh but 4 × 4 was dropped and the + /// layer re-rendered flat. + #[test] + fn quilt_warp_is_read_as_a_multi_patch_mesh() { + let quad = [100.0, 200.0, 240.0, 200.0, 240.0, 260.0, 100.0, 260.0]; + let pts = quilt_points(); + // Photoshop pads the outer boundaries a little past the grid's edges. + let data = sold_with_quilt(quad, 10.0, 7.0, &[-0.5, 12.5, 37.5, 50.5], &[-0.5, 12.5, 25.5], &pts, true); + let warp = parse_placed_warp(b"SoLd", &data).expect("a quilt warp"); + let mesh = warp.mesh.as_ref().expect("its mesh"); + assert_eq!(mesh.points, pts); + assert_eq!(mesh.us.len(), 4); + assert_eq!(mesh.vs.len(), 3); + assert_eq!((mesh.us[0], mesh.us[3], mesh.vs[0], mesh.vs[2]), (0.0, 1.0, 0.0, 1.0)); + assert!((mesh.us[1] - 13.0 / 51.0).abs() < 1e-12 && (mesh.vs[1] - 13.0 / 26.0).abs() < 1e-12); + assert!(mesh.is_valid()); + } + + /// A quilt's control points frame the placed quad as a single patch's do (#3677): a grid + /// spilling to x = -10, y = -5 and x = 60 frames a 70 × 30 box. + #[test] + fn quilt_warp_control_box_frames_the_quad() { + let mut pts = quilt_points(); + pts[0] = [-10.0, -5.0]; + if let Some(last) = pts.last_mut() { + *last = [60.0, 25.0]; + } + let quad = [100.0, 200.0, 240.0, 200.0, 240.0, 260.0, 100.0, 260.0]; + let data = sold_with_quilt(quad, 10.0, 7.0, &[0.0, 12.5, 37.5, 50.0], &[0.0, 12.5, 25.0], &pts, false); + assert_eq!(parse_smart(b"SoLd", &data).1.m, [2.0, 0.0, 0.0, 2.0, 120.0, 210.0]); + } + + /// An inconsistent quilt reads as no warp rather than a wrong one, and never panics. + #[test] + fn inconsistent_quilt_warps_are_not_read() { + let quad = [100.0, 200.0, 240.0, 200.0, 240.0, 260.0, 100.0, 260.0]; + let pts = quilt_points(); + let (sx, sy) = ([0.0, 12.5, 37.5, 50.0], [0.0, 12.5, 25.0]); + let read = + |cols: f64, rows: f64, sx: &[f64], sy: &[f64], pts: &[[f64; 2]]| parse_placed_warp(b"SoLd", &sold_with_quilt(quad, cols, rows, sx, sy, pts, false)); + assert!(read(10.0, 7.0, &sx, &sy, &pts).is_some()); + assert!(read(9.0, 7.0, &sx, &sy, &pts).is_none(), "not three points per patch side plus one"); + assert!(read(13.0, 7.0, &sx, &sy, &pts).is_none(), "more points claimed than stored"); + assert!(read(10.0, 7.0, &[0.0, 12.5, 50.0], &sy, &pts).is_none(), "one slice per patch boundary"); + assert!(read(10.0, 7.0, &[0.0, 37.5, 12.5, 50.0], &sy, &pts).is_none(), "slices must increase"); + assert!(read(10.0, 7.0, &[0.0, f64::NAN, 37.5, 50.0], &sy, &pts).is_none()); + assert!(read(1e9, 7.0, &sx, &sy, &pts).is_none(), "an absurd grid is refused before any arithmetic"); + assert!(read(10.0, 7.0, &sx, &sy, &pts[..pts.len() - 1]).is_none()); + let mut nan = pts.clone(); + nan[3] = [f64::NAN, 0.0]; + assert!(read(10.0, 7.0, &sx, &sy, &nan).is_none()); + } + /// Photoshop's `Trnf` is the image of the custom warp's control-point bounding box, not of the /// source rect: a mesh spilling to x = -10, y = -5 and x = 60 frames a 70 × 30 box. #[test]