mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-14 06:22:24 +00:00
* 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>
1114 lines
53 KiB
Rust
1114 lines
53 KiB
Rust
//! Smart objects: the placed-layer blocks (`SoLd`, `SoLE`, `PlLd`), the files they show
|
||
//! (`lnk2`, `lnkD`, `lnk3`, `lnkE`), and the geometry of a placement.
|
||
//!
|
||
//! A placed layer names a linked file by its unique id and says where the file's corners sit in
|
||
//! the document: a quad, which is a projective map of the content rectangle when it is not a
|
||
//! parallelogram, and optionally a warp (`warp` in `SoLd`): one of the named styles with bend
|
||
//! and horizontal / vertical distortion, or a custom envelope whose quilt mesh is a grid of
|
||
//! bicubic Bézier patches.
|
||
//!
|
||
//! The custom mesh is evaluated exactly (bicubic Bézier patches over `meshPoints`, split by
|
||
//! `quiltSliceX` / `quiltSliceY`). Photoshop does not publish the curves of its named styles;
|
||
//! the styles here are clean-room geometric definitions of what each name describes (Arc,
|
||
//! Arch, Bulge, Shell, Flag, Wave, Fish, Rise, Fisheye, Inflate, Squeeze, Twist), matched by
|
||
//! behaviour, not numerically.
|
||
//!
|
||
//! The blocks themselves stay raw in the model ([`crate::TaggedBlock`]) and write back
|
||
//! unchanged; these are read-only views. Came from EffectCraft's `effectcraft-psd`.
|
||
|
||
use std::borrow::Cow;
|
||
use std::ops::Range;
|
||
|
||
use crate::descriptor::{Descriptor, UnicodeString, Value};
|
||
use crate::error::{PsdError, Result};
|
||
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;
|
||
|
||
/// A placed layer's warp.
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub struct Warp {
|
||
/// `warpNone`, `warpArc`, `warpArcLower`, `warpArcUpper`, `warpArch`, `warpBulge`,
|
||
/// `warpShellLower`, `warpShellUpper`, `warpFlag`, `warpWave`, `warpFish`, `warpRise`,
|
||
/// `warpFisheye`, `warpInflate`, `warpSqueeze`, `warpTwist`, `warpCustom`.
|
||
pub style: String,
|
||
/// Bend, −100..100 (%).
|
||
pub bend: f64,
|
||
/// Horizontal distortion, −100..100 (%).
|
||
pub horizontal: f64,
|
||
/// Vertical distortion, −100..100 (%).
|
||
pub vertical: f64,
|
||
/// The style is applied rotated a quarter turn (`warpRotate` = vertical).
|
||
pub rotate_vertical: bool,
|
||
/// The warped area in content pixels: left, top, right, bottom.
|
||
pub bounds: [f64; 4],
|
||
/// Custom envelope: patch columns.
|
||
pub cols: usize,
|
||
/// Custom envelope: patch rows.
|
||
pub rows: usize,
|
||
/// Custom envelope: the (3·cols+1)×(3·rows+1) control points (content pixels, row-major).
|
||
pub mesh: Vec<[f64; 2]>,
|
||
}
|
||
|
||
/// The eight numbers of a transform entry (`Trnf`, `nonAffineTransform`), when it has exactly eight.
|
||
fn eight(d: &Descriptor, key: &str) -> Option<[f64; 8]> {
|
||
d.get_numbers(key).try_into().ok()
|
||
}
|
||
|
||
/// The corner quad (top-left, top-right, bottom-right, bottom-left) from x0 y0 … x3 y3.
|
||
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).
|
||
pub fn from_descriptor(d: &Descriptor) -> Option<Warp> {
|
||
let style = d.get_enum("warpStyle").unwrap_or_else(|| "warpNone".to_string());
|
||
let side = |b: &Descriptor, k: &str| b.get_number(k).unwrap_or(0.0);
|
||
let bounds = d.get_object("bounds").map_or([0.0; 4], |b| [side(b, "Left"), side(b, "Top "), side(b, "Rght"), side(b, "Btom")]);
|
||
let mut w = Warp {
|
||
bend: d.get_number("warpValue").unwrap_or(0.0),
|
||
horizontal: d.get_number("warpPerspective").unwrap_or(0.0),
|
||
vertical: d.get_number("warpPerspectiveOther").unwrap_or(0.0),
|
||
rotate_vertical: d.get_enum("warpRotate").as_deref() == Some("Vrtc"),
|
||
bounds,
|
||
cols: 1,
|
||
rows: 1,
|
||
mesh: vec![],
|
||
style,
|
||
};
|
||
if let Some(env) = d.get_object("customEnvelopeWarp") {
|
||
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");
|
||
let n = cols.checked_mul(3).zip(rows.checked_mul(3)).and_then(|(c, r)| (c + 1).checked_mul(r + 1));
|
||
if n.is_some_and(|n| xs.len() == n && ys.len() == n) {
|
||
w.cols = cols;
|
||
w.rows = rows;
|
||
w.mesh = xs.into_iter().zip(ys).map(|(x, y)| [x, y]).collect();
|
||
}
|
||
}
|
||
}
|
||
let identity = w.style == "warpNone" || (w.style != "warpCustom" && w.bend == 0.0 && w.horizontal == 0.0 && w.vertical == 0.0);
|
||
let custom_ok = w.style != "warpCustom" || !w.mesh.is_empty();
|
||
(!identity && custom_ok).then_some(w)
|
||
}
|
||
|
||
/// The warp of a content point (content pixels → content pixels).
|
||
pub fn apply(&self, p: [f64; 2], w: f64, h: f64) -> [f64; 2] {
|
||
let [l, t, r, b] = if self.bounds[2] > self.bounds[0] && self.bounds[3] > self.bounds[1] { self.bounds } else { [0.0, 0.0, w, h] };
|
||
let (bw, bh) = (r - l, b - t);
|
||
let (u, v) = ((p[0] - l) / bw, (p[1] - t) / bh);
|
||
if self.style == "warpCustom" {
|
||
// A mesh that does not match cols × rows (public fields) leaves the point where it is.
|
||
return self.mesh_point(u.clamp(0.0, 1.0), v.clamp(0.0, 1.0)).unwrap_or(p);
|
||
}
|
||
// Styles work in a centred frame (pixels), optionally rotated a quarter turn.
|
||
let (x, y, fw, fh) = if self.rotate_vertical { (bh * (v - 0.5), -bw * (u - 0.5), bh, bw) } else { (bw * (u - 0.5), bh * (v - 0.5), bw, bh) };
|
||
let [mut x2, mut y2] = style_map(&self.style, self.bend / 100.0, x, y, fw, fh);
|
||
// Distortions: a perspective-like taper across the width (horizontal) or height.
|
||
let (hd, vd) =
|
||
if self.rotate_vertical { (self.vertical / 100.0, self.horizontal / 100.0) } else { (self.horizontal / 100.0, self.vertical / 100.0) };
|
||
let s = (x2 / (fw / 2.0)).clamp(-1.5, 1.5);
|
||
let tt = (y2 / (fh / 2.0)).clamp(-1.5, 1.5);
|
||
y2 *= 1.0 + hd * s;
|
||
x2 *= 1.0 + vd * tt;
|
||
let (ox, oy) = if self.rotate_vertical { (-y2, x2) } else { (x2, y2) };
|
||
[l + bw / 2.0 + ox, t + bh / 2.0 + oy]
|
||
}
|
||
|
||
/// A point of the custom quilt mesh at (u, v) ∈ [0, 1]².
|
||
fn mesh_point(&self, u: f64, v: f64) -> Option<[f64; 2]> {
|
||
let (cols, rows) = (self.cols, self.rows);
|
||
if cols == 0 || rows == 0 {
|
||
return None;
|
||
}
|
||
let pu = (u * cols as f64).min(cols as f64 - 1e-9);
|
||
let pv = (v * rows as f64).min(rows as f64 - 1e-9);
|
||
let (ci, cj) = (pu.floor() as usize, pv.floor() as usize);
|
||
let (fu, fv) = (pu - ci as f64, pv - cj as f64);
|
||
let stride = cols.checked_mul(3)?.checked_add(1)?;
|
||
let bern = |t: f64| {
|
||
let s = 1.0 - t;
|
||
[s * s * s, 3.0 * s * s * t, 3.0 * s * t * t, t * t * t]
|
||
};
|
||
let (bu, bv) = (bern(fu), bern(fv));
|
||
let mut out = [0.0; 2];
|
||
for (j, bvj) in bv.iter().enumerate() {
|
||
for (i, bui) in bu.iter().enumerate() {
|
||
let idx = cj.saturating_mul(3).saturating_add(j).saturating_mul(stride).saturating_add(ci.saturating_mul(3)).saturating_add(i);
|
||
let q = self.mesh.get(idx)?;
|
||
let k = bui * bvj;
|
||
out[0] += q[0] * k;
|
||
out[1] += q[1] * k;
|
||
}
|
||
}
|
||
Some(out)
|
||
}
|
||
}
|
||
|
||
/// A named style in a centred frame `fw`×`fh` (y down), bend `b` ∈ [−1, 1].
|
||
fn style_map(style: &str, b: f64, x: f64, y: f64, fw: f64, fh: f64) -> [f64; 2] {
|
||
use std::f64::consts::PI;
|
||
let (hw, hh) = (fw / 2.0, fh / 2.0);
|
||
let s = x / hw; // −1..1 across
|
||
let t = y / hh; // −1..1 down
|
||
// Concentric arcs: the midline bends through an angle of b·π, rows keep their distance.
|
||
let arc = |x: f64, y: f64| -> [f64; 2] {
|
||
let theta = b * PI;
|
||
if theta.abs() < 1e-9 {
|
||
return [x, y];
|
||
}
|
||
let r = fw / theta;
|
||
let phi = x / hw * theta / 2.0;
|
||
let rr = r - y;
|
||
// Arc centre below the shape for upward bends; keep the midline's centre fixed.
|
||
[rr * phi.sin(), r - rr * phi.cos()]
|
||
};
|
||
let bump = 1.0 - s * s;
|
||
match style {
|
||
"warpArc" => arc(x, y),
|
||
"warpArcLower" => {
|
||
let a = arc(x, y);
|
||
let k = (t + 1.0) / 2.0;
|
||
[x + (a[0] - x) * k, y + (a[1] - y) * k]
|
||
}
|
||
"warpArcUpper" => {
|
||
let a = arc(x, y);
|
||
let k = (1.0 - t) / 2.0;
|
||
[x + (a[0] - x) * k, y + (a[1] - y) * k]
|
||
}
|
||
"warpArch" => [x, y - b * hh * bump],
|
||
"warpBulge" => [x, y + t * b * hh * bump],
|
||
"warpShellLower" => [x * (1.0 - b * 0.25 * (1.0 - t) / 2.0), y + (t + 1.0) / 2.0 * b * hh * bump],
|
||
"warpShellUpper" => [x * (1.0 - b * 0.25 * (t + 1.0) / 2.0), y - (1.0 - t) / 2.0 * b * hh * bump],
|
||
"warpFlag" => [x, y - b * hh * 0.5 * (PI * s).sin()],
|
||
"warpWave" => [x + b * hw * 0.1 * (PI * t).sin(), y - b * hh * 0.5 * (PI * s).sin() * (1.0 - 0.5 * t)],
|
||
"warpFish" => [x, y + t * b * hh * (PI * (s + 1.0) / 2.0).sin() * (1.0 - 0.5 * (s + 1.0) / 2.0)],
|
||
"warpRise" => [x, y - b * hh * (PI * s / 2.0).sin()],
|
||
"warpFisheye" | "warpInflate" => {
|
||
let k = if style == "warpFisheye" { (1.0 - (s * s + t * t) / 2.0).max(0.0) } else { 1.0 };
|
||
[x * (1.0 + b * 0.5 * k * (1.0 - t * t)), y * (1.0 + b * 0.5 * k * bump)]
|
||
}
|
||
"warpSqueeze" => [x * (1.0 - b * 0.5 * (1.0 - t * t)), y * (1.0 + b * 0.5 * bump)],
|
||
"warpTwist" => {
|
||
let r = ((s * s + t * t) / 2.0).sqrt().min(1.0);
|
||
let a = b * PI / 2.0 * (1.0 - r);
|
||
let (sn, cs) = a.sin_cos();
|
||
[x * cs - y * sn, x * sn + y * cs]
|
||
}
|
||
_ => [x, y],
|
||
}
|
||
}
|
||
|
||
/// The projective map taking the unit square's corners (0,0), (1,0), (1,1), (0,1) to `q`
|
||
/// (Heckbert's square-to-quad), as a 3×3 matrix applied to (u, v, 1).
|
||
pub fn square_to_quad(q: [[f64; 2]; 4]) -> [[f64; 3]; 3] {
|
||
let [p0, p1, p2, p3] = q;
|
||
let sx = p0[0] - p1[0] + p2[0] - p3[0];
|
||
let sy = p0[1] - p1[1] + p2[1] - p3[1];
|
||
if sx.abs() < 1e-12 && sy.abs() < 1e-12 {
|
||
return [[p1[0] - p0[0], p2[0] - p1[0], p0[0]], [p1[1] - p0[1], p2[1] - p1[1], p0[1]], [0.0, 0.0, 1.0]];
|
||
}
|
||
let (dx1, dx2) = (p1[0] - p2[0], p3[0] - p2[0]);
|
||
let (dy1, dy2) = (p1[1] - p2[1], p3[1] - p2[1]);
|
||
let den = dx1 * dy2 - dx2 * dy1;
|
||
let g = (sx * dy2 - dx2 * sy) / den;
|
||
let h = (dx1 * sy - sx * dy1) / den;
|
||
[[p1[0] - p0[0] + g * p1[0], p3[0] - p0[0] + h * p3[0], p0[0]], [p1[1] - p0[1] + g * p1[1], p3[1] - p0[1] + h * p3[1], p0[1]], [g, h, 1.0]]
|
||
}
|
||
|
||
/// The baked size of placed content with document-space bounds `bb` at `k` pixels per document pixel.
|
||
pub(crate) fn size_for(bb: [f64; 4], k: f64) -> (u32, u32) {
|
||
(((bb[2] - bb[0]) * k).ceil().clamp(1.0, 16384.0) as u32, ((bb[3] - bb[1]) * k).ceil().clamp(1.0, 16384.0) as u32)
|
||
}
|
||
|
||
/// A placed layer (smart object): `SoLd` / `SoLE` (placed layer data) or the older `PlLd`.
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub struct SmartObject {
|
||
/// Unique id of the linked file ([`LinkedFile::uuid`]) the layer shows.
|
||
pub uuid: String,
|
||
/// Where the content's corners sit in document pixels: top-left, top-right, bottom-right,
|
||
/// bottom-left. The non-affine transform when the block has one.
|
||
pub quad: [[f64; 2]; 4],
|
||
/// The content's size in pixels (`Sz `), when recorded.
|
||
pub size: Option<[f64; 2]>,
|
||
/// The placed layer's warp (`warp`), when it has one.
|
||
pub warp: Option<Warp>,
|
||
}
|
||
|
||
impl SmartObject {
|
||
/// Reads the data of a `SoLd` / `SoLE` block.
|
||
pub fn from_placed_layer_data(data: &[u8]) -> Result<Self> {
|
||
let mut r = Reader::new(data);
|
||
let _kind = r.array::<4>()?;
|
||
let _version = r.u32()?;
|
||
let _descriptor_version = r.u32()?;
|
||
let d = Descriptor::read(&mut r)?;
|
||
let uuid = d.get_text("Idnt").unwrap_or_default();
|
||
let t = eight(&d, "Trnf").unwrap_or([0.0; 8]);
|
||
// 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")?]));
|
||
// 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 })
|
||
}
|
||
|
||
/// Reads the data of a `PlLd` block. A transform that is not finite is an error.
|
||
pub fn from_placed_layer(data: &[u8]) -> Result<Self> {
|
||
let mut r = Reader::new(data);
|
||
let _kind = r.array::<4>()?;
|
||
let _version = r.u32()?;
|
||
let uuid = String::from_utf8_lossy(&read_pascal(&mut r, 1)?).into_owned();
|
||
// Page, total pages, anti-alias policy, layer type.
|
||
r.skip(16)?;
|
||
let mut t = [0.0; 8];
|
||
for v in &mut t {
|
||
*v = r.f64()?;
|
||
if !v.is_finite() {
|
||
return Err(PsdError::invalid("PlLd: transform is not finite"));
|
||
}
|
||
}
|
||
Ok(SmartObject { uuid, quad: quad_from(t), size: None, warp: None })
|
||
}
|
||
|
||
/// The quad as an affine placement of a `w`×`h` content rectangle: (centre, scale x, scale
|
||
/// y, rotation in degrees). Perspective and skew are dropped.
|
||
pub fn placement(&self, w: f64, h: f64) -> ([f64; 2], f64, f64, f64) {
|
||
let q = self.quad;
|
||
let centre = [(q[0][0] + q[1][0] + q[2][0] + q[3][0]) / 4.0, (q[0][1] + q[1][1] + q[2][1] + q[3][1]) / 4.0];
|
||
let top = [q[1][0] - q[0][0], q[1][1] - q[0][1]];
|
||
let left = [q[3][0] - q[0][0], q[3][1] - q[0][1]];
|
||
let sx = top[0].hypot(top[1]) / w.max(1e-9);
|
||
let sy = left[0].hypot(left[1]) / h.max(1e-9);
|
||
// Mirrored content (left edge turning the other way) keeps a negative y scale.
|
||
let cross = top[0] * left[1] - top[1] * left[0];
|
||
(centre, sx, if cross < 0.0 { -sy } else { sy }, top[1].atan2(top[0]).to_degrees())
|
||
}
|
||
|
||
/// Whether the corner quad is a parallelogram (an affine placement).
|
||
pub fn is_affine(&self) -> bool {
|
||
let q = self.quad;
|
||
let ex = q[0][0] + q[2][0] - q[1][0] - q[3][0];
|
||
let ey = q[0][1] + q[2][1] - q[1][1] - q[3][1];
|
||
ex.abs() < 1e-3 && ey.abs() < 1e-3
|
||
}
|
||
|
||
/// Whether placing needs baking (a perspective quad or a warp).
|
||
pub fn needs_bake(&self) -> bool {
|
||
!self.is_affine() || self.warp.is_some()
|
||
}
|
||
|
||
/// A content point (pixels of a `w`×`h` content) in document pixels: the warp, then the quad.
|
||
pub fn map(&self, p: [f64; 2], w: f64, h: f64) -> [f64; 2] {
|
||
self.map_with(&square_to_quad(self.quad), p, w, h)
|
||
}
|
||
|
||
/// [`Self::map`] with the quad's projective matrix already solved (`square_to_quad(self.quad)`).
|
||
pub(crate) fn map_with(&self, m: &[[f64; 3]; 3], p: [f64; 2], w: f64, h: f64) -> [f64; 2] {
|
||
let p = match &self.warp {
|
||
Some(wp) => wp.apply(p, w, h),
|
||
None => p,
|
||
};
|
||
let (u, v) = (p[0] / w.max(1e-9), p[1] / h.max(1e-9));
|
||
let z = m[2][0] * u + m[2][1] * v + m[2][2];
|
||
let z = if z.abs() < 1e-12 { 1e-12 } else { z };
|
||
[(m[0][0] * u + m[0][1] * v + m[0][2]) / z, (m[1][0] * u + m[1][1] * v + m[1][2]) / z]
|
||
}
|
||
|
||
/// The document-space bounds (x0, y0, x1, y1) of the placed (warped) content.
|
||
pub fn placed_bounds(&self, w: f64, h: f64) -> [f64; 4] {
|
||
self.bounds_with(&square_to_quad(self.quad), w, h)
|
||
}
|
||
|
||
pub(crate) fn bounds_with(&self, m: &[[f64; 3]; 3], w: f64, h: f64) -> [f64; 4] {
|
||
let n = 32;
|
||
let mut b = [f64::MAX, f64::MAX, f64::MIN, f64::MIN];
|
||
for j in 0..=n {
|
||
for i in 0..=n {
|
||
let p = self.map_with(m, [i as f64 / n as f64 * w, j as f64 / n as f64 * h], w, h);
|
||
b = [b[0].min(p[0]), b[1].min(p[1]), b[2].max(p[0]), b[3].max(p[1])];
|
||
}
|
||
}
|
||
[b[0].floor(), b[1].floor(), b[2].ceil(), b[3].ceil()]
|
||
}
|
||
|
||
/// The baked size for a `w`×`h` content at `k` pixels per document pixel.
|
||
pub fn placed_size(&self, w: f64, h: f64, k: f64) -> (u32, u32) {
|
||
size_for(self.placed_bounds(w, h), k)
|
||
}
|
||
|
||
/// The scale and size to bake a `w`×`h` content at, wanting `k` pixels per document pixel but
|
||
/// never more than [`MAX_BAKE_PIXELS`] pixels, nor more than 4× the `doc` size across either
|
||
/// way: a quad corner far outside the document lowers `k` (possibly below 1) instead of asking
|
||
/// for a gigantic image. Returns the scale actually used and the size in pixels.
|
||
pub fn bake_scale(&self, w: f64, h: f64, k: f64, doc: (u32, u32)) -> (f64, (u32, u32)) {
|
||
let bb = self.placed_bounds(w, h);
|
||
let (bw, bh) = ((bb[2] - bb[0]).max(1.0), (bb[3] - bb[1]).max(1.0));
|
||
if !(bw.is_finite() && bh.is_finite() && k.is_finite() && k > 0.0) {
|
||
return (1.0, (1, 1));
|
||
}
|
||
let (max_w, max_h) = ((f64::from(doc.0) * 4.0).max(1.0), (f64::from(doc.1) * 4.0).max(1.0));
|
||
let capped = k.min(max_w / bw).min(max_h / bh).min((MAX_BAKE_PIXELS as f64 / (bw * bh)).sqrt());
|
||
// Rounding up could step over a cap, so a lowered scale rounds down.
|
||
let side = |len: f64| if capped < k { (len * capped).floor() } else { (len * capped).ceil() }.clamp(1.0, 16384.0) as u32;
|
||
(capped, (side(bw), side(bh)))
|
||
}
|
||
}
|
||
|
||
impl LayerRecord {
|
||
/// The layer's smart object placement: `SoLd` or `SoLE` when one reads, else `PlLd`. A
|
||
/// malformed block counts as absent.
|
||
pub fn smart_object(&self) -> Option<SmartObject> {
|
||
let read = |key: &[u8; 4], f: fn(&[u8]) -> Result<SmartObject>| self.block(key).and_then(|b| f(&b.data).ok());
|
||
read(b"SoLd", SmartObject::from_placed_layer_data)
|
||
.or_else(|| read(b"SoLE", SmartObject::from_placed_layer_data))
|
||
.or_else(|| read(b"PlLd", SmartObject::from_placed_layer))
|
||
}
|
||
}
|
||
|
||
/// A file embedded in (or linked from) the document: one record of a linked layer block
|
||
/// (`lnk2`, `lnkD`, `lnk3`, `lnkE`).
|
||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||
pub struct LinkedFile {
|
||
/// `liFD` (embedded data), `liFE` (external file) or `liFA` (alias).
|
||
pub kind: [u8; 4],
|
||
/// Unique id, which a [`SmartObject::uuid`] names.
|
||
pub uuid: String,
|
||
/// Original file name.
|
||
pub name: String,
|
||
/// File type code (`8BPS`, `PNGf`, `JPEG`…), may be blank.
|
||
pub file_type: String,
|
||
/// Where the embedded bytes are in the block's data (absent for externally linked files).
|
||
pub data: Option<Range<usize>>,
|
||
}
|
||
|
||
/// `true` for the keys of the linked layer blocks.
|
||
pub fn is_linked_layer_key(key: &[u8; 4]) -> bool {
|
||
matches!(key, b"lnk2" | b"lnkD" | b"lnk3" | b"lnkE")
|
||
}
|
||
|
||
/// The records of a linked layer block's data (spec: "Linked Layer"): length, kind, version,
|
||
/// unique id, original file name, type, creator, data length, optional descriptors, then the
|
||
/// data. A damaged record ends the list; the records before it are returned.
|
||
pub fn linked_files(data: &[u8]) -> Vec<LinkedFile> {
|
||
let mut out = Vec::new();
|
||
let mut r = Reader::new(data);
|
||
while r.remaining() >= 8 {
|
||
let Ok(len) = r.u64() else { break };
|
||
let start = r.pos();
|
||
let Ok(item) = r.bytes_u64(len) else { break };
|
||
match linked_file(item, start) {
|
||
Ok(f) => out.push(f),
|
||
Err(_) => break,
|
||
}
|
||
// Records are padded to a multiple of 4.
|
||
let pad = ((4 - len % 4) % 4).min(r.remaining() as u64) as usize;
|
||
if r.skip(pad).is_err() {
|
||
break;
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// One record; `at` is where it starts in the block's data.
|
||
fn linked_file(item: &[u8], at: usize) -> Result<LinkedFile> {
|
||
let mut r = Reader::new(item);
|
||
let kind = r.array::<4>()?;
|
||
let version = r.u32()?;
|
||
let uuid = String::from_utf8_lossy(&read_pascal(&mut r, 1)?).into_owned();
|
||
let name = UnicodeString::read(&mut r)?.to_string_lossy();
|
||
let file_type = String::from_utf8_lossy(&r.array::<4>()?).trim().to_string();
|
||
let _creator = r.array::<4>()?;
|
||
let data_len = r.u64()?;
|
||
if r.u8()? != 0 {
|
||
let _version = r.u32()?;
|
||
Descriptor::read(&mut r)?;
|
||
}
|
||
let mut out = LinkedFile { kind, uuid, name, file_type, data: None };
|
||
let embedded = |r: &mut Reader<'_>| -> Result<Range<usize>> {
|
||
let s = at + r.pos();
|
||
let n = r.bytes_u64(data_len)?.len();
|
||
Ok(s..s + n)
|
||
};
|
||
match &kind {
|
||
b"liFD" => out.data = Some(embedded(&mut r)?),
|
||
b"liFE" => {
|
||
let _version = r.u32()?;
|
||
Descriptor::read(&mut r)?;
|
||
if version > 3 {
|
||
// Year, month / day / hour / minute, seconds.
|
||
r.skip(4 + 4 + 8)?;
|
||
}
|
||
let _file_size = r.u64()?;
|
||
if version > 2 && data_len > 0 && r.remaining() as u64 >= data_len {
|
||
out.data = Some(embedded(&mut r)?);
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// The four-character file type Photoshop records for an embedded file, from its first bytes:
|
||
/// `8BPS` (`8BPB` for a PSB), `PNGf`, `JPEG`, `TIFF`, `GIFf`, or four spaces.
|
||
pub fn file_type(bytes: &[u8]) -> [u8; 4] {
|
||
match bytes {
|
||
[b'8', b'B', b'P', b'S', 0, 2, ..] => *b"8BPB",
|
||
[b'8', b'B', b'P', b'S', ..] => *b"8BPS",
|
||
[0x89, b'P', b'N', b'G', ..] => *b"PNGf",
|
||
[0xFF, 0xD8, ..] => *b"JPEG",
|
||
[b'I', b'I', 42, 0, ..] | [b'M', b'M', 0, 42, ..] => *b"TIFF",
|
||
[b'G', b'I', b'F', ..] => *b"GIFf",
|
||
_ => *b" ",
|
||
}
|
||
}
|
||
|
||
/// One embedded-file record (`liFD`, version 7: no open descriptor, no child document, no
|
||
/// modification time, unlocked) with its length field and padding to 4, ready to append to a
|
||
/// linked layer block's data. The type is [`file_type`] of the bytes; the id is cut to 255 bytes.
|
||
pub fn encode_embedded_file(uuid: &str, name: &str, bytes: &[u8]) -> Vec<u8> {
|
||
let mut item = b"liFD".to_vec();
|
||
item.put_u32(7);
|
||
let id = uuid.as_bytes().get(..uuid.len().min(255)).unwrap_or_default();
|
||
item.put_u8(id.len() as u8);
|
||
item.put(id);
|
||
// NUL-terminated, the terminator counted (as Photoshop writes and reads it).
|
||
UnicodeString::new_nul(name).write(&mut item);
|
||
item.put(&file_type(bytes));
|
||
item.put(b"8BIM");
|
||
item.put_u64(bytes.len() as u64);
|
||
item.put_u8(0);
|
||
item.put(bytes);
|
||
item.put_u32(0); // child document id (version 5)
|
||
item.put_f64(0.0); // asset modification time (version 6)
|
||
item.put_u8(0); // asset locked (version 7)
|
||
let mut out = Vec::with_capacity(item.len() + 11);
|
||
out.put_u64(item.len() as u64);
|
||
out.put(&item);
|
||
out.resize(out.len().next_multiple_of(4), 0);
|
||
out
|
||
}
|
||
|
||
/// One record of a linked layer block's data, unparsed.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
pub struct LinkedRecord<'a> {
|
||
/// The record's unique id, when its header is readable (lossy UTF-8).
|
||
pub uuid: Option<&'a [u8]>,
|
||
/// The whole record: length field, record and padding.
|
||
pub raw: &'a [u8],
|
||
}
|
||
|
||
impl LinkedRecord<'_> {
|
||
/// The unique id as text, when it is readable and valid UTF-8.
|
||
pub fn uuid_str(&self) -> Option<&str> {
|
||
std::str::from_utf8(self.uuid?).ok()
|
||
}
|
||
}
|
||
|
||
/// The record inside `data` at its start: (the record without its length field, the record
|
||
/// with length field and padding). `None` when the length runs past the data.
|
||
fn framed_record(data: &[u8]) -> Option<(&[u8], &[u8])> {
|
||
let len = usize::try_from(u64::from_be_bytes(*data.first_chunk::<8>()?)).ok()?;
|
||
let end = len.checked_add(8)?;
|
||
let item = data.get(8..end)?;
|
||
let padded = end.checked_next_multiple_of(4)?.min(data.len());
|
||
Some((item, data.get(..padded)?))
|
||
}
|
||
|
||
/// The unique id bytes of a record (after its type and version), when they are present.
|
||
fn record_uuid(item: &[u8]) -> Option<&[u8]> {
|
||
let n = usize::from(*item.get(8)?);
|
||
item.get(9..9usize.checked_add(n)?)
|
||
}
|
||
|
||
/// The records of a linked layer block's data, in order, each with its unique id when its header
|
||
/// is readable. A tail that is no complete record is returned as one record without an id.
|
||
pub fn linked_records(data: &[u8]) -> Vec<LinkedRecord<'_>> {
|
||
let mut out = Vec::new();
|
||
let mut rest = data;
|
||
while !rest.is_empty() {
|
||
let Some((item, raw)) = framed_record(rest) else {
|
||
out.push(LinkedRecord { uuid: None, raw: rest });
|
||
break;
|
||
};
|
||
out.push(LinkedRecord { uuid: record_uuid(item), raw });
|
||
rest = rest.get(raw.len()..).unwrap_or_default();
|
||
}
|
||
out
|
||
}
|
||
|
||
/// The unique ids of every record of a linked layer block's data that has a readable one.
|
||
pub fn linked_uuids(data: &[u8]) -> Vec<String> {
|
||
linked_records(data).iter().filter_map(|r| r.uuid.map(|u| String::from_utf8_lossy(u).into_owned())).collect()
|
||
}
|
||
|
||
/// A linked layer block's data with the records `keep` accepts (by unique id, lossy UTF-8) and
|
||
/// every record without a readable id, then the records of `add` (see
|
||
/// [`encode_embedded_file`]) appended. `None` when nothing would change.
|
||
pub fn rebuild_linked_block(data: &[u8], keep: &dyn Fn(&str) -> bool, add: &[Vec<u8>]) -> Option<Vec<u8>> {
|
||
let records = linked_records(data);
|
||
let kept = |r: &LinkedRecord<'_>| r.uuid.is_none_or(|u| keep(&String::from_utf8_lossy(u)));
|
||
if records.iter().all(kept) && add.is_empty() {
|
||
return None;
|
||
}
|
||
let mut out = Vec::with_capacity(data.len());
|
||
for r in records.iter().filter(|r| kept(r)) {
|
||
out.extend_from_slice(r.raw);
|
||
}
|
||
for a in add {
|
||
out.extend_from_slice(a);
|
||
}
|
||
Some(out)
|
||
}
|
||
|
||
/// Only the complete records `keep(uuid, raw record)` accepts, for copying smart objects to
|
||
/// another document: unlike [`rebuild_linked_block`], records without an id and a damaged tail
|
||
/// are left out, so a copy never carries unrelated embedded files. An id must be valid UTF-8 to
|
||
/// be selected: one that is not, but whose lossy text `keep` accepts, is an error, as is a
|
||
/// selected record that is cut short. `Ok(None)` when every record is selected and complete
|
||
/// (the data can be shared as it is).
|
||
pub fn select_linked_records(data: &[u8], keep: &dyn Fn(&str, &[u8]) -> bool) -> Result<Option<Vec<u8>>> {
|
||
let cut_short = || PsdError::invalid("a selected linked-layer record is cut short");
|
||
let append = |out: &mut Vec<u8>, bytes: &[u8]| -> Result<()> {
|
||
out.try_reserve(bytes.len()).map_err(|_| PsdError::OutOfMemory("not enough memory to copy linked-layer records"))?;
|
||
out.extend_from_slice(bytes);
|
||
Ok(())
|
||
};
|
||
let mut selected: Option<Vec<u8>> = None;
|
||
let mut at = 0usize;
|
||
while let Some(rest) = data.get(at..).filter(|r| !r.is_empty()) {
|
||
let Some((item, raw)) = framed_record(rest) else {
|
||
if let Some(item) = rest.get(8..)
|
||
&& selection_uuid(item, rest, keep)?.is_some_and(|uuid| keep(uuid, rest))
|
||
{
|
||
return Err(cut_short());
|
||
}
|
||
if selected.is_none() {
|
||
let mut prefix = Vec::new();
|
||
append(&mut prefix, data.get(..at).ok_or_else(cut_short)?)?;
|
||
selected = Some(prefix);
|
||
}
|
||
break;
|
||
};
|
||
if selection_uuid(item, raw, keep)?.is_some_and(|uuid| keep(uuid, raw)) {
|
||
if let Some(out) = &mut selected {
|
||
append(out, raw)?;
|
||
}
|
||
} else if selected.is_none() {
|
||
let mut prefix = Vec::new();
|
||
append(&mut prefix, data.get(..at).ok_or_else(cut_short)?)?;
|
||
selected = Some(prefix);
|
||
}
|
||
at = at.checked_add(raw.len()).ok_or_else(cut_short)?;
|
||
}
|
||
Ok(selected)
|
||
}
|
||
|
||
/// A record's id for selection: exact UTF-8, or an error when only its lossy text is selected.
|
||
fn selection_uuid<'a>(item: &'a [u8], raw: &[u8], keep: &dyn Fn(&str, &[u8]) -> bool) -> Result<Option<&'a str>> {
|
||
let Some(bytes) = record_uuid(item).filter(|b| !b.is_empty()) else { return Ok(None) };
|
||
match std::str::from_utf8(bytes) {
|
||
Ok(uuid) => Ok(Some(uuid)),
|
||
Err(_) if keep(&String::from_utf8_lossy(bytes), raw) => Err(PsdError::invalid("a linked-layer record id is not valid UTF-8")),
|
||
Err(_) => Ok(None),
|
||
}
|
||
}
|
||
|
||
/// Whether the complete record `record` (length field and padding included) is in a linked layer
|
||
/// block's data, without decoding any file bytes.
|
||
pub fn contains_linked_record(data: &[u8], record: &[u8]) -> bool {
|
||
let mut rest = data;
|
||
while let Some((_, raw)) = framed_record(rest) {
|
||
if raw == record {
|
||
return true;
|
||
}
|
||
let Some(next) = rest.get(raw.len()..) else { return false };
|
||
rest = next;
|
||
}
|
||
false
|
||
}
|
||
|
||
/// The record with id `uuid` copied under `new_uuid` (at most 255 bytes), with its length
|
||
/// field and padding; the rest of the record (embedded, external or alias data) is kept.
|
||
pub fn copy_linked_record(data: &[u8], uuid: &str, new_uuid: &str) -> Option<Vec<u8>> {
|
||
let record = linked_records(data).into_iter().find(|r| r.uuid == Some(uuid.as_bytes()))?;
|
||
let (item, _) = framed_record(record.raw)?;
|
||
let old_end = 9usize.checked_add(usize::from(*item.get(8)?))?;
|
||
let new_len = u8::try_from(new_uuid.len()).ok()?;
|
||
let mut copy = item.get(..8)?.to_vec();
|
||
copy.push(new_len);
|
||
copy.extend_from_slice(new_uuid.as_bytes());
|
||
copy.extend_from_slice(item.get(old_end..)?);
|
||
let mut out = Vec::with_capacity(copy.len() + 11);
|
||
out.put_u64(copy.len() as u64);
|
||
out.extend(copy);
|
||
out.resize(out.len().next_multiple_of(4), 0);
|
||
Some(out)
|
||
}
|
||
|
||
impl PsdFile {
|
||
/// Every linked file of the document's linked layer blocks, each with the index of the
|
||
/// global block that holds it ([`LinkedFile::data`] is a range of that block's data). A
|
||
/// block left in the source ([`crate::TaggedBlock::stored`]) is read for it.
|
||
pub fn linked_files(&self) -> Result<Vec<(usize, LinkedFile)>> {
|
||
let mut out = Vec::new();
|
||
for (i, b) in self.global_blocks.iter().enumerate().filter(|(_, b)| is_linked_layer_key(&b.key)) {
|
||
out.extend(linked_files(&b.encoded()?).into_iter().map(|f| (i, f)));
|
||
}
|
||
Ok(out)
|
||
}
|
||
|
||
/// The embedded bytes of the linked file `uuid`, when the document embeds it: borrowed from
|
||
/// a block in memory, read from the source for a block left there.
|
||
pub fn linked_data(&self, uuid: &str) -> Result<Option<Cow<'_, [u8]>>> {
|
||
for b in self.global_blocks.iter().filter(|b| is_linked_layer_key(&b.key)) {
|
||
let data = b.encoded()?;
|
||
let Some(range) = linked_files(&data).into_iter().find(|f| f.uuid == uuid).and_then(|f| f.data) else { continue };
|
||
return Ok(match data {
|
||
Cow::Borrowed(d) => d.get(range).map(Cow::Borrowed),
|
||
Cow::Owned(d) => d.get(range).map(|e| Cow::Owned(e.to_vec())),
|
||
});
|
||
}
|
||
Ok(None)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use crate::descriptor::{Id, ObjectArray};
|
||
use crate::io::{WriteExt, write_pascal};
|
||
use crate::tagged::TaggedBlock;
|
||
|
||
fn so(quad: [[f64; 2]; 4], warp: Option<Warp>) -> SmartObject {
|
||
SmartObject { uuid: "x".into(), quad, size: None, warp }
|
||
}
|
||
|
||
fn list(v: &[f64]) -> Value {
|
||
Value::List(v.iter().map(|&x| Value::Double(x)).collect())
|
||
}
|
||
|
||
/// The data of a `SoLd` block placing `uuid` at `quad`.
|
||
fn placed_layer_data(uuid: &str, quad: [f64; 8], extra: impl FnOnce(Descriptor) -> Descriptor) -> Vec<u8> {
|
||
let d = Descriptor::new("null").with("Idnt", Value::Text(UnicodeString::new_nul(uuid))).with("Trnf", list(&quad));
|
||
let mut out = b"soLD".to_vec();
|
||
out.put_u32(4);
|
||
out.put_u32(16);
|
||
extra(d).write(&mut out);
|
||
out
|
||
}
|
||
|
||
/// One linked layer record, with its length field and padding.
|
||
fn linked_record(kind: &[u8; 4], uuid: &str, name: &str, file_type: &[u8; 4], data: &[u8]) -> Vec<u8> {
|
||
let mut item = kind.to_vec();
|
||
item.put_u32(2);
|
||
write_pascal(&mut item, uuid.as_bytes(), 1);
|
||
UnicodeString::new(name).write(&mut item);
|
||
item.put(file_type);
|
||
item.put(b" ");
|
||
item.put_u64(data.len() as u64);
|
||
item.put_u8(0);
|
||
if kind == b"liFD" {
|
||
item.put(data);
|
||
} else {
|
||
item.put_u32(16);
|
||
Descriptor::new("ExternalFileLink").write(&mut item);
|
||
item.put_u64(0);
|
||
}
|
||
let mut out = Vec::new();
|
||
out.put_u64(item.len() as u64);
|
||
out.put(&item);
|
||
out.resize(out.len().div_ceil(4) * 4, 0);
|
||
out
|
||
}
|
||
|
||
/// The embedded files of a block, as (id, name, bytes).
|
||
fn embedded(data: &[u8]) -> Vec<(String, String, Vec<u8>)> {
|
||
linked_files(data).into_iter().filter_map(|f| Some((f.uuid, f.name, data.get(f.data?)?.to_vec()))).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn embedded_file_records_read_back_and_check() {
|
||
let mut data = encode_embedded_file("abc-1", "a.png", &[0x89, b'P', b'N', b'G', 1]);
|
||
data.extend(encode_embedded_file("def-2", "b.psb", b"8BPS\0\x02rest"));
|
||
assert_eq!(
|
||
embedded(&data),
|
||
[("abc-1".into(), "a.png".into(), vec![0x89, b'P', b'N', b'G', 1]), ("def-2".into(), "b.psb".into(), b"8BPS\0\x02rest".to_vec())]
|
||
);
|
||
assert_eq!(linked_files(&data).iter().map(|f| f.file_type.clone()).collect::<Vec<_>>(), ["PNGf", "8BPB"]);
|
||
TaggedBlock::new(*b"lnk2", data).check_structure().unwrap();
|
||
for (bytes, ty) in [
|
||
(&b"8BPS\0\x01"[..], b"8BPS"),
|
||
(&[0xFF, 0xD8, 0xFF][..], b"JPEG"),
|
||
(b"II*\0", b"TIFF"),
|
||
(b"MM\0*", b"TIFF"),
|
||
(b"GIF89a", b"GIFf"),
|
||
(b"%PDF", b" "),
|
||
] {
|
||
assert_eq!(&file_type(bytes), ty);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn blocks_are_pruned_and_extended() {
|
||
let (a, b, c) =
|
||
(encode_embedded_file("a", "a.png", &[1]), encode_embedded_file("b", "b.psb", &[2, 2]), encode_embedded_file("c", "c", &[7; 3]));
|
||
let mut data = a.clone();
|
||
data.extend(&b);
|
||
assert_eq!(linked_uuids(&data), ["a", "b"]);
|
||
assert_eq!(rebuild_linked_block(&data, &|_| true, &[]), None);
|
||
let out = rebuild_linked_block(&data, &|u| u != "a", std::slice::from_ref(&c)).unwrap();
|
||
assert_eq!(out, [b.clone(), c.clone()].concat());
|
||
// A malformed tail is kept as it is.
|
||
let mut bad = a.clone();
|
||
bad.extend([0, 0, 0, 0, 0, 0, 1, 0, 9]);
|
||
assert!(rebuild_linked_block(&bad, &|_| true, std::slice::from_ref(&c)).unwrap().starts_with(&bad));
|
||
assert!(linked_records(&[0, 0, 0, 0, 0, 0, 0, 200, 1, 2]).iter().all(|r| r.uuid.is_none()));
|
||
assert!(linked_records(&[]).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn copying_a_record_keeps_an_unknown_external_payload() {
|
||
let mut raw = encode_embedded_file("old", "missing.psb", &[7; 5]);
|
||
raw[8..12].copy_from_slice(b"liFE");
|
||
let copy = copy_linked_record(&raw, "old", "independent-copy").unwrap();
|
||
assert_eq!(linked_uuids(©), ["independent-copy"]);
|
||
let original_len = u64::from_be_bytes(raw[..8].try_into().unwrap()) as usize;
|
||
let copied_len = u64::from_be_bytes(copy[..8].try_into().unwrap()) as usize;
|
||
assert_eq!(©[8..16], &raw[8..16]);
|
||
assert_eq!(©[17 + "independent-copy".len()..8 + copied_len], &raw[20..8 + original_len]);
|
||
assert!(copy_linked_record(&raw, "missing", "new").is_none());
|
||
assert!(copy_linked_record(&raw, "old", &"x".repeat(256)).is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn selected_records_leave_out_other_files_and_unidentified_pieces() {
|
||
let (a, b) = (encode_embedded_file("a", "a.psb", &[1; 3]), encode_embedded_file("b", "b.psb", &[2; 4]));
|
||
assert!(select_linked_records(&a, &|uuid, _| uuid == "a").unwrap().is_none(), "a block of only selected records can be shared");
|
||
let mut data = a.clone();
|
||
data.extend(&b);
|
||
data.extend([0, 0, 0, 0, 0, 0, 1, 0, 9]);
|
||
assert_eq!(select_linked_records(&data, &|uuid, _| uuid == "a").unwrap(), Some(a.clone()));
|
||
assert_eq!(select_linked_records(&data, &|uuid, _| uuid == "b").unwrap(), Some(b));
|
||
assert_eq!(select_linked_records(&data, &|_, _| false).unwrap(), Some(Vec::new()));
|
||
// A complete but unidentified record is skipped without losing a later selected one.
|
||
let mut unreadable = 1u64.to_be_bytes().to_vec();
|
||
unreadable.extend([7, 0, 0, 0]);
|
||
unreadable.extend(&a);
|
||
assert_eq!(select_linked_records(&unreadable, &|uuid, _| uuid == "a").unwrap(), Some(a));
|
||
}
|
||
|
||
#[test]
|
||
fn selected_external_and_alias_records_keep_unknown_payloads_verbatim() {
|
||
for kind in [*b"liFE", *b"liFA", *b"next"] {
|
||
let mut raw = encode_embedded_file("selected", "missing.psb", &[7; 5]);
|
||
raw[8..12].copy_from_slice(&kind);
|
||
raw[12..16].copy_from_slice(&42u32.to_be_bytes()); // an unknown future version
|
||
let mut data = encode_embedded_file("unselected", "other.psb", &[9; 3]);
|
||
data.extend(&raw);
|
||
assert_eq!(select_linked_records(&data, &|uuid, _| uuid == "selected").unwrap(), Some(raw));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_cut_selected_record_is_an_error_not_a_missing_file() {
|
||
let mut raw = encode_embedded_file("selected", "missing.psb", &[7; 5]);
|
||
raw[..8].copy_from_slice(&u64::MAX.to_be_bytes());
|
||
assert!(select_linked_records(&raw, &|uuid, _| uuid == "selected").unwrap_err().to_string().contains("cut short"));
|
||
assert_eq!(select_linked_records(&raw, &|uuid, _| uuid == "other").unwrap(), Some(Vec::new()));
|
||
}
|
||
|
||
#[test]
|
||
fn records_are_matched_whole() {
|
||
let (a, b, different) =
|
||
(encode_embedded_file("a", "a.psb", &[1; 3]), encode_embedded_file("b", "b.psb", &[2; 4]), encode_embedded_file("a", "a.psb", &[9; 3]));
|
||
let combined = [a.clone(), b.clone()].concat();
|
||
assert!(contains_linked_record(&combined, &a) && contains_linked_record(&combined, &b));
|
||
assert!(!contains_linked_record(&combined, &different));
|
||
assert_eq!(select_linked_records(&a, &|_, raw| !contains_linked_record(&combined, raw)).unwrap(), Some(Vec::new()));
|
||
assert!(select_linked_records(&different, &|_, raw| !contains_linked_record(&combined, raw)).unwrap().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn empty_ids_are_unidentified_even_when_asked_for() {
|
||
let raw = encode_embedded_file("", "unidentified.psb", &[7; 5]);
|
||
assert_eq!(select_linked_records(&raw, &|_, _| true).unwrap(), Some(Vec::new()));
|
||
let mut cut = raw;
|
||
cut[..8].copy_from_slice(&u64::MAX.to_be_bytes());
|
||
assert_eq!(select_linked_records(&cut, &|_, _| true).unwrap(), Some(Vec::new()));
|
||
}
|
||
|
||
#[test]
|
||
fn selection_never_takes_a_lossy_id_for_a_record_identity() {
|
||
let (mut a, mut b) = (encode_embedded_file("x", "a.psb", &[1; 3]), encode_embedded_file("x", "b.psb", &[2; 4]));
|
||
a[17] = 0xff;
|
||
b[17] = 0xfe;
|
||
let mut data = [a, b].concat();
|
||
assert_eq!(linked_uuids(&data), ["\u{fffd}", "\u{fffd}"]);
|
||
assert!(select_linked_records(&data, &|uuid, _| uuid == "\u{fffd}").unwrap_err().to_string().contains("not valid UTF-8"));
|
||
let valid = encode_embedded_file("valid", "valid.psb", &[3; 5]);
|
||
data.extend(&valid);
|
||
assert_eq!(select_linked_records(&data, &|uuid, _| uuid == "valid").unwrap(), Some(valid));
|
||
// A literal replacement character can itself be a valid UTF-8 id.
|
||
let unicode = encode_embedded_file("\u{fffd}", "unicode.psb", &[4; 2]);
|
||
assert!(select_linked_records(&unicode, &|uuid, _| uuid == "\u{fffd}").unwrap().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn placed_layer_data_gives_the_id_quad_size_and_warp() {
|
||
let quad = [20.0, 4.0, 40.0, 4.0, 40.0, 44.0, 20.0, 44.0];
|
||
let data = placed_layer_data("uuid-1", quad, |d| {
|
||
let size = Descriptor::new("Pnt ").with("Wdth", Value::Double(20.0)).with("Hght", Value::Double(10.0));
|
||
let bounds = ["Top ", "Left", "Btom", "Rght"]
|
||
.iter()
|
||
.zip([0.0, 0.0, 10.0, 20.0])
|
||
.fold(Descriptor::new("classFloatRect"), |b, (k, v)| b.with(k, Value::UnitFloat { unit: *b"#Pxl", value: v }));
|
||
let warp = Descriptor::new("warp")
|
||
.with("warpStyle", Value::Enumerated { type_id: Id::new("warpStyle"), value: Id::new("warpArc") })
|
||
.with("warpValue", Value::Double(25.0))
|
||
.with("warpRotate", Value::Enumerated { type_id: Id::new("Ornt"), value: Id::new("Vrtc") })
|
||
.with("bounds", Value::Descriptor(bounds));
|
||
d.with("Sz ", Value::Descriptor(size)).with("warp", Value::Descriptor(warp))
|
||
});
|
||
let s = SmartObject::from_placed_layer_data(&data).unwrap();
|
||
assert_eq!(s.uuid, "uuid-1");
|
||
assert_eq!(s.quad, [[20.0, 4.0], [40.0, 4.0], [40.0, 44.0], [20.0, 44.0]]);
|
||
assert_eq!(s.size, Some([20.0, 10.0]));
|
||
let w = s.warp.clone().unwrap();
|
||
assert_eq!((w.style.as_str(), w.bend, w.rotate_vertical, w.bounds), ("warpArc", 25.0, true, [0.0, 0.0, 20.0, 10.0]));
|
||
assert!(s.is_affine() && s.needs_bake());
|
||
let (centre, sx, sy, rotation) = s.placement(20.0, 10.0);
|
||
assert_eq!((centre, sx, sy, rotation), ([30.0, 24.0], 1.0, 4.0, 0.0));
|
||
// Every truncation is an error, never a panic.
|
||
for cut in 0..data.len() {
|
||
assert!(SmartObject::from_placed_layer_data(&data[..cut]).is_err(), "cut {cut}");
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn the_non_affine_transform_wins_and_an_identity_warp_is_none() {
|
||
let data = placed_layer_data("p", [0.0, 0.0, 10.0, 0.0, 10.0, 10.0, 0.0, 10.0], |d| {
|
||
let none = Descriptor::new("warp").with("warpStyle", Value::Enumerated { type_id: Id::new("warpStyle"), value: Id::new("warpNone") });
|
||
d.with("nonAffineTransform", list(&[10.0, 10.0, 50.0, 0.0, 50.0, 40.0, 10.0, 30.0])).with("warp", Value::Descriptor(none))
|
||
});
|
||
let s = SmartObject::from_placed_layer_data(&data).unwrap();
|
||
assert_eq!(s.quad, [[10.0, 10.0], [50.0, 0.0], [50.0, 40.0], [10.0, 30.0]]);
|
||
assert!(s.warp.is_none() && !s.is_affine());
|
||
}
|
||
|
||
#[test]
|
||
fn a_custom_envelope_reads_its_quilt_mesh_and_a_wrong_sized_one_is_no_warp() {
|
||
let envelope = |points: usize| {
|
||
let unfl = |v: Vec<f64>| Value::UnitFloats { unit: *b"#Pxl", values: v };
|
||
let body =
|
||
Descriptor::new("rationalPoint").with("Hrzn", unfl((0..points).map(|i| i as f64).collect())).with("Vrtc", unfl(vec![1.0; points]));
|
||
let env = Descriptor::new("customEnvelopeWarp")
|
||
.with("quiltSliceX", unfl(vec![0.0, 10.0, 20.0]))
|
||
.with("quiltSliceY", unfl(vec![0.0, 10.0]))
|
||
.with("meshPoints", Value::ObjectArray(ObjectArray { prefix: 16, body }));
|
||
Descriptor::new("warp")
|
||
.with("warpStyle", Value::Enumerated { type_id: Id::new("warpStyle"), value: Id::new("warpCustom") })
|
||
.with("customEnvelopeWarp", Value::Descriptor(env))
|
||
};
|
||
// Two columns and one row of patches: 7 × 4 control points.
|
||
let w = Warp::from_descriptor(&envelope(28)).unwrap();
|
||
assert_eq!((w.cols, w.rows, w.mesh.len()), (2, 1, 28));
|
||
assert_eq!(w.mesh[5], [5.0, 1.0]);
|
||
assert_eq!(Warp::from_descriptor(&envelope(27)), None);
|
||
}
|
||
|
||
#[test]
|
||
fn the_older_placed_layer_block_gives_the_id_and_quad_and_refuses_a_transform_that_is_not_finite() {
|
||
let block = |t: [f64; 8]| {
|
||
let mut out = b"plcL".to_vec();
|
||
out.put_u32(3);
|
||
write_pascal(&mut out, b"old-id", 1);
|
||
for v in [1u32, 1, 16, 2] {
|
||
out.put_u32(v);
|
||
}
|
||
for v in t {
|
||
out.put(&v.to_be_bytes());
|
||
}
|
||
out
|
||
};
|
||
let s = SmartObject::from_placed_layer(&block([1.0, 2.0, 3.0, 2.0, 3.0, 4.0, 1.0, 4.0])).unwrap();
|
||
assert_eq!((s.uuid.as_str(), s.quad), ("old-id", [[1.0, 2.0], [3.0, 2.0], [3.0, 4.0], [1.0, 4.0]]));
|
||
assert!(SmartObject::from_placed_layer(&block([f64::NAN; 8])).is_err());
|
||
assert!(SmartObject::from_placed_layer(&block([f64::INFINITY; 8])).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn a_layer_prefers_placed_layer_data_and_ignores_a_malformed_block() {
|
||
let good = placed_layer_data("new", [0.0; 8], |d| d);
|
||
let mut old = b"plcL".to_vec();
|
||
old.put_u32(3);
|
||
write_pascal(&mut old, b"old", 1);
|
||
old.extend_from_slice(&[0; 16 + 64]);
|
||
let mut layer = LayerRecord { blocks: vec![TaggedBlock::new(*b"PlLd", old), TaggedBlock::new(*b"SoLd", good)], ..Default::default() };
|
||
assert_eq!(layer.smart_object().unwrap().uuid, "new");
|
||
layer.blocks[1].data.truncate(9);
|
||
assert_eq!(layer.smart_object().unwrap().uuid, "old");
|
||
layer.blocks.clear();
|
||
assert!(layer.smart_object().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn linked_files_lists_embedded_and_external_records_and_stops_at_damage() {
|
||
let mut data = linked_record(b"liFD", "uuid-1", "a.png", b"PNGf", b"PNGDATA");
|
||
let first = data.len();
|
||
data.extend(linked_record(b"liFE", "uuid-2", "b.psd", b"8BPS", b""));
|
||
let files = linked_files(&data);
|
||
assert_eq!(files.len(), 2);
|
||
assert_eq!(
|
||
(files[0].kind, files[0].uuid.as_str(), files[0].name.as_str(), files[0].file_type.as_str()),
|
||
(*b"liFD", "uuid-1", "a.png", "PNGf")
|
||
);
|
||
assert_eq!(&data[files[0].data.clone().unwrap()], b"PNGDATA");
|
||
assert_eq!((files[1].kind, files[1].uuid.as_str(), files[1].data.clone()), (*b"liFE", "uuid-2", None));
|
||
// A record whose length runs past the block, or that is cut short, ends the list.
|
||
let second_end = first + 8 + u64::from_be_bytes(data[first..first + 8].try_into().unwrap()) as usize;
|
||
for cut in first..second_end {
|
||
assert_eq!(linked_files(&data[..cut]).len(), 1, "cut {cut}");
|
||
}
|
||
let mut hostile = data.clone();
|
||
hostile[..8].copy_from_slice(&u64::MAX.to_be_bytes());
|
||
assert!(linked_files(&hostile).is_empty());
|
||
// An embedded length larger than the record is damage, not an out-of-range slice.
|
||
let mut long = linked_record(b"liFD", "u", "n", b"JPEG", b"abc");
|
||
let at = long.windows(8).rposition(|w| w == 3u64.to_be_bytes()).unwrap();
|
||
long[at..at + 8].copy_from_slice(&u64::MAX.to_be_bytes());
|
||
assert!(linked_files(&long).is_empty());
|
||
}
|
||
|
||
#[test]
|
||
fn a_document_finds_the_embedded_bytes_of_a_smart_object() {
|
||
let mut file = crate::testgen::small(crate::Version::Psd, crate::Compression::Raw);
|
||
let first = file.global_blocks.len();
|
||
file.global_blocks.push(TaggedBlock::new(*b"lnk2", linked_record(b"liFD", "uuid-1", "a.png", b"PNGf", b"PNGDATA")));
|
||
file.global_blocks.push(TaggedBlock::new(*b"lnkE", linked_record(b"liFE", "uuid-2", "b.psd", b"8BPS", b"")));
|
||
let embedded = |f: &PsdFile, uuid: &str| f.linked_data(uuid).unwrap().map(Cow::into_owned);
|
||
assert_eq!(
|
||
file.linked_files().unwrap().iter().map(|(i, f)| (*i, f.uuid.as_str())).collect::<Vec<_>>(),
|
||
[(first, "uuid-1"), (first + 1, "uuid-2")]
|
||
);
|
||
assert_eq!(embedded(&file, "uuid-1").as_deref(), Some(b"PNGDATA".as_slice()));
|
||
assert_eq!(embedded(&file, "uuid-2"), None);
|
||
assert_eq!(embedded(&file, "nope"), None);
|
||
// The blocks are untouched: the file still writes and reads back.
|
||
let again = PsdFile::from_bytes(&file.to_bytes().unwrap()).unwrap();
|
||
assert_eq!(embedded(&again, "uuid-1").as_deref(), Some(b"PNGDATA".as_slice()));
|
||
// With the linked-file blocks left in the source, the same files and bytes are found.
|
||
let bytes = file.to_bytes().unwrap();
|
||
let (deferred, _) =
|
||
PsdFile::from_source_deferring(crate::Source::new(bytes), |key, _| is_linked_layer_key(key), |_| Ok(false), |_| false).unwrap();
|
||
assert!(deferred.global_blocks.iter().filter(|b| is_linked_layer_key(&b.key)).all(|b| b.data.is_empty() && b.stored.is_some()));
|
||
assert_eq!(deferred.linked_files().unwrap(), file.linked_files().unwrap());
|
||
assert_eq!(embedded(&deferred, "uuid-1").as_deref(), Some(b"PNGDATA".as_slice()));
|
||
}
|
||
|
||
#[test]
|
||
fn perspective_quad_maps_corners_and_is_projective() {
|
||
let q = [[10.0, 10.0], [50.0, 0.0], [50.0, 40.0], [10.0, 30.0]];
|
||
let s = so(q, None);
|
||
assert!(!s.is_affine() && s.needs_bake());
|
||
for (c, want) in [([0.0, 0.0], q[0]), ([20.0, 0.0], q[1]), ([20.0, 10.0], q[2]), ([0.0, 10.0], q[3])] {
|
||
let p = s.map(c, 20.0, 10.0);
|
||
assert!((p[0] - want[0]).abs() < 1e-9 && (p[1] - want[1]).abs() < 1e-9, "{c:?} → {p:?}");
|
||
}
|
||
// Projective, not bilinear: the content centre is where the diagonals cross.
|
||
let c = s.map([10.0, 5.0], 20.0, 10.0);
|
||
assert!((c[0] - 30.0).abs() > 0.5, "{c:?}");
|
||
let affine = so([[0.0, 0.0], [20.0, 0.0], [20.0, 10.0], [0.0, 10.0]], None);
|
||
assert!(affine.is_affine() && !affine.needs_bake());
|
||
}
|
||
|
||
#[test]
|
||
fn custom_mesh_and_styles_warp() {
|
||
// An identity 4×4 mesh shifted 3 px right in its interior row: interior points move.
|
||
let mut mesh = vec![];
|
||
for j in 0..4 {
|
||
for i in 0..4 {
|
||
mesh.push([i as f64 * 20.0 / 3.0 + if j == 1 || j == 2 { 3.0 } else { 0.0 }, j as f64 * 10.0 / 3.0]);
|
||
}
|
||
}
|
||
let w = Warp { style: "warpCustom".into(), bounds: [0.0, 0.0, 20.0, 10.0], cols: 1, rows: 1, mesh, ..Default::default() };
|
||
let p = w.apply([10.0, 5.0], 20.0, 10.0);
|
||
assert!((p[0] - 12.25).abs() < 1e-9 && (p[1] - 5.0).abs() < 1e-9, "{p:?}");
|
||
assert_eq!(w.apply([0.0, 0.0], 20.0, 10.0), [0.0, 0.0]);
|
||
// Arc: the top centre rises, the bottom centre too (concentric arcs); Bulge: top up,
|
||
// bottom down; Flag keeps the centre.
|
||
let style = |s: &str| Warp { style: s.into(), bend: 50.0, bounds: [0.0, 0.0, 200.0, 100.0], ..Default::default() };
|
||
let top = style("warpArc").apply([100.0, 0.0], 200.0, 100.0);
|
||
let corner = style("warpArc").apply([0.0, 0.0], 200.0, 100.0);
|
||
assert!(top[1] < corner[1], "arc {top:?} {corner:?}");
|
||
let b = style("warpBulge");
|
||
assert!(b.apply([100.0, 0.0], 200.0, 100.0)[1] < 0.0 && b.apply([100.0, 100.0], 200.0, 100.0)[1] > 100.0);
|
||
assert!((style("warpFlag").apply([100.0, 50.0], 200.0, 100.0)[1] - 50.0).abs() < 1e-9);
|
||
// Vertical rotation swaps the roles of the axes.
|
||
let mut v = style("warpBulge");
|
||
v.rotate_vertical = true;
|
||
let l = v.apply([0.0, 50.0], 200.0, 100.0);
|
||
assert!(l[0] < 0.0 && (l[1] - 50.0).abs() < 1e-9, "{l:?}");
|
||
// Horizontal distortion: the right side grows taller.
|
||
let d = Warp { style: "warpArc".into(), horizontal: 50.0, bounds: [0.0, 0.0, 200.0, 100.0], ..Default::default() };
|
||
assert!(d.apply([200.0, 0.0], 200.0, 100.0)[1] < d.apply([0.0, 0.0], 200.0, 100.0)[1]);
|
||
}
|
||
|
||
#[test]
|
||
fn custom_warp_with_a_missing_or_short_mesh_does_not_panic() {
|
||
let base = Warp { style: "warpCustom".into(), bounds: [0.0, 0.0, 20.0, 10.0], cols: 1, rows: 1, ..Default::default() };
|
||
// Empty and too-short meshes leave the point where it is.
|
||
assert_eq!(base.apply([5.0, 5.0], 20.0, 10.0), [5.0, 5.0]);
|
||
let short = Warp { mesh: vec![[0.0, 0.0]; 5], ..base.clone() };
|
||
assert_eq!(short.apply([5.0, 5.0], 20.0, 10.0), [5.0, 5.0]);
|
||
for (cols, rows) in [(0, 0), (0, 3), (usize::MAX, 1), (1, usize::MAX), (usize::MAX, usize::MAX)] {
|
||
let w = Warp { cols, rows, ..base.clone() };
|
||
assert_eq!(w.apply([5.0, 5.0], 20.0, 10.0), [5.0, 5.0]);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn an_absurd_quad_corner_bakes_within_four_times_the_document_and_2_pow_24_pixels() {
|
||
let doc = (1920u32, 1080u32);
|
||
for far in [1e4, 1e6, 1e12] {
|
||
let s = SmartObject { uuid: "x".into(), quad: [[0.0, 0.0], [far, 5.0], [far, far], [0.0, 20.0]], size: None, warp: None };
|
||
assert!(s.needs_bake());
|
||
// What the importer asks for: the unbounded size is the 16384² clamp, 4 GiB as float RGBA.
|
||
assert_eq!(s.placed_size(100.0, 100.0, 4.0), (16384, 16384));
|
||
let (k, (w, h)) = s.bake_scale(100.0, 100.0, 4.0, doc);
|
||
assert!(k > 0.0 && k <= 4.0, "{k}");
|
||
assert!(w <= doc.0 * 4 && h <= doc.1 * 4, "{w}x{h}");
|
||
assert!(u64::from(w) * u64::from(h) <= MAX_BAKE_PIXELS, "{w}x{h}");
|
||
}
|
||
// A quad inside the document keeps the scale and the size it always had.
|
||
let s = SmartObject { uuid: "x".into(), quad: [[0.0, 0.0], [400.0, 50.0], [400.0, 150.0], [0.0, 200.0]], size: None, warp: None };
|
||
let (k, size) = s.bake_scale(100.0, 100.0, 2.0, doc);
|
||
assert_eq!((k, size), (2.0, s.placed_size(100.0, 100.0, 2.0)));
|
||
// Not-a-number corners stay small too.
|
||
let s = SmartObject { uuid: "x".into(), quad: [[f64::NAN; 2]; 4], size: None, warp: None };
|
||
let (_, (w, h)) = s.bake_scale(100.0, 100.0, 4.0, doc);
|
||
assert!(u64::from(w) * u64::from(h) <= MAX_BAKE_PIXELS);
|
||
}
|
||
}
|