diff --git a/crates/engine/src/psd_import.rs b/crates/engine/src/psd_import.rs index 2f012eee..555dd97b 100644 --- a/crates/engine/src/psd_import.rs +++ b/crates/engine/src/psd_import.rs @@ -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 { + 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 { 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); diff --git a/crates/host/tests/vector_import.rs b/crates/host/tests/vector_import.rs index 7b516a2a..9b77ccae 100644 --- a/crates/host/tests/vector_import.rs +++ b/crates/host/tests/vector_import.rs @@ -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 = diff --git a/crates/media/src/layered.rs b/crates/media/src/layered.rs index 111140a4..b2331d81 100644 --- a/crates/media/src/layered.rs +++ b/crates/media/src/layered.rs @@ -67,6 +67,17 @@ pub(crate) fn probe(path: &str, bytes: &[u8]) -> Result> { pub(crate) fn decode(path: &str, bytes: &[u8], footage: &Footage, op: AlphaOp) -> Result> { 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(), diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index ed6521c9..a0864f08 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -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 }, diff --git a/crates/psd/README.md b/crates/psd/README.md index e238b9b2..b0b19ba3 100644 --- a/crates/psd/README.md +++ b/crates/psd/README.md @@ -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)). diff --git a/crates/psd/src/lib.rs b/crates/psd/src/lib.rs index 885ab3c2..61cadc7d 100644 --- a/crates/psd/src/lib.rs +++ b/crates/psd/src/lib.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>, +} + #[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, pub vector_mask: Option, + /// Smart object placement. + pub smart_object: Option, /// 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, + /// Files embedded for smart objects. + pub linked: Vec, 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 { + 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> { + 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()?; diff --git a/crates/psd/src/tests.rs b/crates/psd/src/tests.rs index 68680fe7..cd2f9293 100644 --- a/crates/psd/src/tests.rs +++ b/crates/psd/src/tests.rs @@ -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()); +} diff --git a/crates/psd/src/write.rs b/crates/psd/src/write.rs index fb78cc78..160e81d3 100644 --- a/crates/psd/src/write.rs +++ b/crates/psd/src/write.rs @@ -103,11 +103,14 @@ pub struct WDoc { /// Merged image (straight RGBA, document-sized); `None` composites visible pixel layers /// with Normal blending. pub composite: Option>, + /// 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)>, } 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 { 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 { out } +/// Linked layer data records (`liFD`, version 7) for embedded files. +fn linked_block(files: &[(String, String, [u8; 4], Vec)]) -> Vec { + 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) { + let list = |v: &[f64]| DValue::List(v.iter().map(|x| DValue::Double(*x)).collect()); + let t: Vec = 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) { let mut d = vec![];