mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-02 22:30:20 -06:00
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.
This commit is contained in:
+187
-14
@@ -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<photocraft_geom::warp::Warp> {
|
||||
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<Option<photocraft_geom::warp::Warp>> {
|
||||
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<Option<photocraft_geom::warp::Warp>> {
|
||||
_ => 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<photocraft_geom::warp::BezierMesh> {
|
||||
// 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<usize> {
|
||||
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<Vec<f64>> {
|
||||
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<f64>, patches: usize| -> Option<Vec<f64>> {
|
||||
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<Descriptor> {
|
||||
}
|
||||
|
||||
/// 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<u8> {
|
||||
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]
|
||||
|
||||
Reference in New Issue
Block a user