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:
Brandon Thomas 2026-10-03 04:18:05 -04:00
parent da0ec526f3
commit 7352b624d4
8 changed files with 365 additions and 7 deletions

View File

@ -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);

View File

@ -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 =

View File

@ -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(),

View File

@ -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 },

View File

@ -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)).

View File

@ -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()?;

View File

@ -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());
}

View File

@ -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![];