M13.2: PSD smart objects (SoLd/PlLd + lnk2 embedded files) import as placed footage of the embedded document or image
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
da0ec526f3
commit
7352b624d4
|
|
@ -6,7 +6,9 @@
|
|||
//! so their blend modes act on the layers below, like Photoshop); type layers become editable
|
||||
//! text layers; layer masks are baked into the layer's alpha and vector masks become masks;
|
||||
//! layer effects map to Layer Styles; adjustment layers become adjustment layers with the
|
||||
//! matching effect (best effort); clipped layers preserve the transparency underneath.
|
||||
//! matching effect (best effort); clipped layers preserve the transparency underneath. Smart
|
||||
//! objects whose file is embedded become footage of that file (an embedded Photoshop document's
|
||||
//! merged image, or the embedded image) placed with the smart object's transform.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -164,6 +166,60 @@ fn footage_item(proj: &mut Project, cx: &mut Ctx, index: usize, name: &str) -> (
|
|||
(id, (w, h))
|
||||
}
|
||||
|
||||
/// Size of an embedded file's image: the smart object's recorded size, else the embedded
|
||||
/// document's header (Photoshop, PNG).
|
||||
fn embedded_size(so: &effectcraft_psd::SmartObject, data: &[u8]) -> Option<(u32, u32)> {
|
||||
if let Some([w, h]) = so.size.filter(|s| s[0] >= 1.0 && s[1] >= 1.0) {
|
||||
return Some((w.round() as u32, h.round() as u32));
|
||||
}
|
||||
if effectcraft_psd::is_psd(data) && data.len() >= 22 {
|
||||
let h = u32::from_be_bytes([data[14], data[15], data[16], data[17]]);
|
||||
let w = u32::from_be_bytes([data[18], data[19], data[20], data[21]]);
|
||||
return Some((w, h));
|
||||
}
|
||||
if data.starts_with(b"\x89PNG") && data.len() >= 24 {
|
||||
let w = u32::from_be_bytes([data[16], data[17], data[18], data[19]]);
|
||||
let h = u32::from_be_bytes([data[20], data[21], data[22], data[23]]);
|
||||
return Some((w, h));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// A smart object layer as footage of its embedded file, placed by the smart object's
|
||||
/// transform. `None` when the file is not embedded (linked externally) or unreadable.
|
||||
fn smart_object_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, index: usize, name: &str) -> Option<Layer> {
|
||||
let pl = &cx.psd.layers[index];
|
||||
let so = pl.smart_object.clone()?;
|
||||
let data = cx.psd.linked_data(&so.uuid)?;
|
||||
let (w, h) = embedded_size(&so, data).filter(|s| s.0 > 0 && s.1 > 0)?;
|
||||
let file = cx.psd.linked.iter().find(|f| f.uuid == so.uuid).map(|f| f.name.clone()).unwrap_or_default();
|
||||
let f = Footage {
|
||||
path: cx.path.to_string(),
|
||||
kind: FootageKind::Still,
|
||||
width: w,
|
||||
height: h,
|
||||
pixel_aspect: 1.0,
|
||||
frame_rate: cx.rate,
|
||||
has_video: true,
|
||||
alpha: AlphaMode::Straight,
|
||||
loop_count: 1,
|
||||
codec: if effectcraft_psd::is_psd(data) { "PSD".into() } else { "PSD (embedded image)".into() },
|
||||
layer: Some(SourceLayer { index: index as u32, name: name.to_string(), layer_size: true, embedded: Some(so.uuid.clone()) }),
|
||||
..Default::default()
|
||||
};
|
||||
let label = if file.is_empty() { name.to_string() } else { file };
|
||||
let id = proj.add_item(&format!("{label} (Smart Object)"), Label::Lavender, Some(cx.folder), ItemKind::Footage(f));
|
||||
cx.items.push(id);
|
||||
let mut l = build::layer(proj, comp, name, LayerSource::Footage { item: id }, (w, h), None);
|
||||
let (c, sx, sy, rot) = so.placement(w as f64, h as f64);
|
||||
set_vec2(&mut l, "transform/position", c);
|
||||
set_vec2(&mut l, "transform/scale", [sx * 100.0, sy * 100.0]);
|
||||
if rot.abs() > 1e-9 {
|
||||
set_scalar(&mut l, "transform/rotation", rot);
|
||||
}
|
||||
Some(l)
|
||||
}
|
||||
|
||||
fn build_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, n: &Node) -> Option<Layer> {
|
||||
let (index, children) = match n {
|
||||
Node::Layer(i) => (*i, None),
|
||||
|
|
@ -183,6 +239,8 @@ fn build_layer(proj: &mut Project, cx: &mut Ctx, comp: &Comp, n: &Node) -> Optio
|
|||
let mut l = adjustment_layer(proj, comp, &name);
|
||||
add_adjustment_effect(proj, &mut l, adj, comp, &mut cx.warnings, &name);
|
||||
l
|
||||
} else if let Some(l) = pl.smart_object.as_ref().and_then(|_| smart_object_layer(proj, cx, comp, index, &name)) {
|
||||
l
|
||||
} else if pl.has_pixels() || pl.fill.is_some() {
|
||||
let (item, size) = footage_item(proj, cx, index, &name);
|
||||
let mut l = build::layer(proj, comp, &name, LayerSource::Footage { item }, size, None);
|
||||
|
|
|
|||
|
|
@ -281,6 +281,43 @@ fn pdf_and_ai_footage_layers_and_shapes_from_vector_layer() {
|
|||
assert!(p[2] > p[0], "only sky under the art {p:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn psd_smart_object_imports_its_embedded_document() {
|
||||
// The embedded document: red left half, green right half (20×10).
|
||||
let mut inner = WDoc::new(20, 10);
|
||||
inner.layers =
|
||||
vec![WLayer::solid("Red", Rect::new(0, 0, 10, 10), [1.0, 0.0, 0.0, 1.0]), WLayer::solid("Green", Rect::new(10, 0, 10, 10), [0.0, 1.0, 0.0, 1.0])];
|
||||
let inner_bytes = write(&inner);
|
||||
let mut d = WDoc::new(64, 48);
|
||||
// Placed at 2× and turned a quarter clockwise: the content's top-left corner at (40, 4).
|
||||
let quad = [[40.0, 4.0], [40.0, 44.0], [20.0, 44.0], [20.0, 4.0]];
|
||||
d.layers = vec![
|
||||
WLayer::solid("Back", Rect::new(0, 0, 64, 48), [0.0, 0.0, 0.0, 1.0]),
|
||||
// Placeholder pixels (grey) that the embedded file replaces.
|
||||
WLayer::solid("Placed", Rect::new(20, 4, 20, 40), [0.5, 0.5, 0.5, 1.0]).with_block(smart_object_block("so-1", quad, [20.0, 10.0])),
|
||||
];
|
||||
d.linked = vec![("so-1".into(), "inner.psd".into(), *b"8BPS", inner_bytes)];
|
||||
let path = tmp("smart.psd");
|
||||
std::fs::write(&path, write(&d)).unwrap();
|
||||
let mut s = effectcraft_host::session();
|
||||
let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap();
|
||||
assert_eq!(r["errors"], json!([]), "{r}");
|
||||
let cid = ItemId(r["comps"][0].as_u64().unwrap());
|
||||
let comp = s.project.comp(cid).unwrap();
|
||||
let placed = comp.layers.iter().find(|l| l.name == "Placed").unwrap();
|
||||
let effectcraft_project::LayerSource::Footage { item } = placed.source else { panic!("footage layer") };
|
||||
let effectcraft_project::ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!() };
|
||||
assert_eq!((f.width, f.height), (20, 10));
|
||||
assert_eq!(f.layer.as_ref().unwrap().embedded.as_deref(), Some("so-1"));
|
||||
assert!(s.project.item(item).unwrap().name.contains("inner.psd"));
|
||||
let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default());
|
||||
// Rotated a quarter clockwise: the red half is on top (rows 4–24), green below.
|
||||
let (red, green) = (img.get(30, 14), img.get(30, 34));
|
||||
assert!(red[0] > 0.95 && red[1] < 0.05, "{red:?}");
|
||||
assert!(green[1] > 0.95 && green[0] < 0.05, "{green:?}");
|
||||
assert!(img.get(10, 24)[0] < 0.01 && img.get(10, 24)[3] > 0.99, "background outside");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eps_footage_imports() {
|
||||
let eps =
|
||||
|
|
|
|||
|
|
@ -67,6 +67,17 @@ pub(crate) fn probe(path: &str, bytes: &[u8]) -> Result<Option<Footage>> {
|
|||
pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -> Result<Option<Image>> {
|
||||
if effectcraft_psd::is_psd(bytes) {
|
||||
let psd = effectcraft_psd::Psd::parse(bytes.to_vec()).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?;
|
||||
// A smart object's embedded file: an embedded document's merged image, or an image.
|
||||
if let Some(uuid) = footage.layer.as_ref().and_then(|l| l.embedded.as_deref()) {
|
||||
let data = psd.linked_data(uuid).ok_or_else(|| MediaError::Decode(format!("{path}: no embedded file {uuid}")))?;
|
||||
if effectcraft_psd::is_psd(data) {
|
||||
let inner = effectcraft_psd::Psd::parse(data.to_vec()).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
|
||||
let px = inner.composite().map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
|
||||
return Ok(Some(straight_to_image(px.width, px.height, &px.data, op)));
|
||||
}
|
||||
let img = image::load_from_memory(data).map_err(|e| MediaError::Decode(format!("{path} (smart object): {e}")))?;
|
||||
return Ok(Some(crate::convert::dynamic_to_image(&img, op)));
|
||||
}
|
||||
let px = match &footage.layer {
|
||||
Some(l) => psd.layer_pixels(l.index as usize, !l.layer_size),
|
||||
None => psd.composite(),
|
||||
|
|
|
|||
|
|
@ -386,9 +386,10 @@ impl Inner {
|
|||
let i = Self::frame_index(footage.frame_rate, t, footage.sequence.len() as i64, footage.loop_count);
|
||||
Loc { key: key(&footage.sequence[i as usize], 0), media_t: None }
|
||||
}
|
||||
// A layer of a layered still is keyed by its layer index and size mode.
|
||||
// A layer of a layered still is keyed by its layer index and size mode (smart objects
|
||||
// by their embedded file).
|
||||
FootageKind::Still if footage.layer.is_some() => {
|
||||
let l = footage.layer.as_ref().map_or(0, |l| 1 + l.index as i64 * 2 + l.layer_size as i64);
|
||||
let l = footage.layer.as_ref().map_or(0, |l| 1 + l.index as i64 * 2 + l.layer_size as i64 + if l.embedded.is_some() { 1 << 40 } else { 0 });
|
||||
Loc { key: key(&footage.path, l), media_t: None }
|
||||
}
|
||||
FootageKind::Still | FootageKind::Sequence | FootageKind::Model | FootageKind::Data => Loc { key: key(&footage.path, 0), media_t: None },
|
||||
|
|
|
|||
|
|
@ -8,10 +8,14 @@ Implemented from Adobe's public **Adobe Photoshop File Formats Specification** (
|
|||
edition): file header, colour mode data, image resources, layer and mask information (layer
|
||||
records, channel image data, layer mask data, additional layer information: `luni`, `lsct`/`lsdk`,
|
||||
`lyid`, `iOpa`, `TySh`, `lfx2`, `SoCo`, `nvrt`, `brit`, `hue2`, `levl`, `expA`, `vmsk`/`vsms`,
|
||||
`Lr16`/`Lr32`), the descriptor (action) structure, and the merged image data. Raw, RLE (PackBits)
|
||||
`Lr16`/`Lr32`, smart objects: `SoLd`/`SoLE`/`PlLd` placement and `lnk2`/`lnkD`/`lnk3`/`lnkE`
|
||||
linked layer data with embedded files), the descriptor (action) structure, and the merged image data. Raw, RLE (PackBits)
|
||||
and ZIP (with and without prediction, inflated with `miniz_oxide`) compression; 1/8/16/32-bit;
|
||||
Bitmap, Grayscale, Indexed, RGB, CMYK (naive conversion) and Lab colour modes.
|
||||
|
||||
Text engine data (`EngineData`) is read best-effort for the font, size, fill colour and
|
||||
justification of the first style run. No Adobe code, sample files or assets were used; test
|
||||
justification of the first style run. Smart objects import as footage of their embedded file
|
||||
(a Photoshop document's merged image, or an image) placed by the smart object's corner quad
|
||||
(position, scale, rotation; perspective and warps are not applied). Externally linked files
|
||||
fall back to the layer's rendered pixels. No Adobe code, sample files or assets were used; test
|
||||
fixtures are generated in-test by the writer in this crate ([`write`](src/write.rs)).
|
||||
|
|
|
|||
|
|
@ -204,6 +204,46 @@ pub struct SubPath {
|
|||
pub knots: Vec<[[f64; 2]; 3]>,
|
||||
}
|
||||
|
||||
/// 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 ([`Psd::linked`]) the layer shows.
|
||||
pub uuid: String,
|
||||
/// Where the content's corners sit in document pixels: top-left, top-right, bottom-right,
|
||||
/// bottom-left.
|
||||
pub quad: [[f64; 2]; 4],
|
||||
/// The content's size in pixels (`Sz `), when recorded.
|
||||
pub size: Option<[f64; 2]>,
|
||||
}
|
||||
|
||||
impl SmartObject {
|
||||
/// 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())
|
||||
}
|
||||
}
|
||||
|
||||
/// A file embedded in (or linked from) the document: linked layer data (`lnk2`, `lnkD`,
|
||||
/// `lnk3`, `lnkE`).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct LinkedFile {
|
||||
pub uuid: String,
|
||||
pub name: String,
|
||||
/// File type code (`8BPS`, `PNGf`, `JPEG`…), may be blank.
|
||||
pub file_type: String,
|
||||
/// Embedded data (absent for externally linked files).
|
||||
pub data: Option<std::ops::Range<usize>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Layer {
|
||||
/// Index in [`Psd::layers`] (file order: bottom of the stack first).
|
||||
|
|
@ -230,6 +270,8 @@ pub struct Layer {
|
|||
/// Layer effects (`lfx2` descriptor).
|
||||
pub effects: Option<Descriptor>,
|
||||
pub vector_mask: Option<VectorMask>,
|
||||
/// Smart object placement.
|
||||
pub smart_object: Option<SmartObject>,
|
||||
/// Keys of every additional-information block the layer carries.
|
||||
pub keys: Vec<[u8; 4]>,
|
||||
}
|
||||
|
|
@ -277,6 +319,8 @@ pub struct Psd {
|
|||
pub merged_alpha: bool,
|
||||
/// Indexed colour palette (256 R, then G, then B).
|
||||
pub palette: Vec<u8>,
|
||||
/// Files embedded for smart objects.
|
||||
pub linked: Vec<LinkedFile>,
|
||||
data: Arc<[u8]>,
|
||||
merged_offset: usize,
|
||||
}
|
||||
|
|
@ -314,7 +358,20 @@ impl Psd {
|
|||
let n = r.u32()? as usize;
|
||||
r.skip(n)?;
|
||||
// Layer and mask information.
|
||||
let mut psd = Psd { psb, width, height, channels, depth, mode, layers: vec![], merged_alpha: false, palette, data: data.clone(), merged_offset: 0 };
|
||||
let mut psd = Psd {
|
||||
psb,
|
||||
width,
|
||||
height,
|
||||
channels,
|
||||
depth,
|
||||
mode,
|
||||
layers: vec![],
|
||||
merged_alpha: false,
|
||||
palette,
|
||||
linked: vec![],
|
||||
data: data.clone(),
|
||||
merged_offset: 0,
|
||||
};
|
||||
let lm_len = r.length(psb)?;
|
||||
let lm_end = r.pos.checked_add(lm_len).filter(|&e| e <= data.len()).ok_or(Error::Truncated)?;
|
||||
if lm_len > 0 {
|
||||
|
|
@ -330,6 +387,9 @@ impl Psd {
|
|||
}
|
||||
// Global additional layer information (16/32-bit layer info lives here).
|
||||
while let Some((key, start, end)) = next_block(&data, &mut r.pos, lm_end, psb) {
|
||||
if matches!(&key, b"lnk2" | b"lnkD" | b"lnk3" | b"lnkE") {
|
||||
psd.linked_files(&data, start, end);
|
||||
}
|
||||
if matches!(&key, b"Lr16" | b"Lr32" | b"Layr") && psd.layers.is_empty() && end > start {
|
||||
// The block holds the layer info, normally without its length field; accept
|
||||
// both layouts.
|
||||
|
|
@ -348,6 +408,67 @@ impl Psd {
|
|||
Ok(psd)
|
||||
}
|
||||
|
||||
/// The embedded bytes of linked file `uuid`.
|
||||
pub fn linked_data(&self, uuid: &str) -> Option<&[u8]> {
|
||||
let f = self.linked.iter().find(|f| f.uuid == uuid)?;
|
||||
self.data.get(f.data.clone()?)
|
||||
}
|
||||
|
||||
/// Linked layer data records (spec: "Linked Layer"): length, kind (`liFD` embedded data,
|
||||
/// `liFE` external file, `liFA` alias), version, unique id, original file name, type,
|
||||
/// creator, data length, optional descriptors, then the data. A damaged record ends the list.
|
||||
fn linked_files(&mut self, data: &[u8], start: usize, end: usize) {
|
||||
let mut pos = start;
|
||||
while pos + 8 <= end {
|
||||
let mut r = Reader::at(&data[..end], pos);
|
||||
let Ok(len) = r.u64() else { break };
|
||||
let body = r.pos;
|
||||
let Some(rec_end) = body.checked_add(len as usize).filter(|e| *e <= end) else { break };
|
||||
let mut r = Reader::at(&data[..rec_end], body);
|
||||
let rec = (|| -> Result<LinkedFile> {
|
||||
let kind = r.tag()?;
|
||||
let version = r.u32()?;
|
||||
let n = r.u8()? as usize;
|
||||
let uuid = String::from_utf8_lossy(r.bytes(n)?).into_owned();
|
||||
let name = descriptor::read_unicode(&mut r)?;
|
||||
let file_type = String::from_utf8_lossy(&r.tag()?).trim().to_string();
|
||||
let _creator = r.tag()?;
|
||||
let dlen = r.u64()? as usize;
|
||||
if r.u8()? != 0 {
|
||||
let _v = r.u32()?;
|
||||
descriptor::read_descriptor(&mut r)?;
|
||||
}
|
||||
let mut out = LinkedFile { uuid, name, file_type, data: None };
|
||||
match &kind {
|
||||
b"liFD" => {
|
||||
let s = r.pos;
|
||||
r.skip(dlen)?;
|
||||
out.data = Some(s..s + dlen);
|
||||
}
|
||||
b"liFE" => {
|
||||
let _v = r.u32()?;
|
||||
descriptor::read_descriptor(&mut r)?;
|
||||
if version > 3 {
|
||||
r.skip(4 + 4 + 8)?;
|
||||
}
|
||||
let _size = r.u64()?;
|
||||
if version > 2 && r.remaining() >= dlen && dlen > 0 {
|
||||
let s = r.pos;
|
||||
out.data = Some(s..s + dlen);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(out)
|
||||
})();
|
||||
match rec {
|
||||
Ok(f) => self.linked.push(f),
|
||||
Err(_) => break,
|
||||
}
|
||||
pos = rec_end.div_ceil(4) * 4;
|
||||
}
|
||||
}
|
||||
|
||||
fn layer_info(&mut self, data: &[u8], start: usize, end: usize) -> Result<()> {
|
||||
let mut r = Reader::at(&data[..end], start);
|
||||
let count = r.i16()?;
|
||||
|
|
@ -522,6 +643,46 @@ impl Psd {
|
|||
l.adjustment = Some(Adjustment::Other(String::from_utf8_lossy(key).into_owned()));
|
||||
}
|
||||
}
|
||||
b"SoLd" | b"SoLE" => {
|
||||
let _id = r.tag()?;
|
||||
let _ver = r.u32()?;
|
||||
let _dver = r.u32()?;
|
||||
let d = descriptor::read_descriptor(&mut r)?;
|
||||
let uuid = d.text("Idnt").unwrap_or_default().to_string();
|
||||
let nums = |k: &str| -> Option<Vec<f64>> {
|
||||
match d.get(k)? {
|
||||
DValue::List(l) => Some(
|
||||
l.iter()
|
||||
.filter_map(|v| match v {
|
||||
DValue::Double(x) | DValue::UnitFloat(_, x) => Some(*x),
|
||||
DValue::Integer(i) => Some(*i as f64),
|
||||
_ => None,
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
DValue::UnitFloats(_, v) => Some(v.clone()),
|
||||
_ => None,
|
||||
}
|
||||
};
|
||||
let t = nums("Trnf").filter(|t| t.len() == 8).unwrap_or_else(|| vec![0.0; 8]);
|
||||
let size = d.obj("Sz ").and_then(|s| Some([s.num("Wdth")?, s.num("Hght")?]));
|
||||
l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size });
|
||||
}
|
||||
b"PlLd" if l.smart_object.is_none() => {
|
||||
let _id = r.tag()?;
|
||||
let _ver = r.u32()?;
|
||||
let n = r.u8()? as usize;
|
||||
let uuid = String::from_utf8_lossy(r.bytes(n)?).into_owned();
|
||||
let _page = r.u32()?;
|
||||
let _pages = r.u32()?;
|
||||
let _aa = r.u32()?;
|
||||
let _ty = r.u32()?;
|
||||
let mut t = [0.0; 8];
|
||||
for v in &mut t {
|
||||
*v = r.f64()?;
|
||||
}
|
||||
l.smart_object = Some(SmartObject { uuid, quad: [[t[0], t[1]], [t[2], t[3]], [t[4], t[5]], [t[6], t[7]]], size: None });
|
||||
}
|
||||
b"vmsk" | b"vsms" => {
|
||||
let _ver = r.u32()?;
|
||||
let flags = r.u32()?;
|
||||
|
|
|
|||
|
|
@ -187,3 +187,34 @@ fn truncated_and_garbage_input_errors() {
|
|||
assert!(Psd::parse(b"8BPS\0\x09garbage".to_vec()).is_err());
|
||||
assert!(Psd::parse(b"PNG".to_vec()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_objects_and_embedded_files() {
|
||||
let mut inner = WDoc::new(20, 10);
|
||||
inner.layers = vec![WLayer::solid("Red", Rect::new(0, 0, 20, 10), [1.0, 0.0, 0.0, 1.0])];
|
||||
let inner_bytes = write(&inner);
|
||||
let mut d = WDoc::new(64, 48);
|
||||
// The content scaled 2× and rotated 90° clockwise: top-left at (40, 4).
|
||||
let quad = [[40.0, 4.0], [40.0, 44.0], [20.0, 44.0], [20.0, 4.0]];
|
||||
d.layers = vec![
|
||||
WLayer::solid("Back", Rect::new(0, 0, 64, 48), [0.0, 0.0, 0.0, 1.0]),
|
||||
WLayer::solid("Placed", Rect::new(20, 4, 20, 40), [0.5, 0.5, 0.5, 1.0]).with_block(smart_object_block("uuid-1", quad, [20.0, 10.0])),
|
||||
];
|
||||
d.linked = vec![("uuid-1".into(), "inner.psd".into(), *b"8BPS", inner_bytes.clone())];
|
||||
let p = Psd::parse(write(&d)).unwrap();
|
||||
assert_eq!(p.linked.len(), 1);
|
||||
assert_eq!(p.linked[0].name, "inner.psd");
|
||||
assert_eq!(p.linked[0].file_type, "8BPS");
|
||||
assert_eq!(p.linked_data("uuid-1").unwrap(), &inner_bytes[..]);
|
||||
let so = p.layers[1].smart_object.clone().unwrap();
|
||||
assert_eq!(so.uuid, "uuid-1");
|
||||
assert_eq!(so.quad, quad);
|
||||
assert_eq!(so.size, Some([20.0, 10.0]));
|
||||
let (c, sx, sy, rot) = so.placement(20.0, 10.0);
|
||||
assert_eq!(c, [30.0, 24.0]);
|
||||
assert!((sx - 2.0).abs() < 1e-9 && (sy - 2.0).abs() < 1e-9 && (rot - 90.0).abs() < 1e-9, "{sx} {sy} {rot}");
|
||||
// The embedded document parses on its own.
|
||||
let e = Psd::parse(p.linked_data("uuid-1").unwrap().to_vec()).unwrap();
|
||||
assert_eq!((e.width, e.height), (20, 10));
|
||||
assert!(p.layers[0].smart_object.is_none());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -103,11 +103,14 @@ pub struct WDoc {
|
|||
/// Merged image (straight RGBA, document-sized); `None` composites visible pixel layers
|
||||
/// with Normal blending.
|
||||
pub composite: Option<Vec<[f32; 4]>>,
|
||||
/// Embedded files for smart objects: (unique id, file name, type code, bytes), written as
|
||||
/// linked layer data (`lnk2`).
|
||||
pub linked: Vec<(String, String, [u8; 4], Vec<u8>)>,
|
||||
}
|
||||
|
||||
impl WDoc {
|
||||
pub fn new(width: u32, height: u32) -> WDoc {
|
||||
WDoc { width, height, depth: 8, mode: ColorMode::Rgb, rle: true, layers: vec![], composite: None }
|
||||
WDoc { width, height, depth: 8, mode: ColorMode::Rgb, rle: true, layers: vec![], composite: None, linked: vec![] }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -339,6 +342,13 @@ pub fn write(doc: &WDoc) -> Vec<u8> {
|
|||
lm.extend_from_slice(&li);
|
||||
u32b(&mut lm, 0);
|
||||
}
|
||||
if !doc.linked.is_empty() {
|
||||
let d = linked_block(&doc.linked);
|
||||
lm.extend_from_slice(b"8BIM");
|
||||
lm.extend_from_slice(b"lnk2");
|
||||
u32b(&mut lm, d.len() as u32);
|
||||
lm.extend_from_slice(&d);
|
||||
}
|
||||
}
|
||||
u32b(&mut out, lm.len() as u32);
|
||||
out.extend_from_slice(&lm);
|
||||
|
|
@ -372,8 +382,53 @@ pub fn write(doc: &WDoc) -> Vec<u8> {
|
|||
out
|
||||
}
|
||||
|
||||
/// Linked layer data records (`liFD`, version 7) for embedded files.
|
||||
fn linked_block(files: &[(String, String, [u8; 4], Vec<u8>)]) -> Vec<u8> {
|
||||
let mut out = vec![];
|
||||
for (uuid, name, ty, data) in files {
|
||||
let mut rec = vec![];
|
||||
rec.extend_from_slice(b"liFD");
|
||||
u32b(&mut rec, 7);
|
||||
rec.push(uuid.len() as u8);
|
||||
rec.extend_from_slice(uuid.as_bytes());
|
||||
rec.extend_from_slice(&unicode_plain(name));
|
||||
rec.extend_from_slice(ty);
|
||||
rec.extend_from_slice(b"8BIM");
|
||||
rec.extend_from_slice(&(data.len() as u64).to_be_bytes());
|
||||
rec.push(0); // no file-open descriptor
|
||||
rec.extend_from_slice(data);
|
||||
u32b(&mut rec, 0); // child document id (empty unicode string)
|
||||
rec.extend_from_slice(&0f64.to_be_bytes()); // asset modification time
|
||||
rec.push(0); // asset locked state
|
||||
out.extend_from_slice(&(rec.len() as u64).to_be_bytes());
|
||||
out.extend_from_slice(&rec);
|
||||
while !out.len().is_multiple_of(4) {
|
||||
out.push(0);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- block helpers
|
||||
|
||||
/// `SoLd`: a placed layer showing linked file `uuid` (content `size` pixels) with its corners at
|
||||
/// `quad` (top-left, top-right, bottom-right, bottom-left).
|
||||
pub fn smart_object_block(uuid: &str, quad: [[f64; 2]; 4], size: [f64; 2]) -> ([u8; 4], Vec<u8>) {
|
||||
let list = |v: &[f64]| DValue::List(v.iter().map(|x| DValue::Double(*x)).collect());
|
||||
let t: Vec<f64> = quad.iter().flat_map(|p| [p[0], p[1]]).collect();
|
||||
let d = Descriptor::new("null")
|
||||
.with("Idnt", DValue::Text(uuid.to_string()))
|
||||
.with("placed", DValue::Text(uuid.to_string()))
|
||||
.with("Trnf", list(&t))
|
||||
.with("nonAffineTransform", list(&t))
|
||||
.with("Sz ", DValue::Descriptor(Descriptor::new("Pnt ").with("Wdth", DValue::Double(size[0])).with("Hght", DValue::Double(size[1]))));
|
||||
let mut out = b"soLD".to_vec();
|
||||
u32b(&mut out, 4);
|
||||
u32b(&mut out, 16);
|
||||
descriptor::write_descriptor(&mut out, &d);
|
||||
(*b"SoLd", out)
|
||||
}
|
||||
|
||||
/// `TySh`: a type layer with one style run; the text origin sits at `origin`.
|
||||
pub fn text_block(text: &str, origin: [f64; 2], font: &str, size: f64, color: [f64; 3], justification: u8) -> ([u8; 4], Vec<u8>) {
|
||||
let mut d = vec![];
|
||||
|
|
|
|||
Loading…
Reference in New Issue