From 2d216151c688fb74558128e8bd7943c8a0ba0ad1 Mon Sep 17 00:00:00 2001 From: arnav-hiwarkar Date: Thu, 15 Oct 2026 13:33:50 +0530 Subject: [PATCH] Import: Composition - Retain Layer Sizes for PDF / Illustrator files (#1536) The PDF / Illustrator import dialog now offers Composition - Retain Layer Sizes. Each file layer's footage is cropped to its artwork bounds (fills, stroke outlines, images, clips) and positioned to keep its place on the page. Decoding, continuous rasterisation, shape conversion and reload share one crop (effectcraft_pdf::layer_doc_with_sizes), so they agree. Also collapses a match guard in the STL reader for current stable clippy. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/file.rs | 6 +- crates/engine/src/footage_reload.rs | 7 ++ crates/engine/src/vector.rs | 26 +++++-- crates/host/tests/vector_import.rs | 108 ++++++++++++++++++++++----- crates/media/src/layered.rs | 2 +- crates/model/src/stl.rs | 6 +- crates/pdf/README.md | 4 +- crates/pdf/src/lib.rs | 21 ++++++ crates/pdf/src/tests.rs | 45 +++++++++++ crates/svg/src/bounds.rs | 64 ++++++++++++++++ crates/svg/src/lib.rs | 23 ++++++ crates/svg/src/raster.rs | 18 +---- crates/ui-egui/src/panels/forms.rs | 11 ++- crates/ui-egui/tests/ui_drag_drop.rs | 25 +++++++ 14 files changed, 314 insertions(+), 52 deletions(-) create mode 100644 crates/svg/src/bounds.rs diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 3b43eb63..501d1548 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -391,7 +391,7 @@ fn prepare_import(s: &mut Session, p: &Value) -> Result { Ok(b) if effectcraft_psd::is_psd(&b) => b, Ok(b) if effectcraft_pdf::sniff(&b).is_some() && !path.to_ascii_lowercase().ends_with(".svg") => { // PDF / Illustrator / EPS: one layer per file layer. - match import_vector_comp(s, &path, &b, page) { + match import_vector_comp(s, &path, &b, page, retain) { Ok((comp, made)) => { comps.push(comp); items.extend(made); @@ -540,7 +540,7 @@ pub(crate) fn import_psd_comp(s: &mut Session, path: &str, bytes: Vec, retai /// Import page `page` of a PDF / Illustrator / EPS file as a composition (one undo step). /// Returns (comp, items). -fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32) -> Result<(u64, Vec)> { +fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32, retain: bool) -> Result<(u64, Vec)> { let mut name = std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "Vector".into()); if page > 0 { name = format!("{name} (Page {})", page + 1); @@ -549,7 +549,7 @@ fn import_vector_comp(s: &mut Session, path: &str, bytes: &[u8], page: u32) -> R let secs = if s.prefs.import.still_footage == "seconds" { s.prefs.import.still_seconds } else { 10.0 }; let duration = rate.snap_nearest(effectcraft_time::Tick::from_seconds_f64(secs)); let (comp, folder, items) = s.edit("Import", None, |proj, st| { - let r = crate::vector::import_vector_comp(proj, path, bytes, &name, page, rate, duration).map_err(EngineError::Other)?; + let r = crate::vector::import_vector_comp(proj, path, bytes, &name, page, retain, (rate, duration)).map_err(EngineError::Other)?; st.project_selection = vec![r.0]; Ok(r) })?; diff --git a/crates/engine/src/footage_reload.rs b/crates/engine/src/footage_reload.rs index a8f32a37..ca51069a 100644 --- a/crates/engine/src/footage_reload.rs +++ b/crates/engine/src/footage_reload.rs @@ -58,6 +58,12 @@ fn psd_layer(s: &Session, path: &str, l: &SourceLayer) -> Option<(u32, Option<(u Some((u32::try_from(found.index).ok()?, size)) } +fn vector_size(s: &Session, f: &Footage) -> Option<(u32, u32)> { + let bytes = s.services.read_file(&f.path).ok()?; + let doc = crate::vector::vector_doc(&f.path, &bytes, f.layer.as_ref(), f.page)?.ok()?; + (!doc.root.children.is_empty()).then(|| doc.pixel_size()) +} + /// Footage `f` as its file is now, given the importer's fresh look at the file (`probed`). fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage { let mut nf = f.clone(); @@ -89,6 +95,7 @@ fn refreshed(s: &Session, f: &Footage, probed: Footage) -> Footage { match &f.layer { // A smart object's size is its embedded file's, recorded at import. Some(l) if l.embedded.is_some() => (w, h) = (f.width, f.height), + Some(_) if crate::vector::is_vector(f) => (w, h) = vector_size(s, f).unwrap_or((f.width, f.height)), Some(l) => match psd_layer(s, &f.path, l) { Some((index, size)) => { if let Some(nl) = nf.layer.as_mut() { diff --git a/crates/engine/src/vector.rs b/crates/engine/src/vector.rs index 23400953..3c349a7d 100644 --- a/crates/engine/src/vector.rs +++ b/crates/engine/src/vector.rs @@ -271,7 +271,7 @@ pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project:: Some( effectcraft_pdf::parse_page(bytes, page as usize) .map(|d| match layer { - Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize), + Some(l) => effectcraft_pdf::layer_doc_with_sizes(&d, l.index as usize, l.layer_size).0, None => d, }) .map_err(|e| format!("{path}: {e}")), @@ -279,7 +279,7 @@ pub fn vector_doc(path: &str, bytes: &[u8], layer: Option<&effectcraft_project:: } /// Import page `page` of a PDF / Illustrator / EPS document as a composition the size of the -/// page: one footage item per file layer (each showing only that layer, continuously +/// page: one footage item per file layer (optionally cropped to its artwork, continuously /// rasterisable), top layer first. Returns (comp, folder, items). pub fn import_vector_comp( proj: &mut Project, @@ -287,11 +287,12 @@ pub fn import_vector_comp( bytes: &[u8], name: &str, page: u32, - rate: effectcraft_time::FrameRate, - duration: effectcraft_time::Tick, + retain: bool, + timing: (effectcraft_time::FrameRate, effectcraft_time::Tick), ) -> Result<(ItemId, ItemId, Vec), String> { use effectcraft_color::Label; use effectcraft_project::{AlphaMode, Footage, FootageKind, ItemKind, SourceLayer}; + let (rate, duration) = timing; let doc = effectcraft_pdf::parse_page(bytes, page as usize).map_err(|e| format!("{path}: {e}"))?; let codec = effectcraft_pdf::codec(path, bytes).unwrap_or("PDF"); let (w, h) = doc.pixel_size(); @@ -301,24 +302,33 @@ pub fn import_vector_comp( let mut items = vec![]; let names = effectcraft_pdf::layer_names(&doc); for (i, lname) in names.iter().enumerate() { + let (size, offset) = if retain { + let (layer, offset) = effectcraft_pdf::layer_doc_with_sizes(&doc, i, true); + (layer.pixel_size(), offset) + } else { + ((w, h), [0.0, 0.0]) + }; let f = Footage { path: path.to_string(), kind: FootageKind::Still, - width: w, - height: h, + width: size.0, + height: size.1, pixel_aspect: 1.0, frame_rate: rate, has_video: true, alpha: AlphaMode::Straight, loop_count: 1, codec: codec.into(), - layer: Some(SourceLayer { index: i as u32, name: lname.clone(), layer_size: false, ..Default::default() }), + layer: Some(SourceLayer { index: i as u32, name: lname.clone(), layer_size: retain, ..Default::default() }), page, ..Default::default() }; let id = proj.add_item(&format!("{lname}/{file}"), Label::Lavender, Some(folder), ItemKind::Footage(f)); items.push(id); - let mut l = build::layer(proj, &comp, lname, LayerSource::Footage { item: id }, (w, h), None); + let mut l = build::layer(proj, &comp, lname, LayerSource::Footage { item: id }, size, None); + if retain && let Some(position) = l.props.prop_mut("transform/position") { + position.value = Value::Vec3([offset[0] + f64::from(size.0) / 2.0, offset[1] + f64::from(size.1) / 2.0, 0.0]); + } l.name = comp.unique_layer_name(lname); comp.layers.insert(0, l); } diff --git a/crates/host/tests/vector_import.rs b/crates/host/tests/vector_import.rs index 44d2073f..49f8d950 100644 --- a/crates/host/tests/vector_import.rs +++ b/crates/host/tests/vector_import.rs @@ -260,23 +260,25 @@ fn pdf_and_ai_footage_layers_and_shapes_from_vector_layer() { // Import As: Composition — one layer per file layer, top first, together like the page. let path = tmp("layers.ai"); std::fs::write(&path, &bytes).unwrap(); - let mut s = effectcraft_host::session(); - let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "composition"})).unwrap(); - let cid = ItemId(r["comps"][0].as_u64().unwrap()); - let comp = s.project.comp(cid).unwrap(); - let names: Vec<&str> = comp.layers.iter().map(|l| l.name.as_str()).collect(); - assert_eq!(names, vec!["Art", "Sky"]); - assert_eq!((comp.width, comp.height), (120, 80)); - let art = comp.layers[0].id.0; - let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); - let d = mean_diff(&img, &direct); - assert!(d < 1.0 / 255.0, "layered comp vs page {d}"); - // Each layer's footage shows only its layer. - s.execute("layer.setSwitch", json!({"layers": [art], "switch": "video", "value": false})).unwrap(); - let sky = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); - assert_eq!(sky.get(5, 5), img.get(5, 5)); - let p = sky.get(60, 30); - assert!(p[2] > p[0], "only sky under the art {p:?}"); + for import_as in ["composition", "compositionLayerSizes"] { + let mut s = effectcraft_host::session(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": import_as})).unwrap(); + let cid = ItemId(r["comps"][0].as_u64().unwrap()); + let comp = s.project.comp(cid).unwrap(); + let names: Vec<&str> = comp.layers.iter().map(|l| l.name.as_str()).collect(); + assert_eq!(names, vec!["Art", "Sky"]); + assert_eq!((comp.width, comp.height), (120, 80)); + let art = comp.layers[0].id.0; + let img = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); + let d = mean_diff(&img, &direct); + assert!(d < 1.0 / 255.0, "layered comp vs page {d}"); + // Each layer's footage shows only its layer. + s.execute("layer.setSwitch", json!({"layers": [art], "switch": "video", "value": false})).unwrap(); + let sky = s.render(cid, effectcraft_time::Tick::ZERO, RenderOpts::default()); + assert_eq!(sky.get(5, 5), img.get(5, 5)); + let p = sky.get(60, 30); + assert!(p[2] > p[0], "only sky under the art {p:?}"); + } } #[test] @@ -522,3 +524,75 @@ fn shapes_from_vector_layer_keeps_images_as_footage_layers() { assert!((0..3).all(|k| (a[k] - b[k]).abs() < 0.05), "({x}, {y}): {a:?} vs {b:?}"); } } + +fn retain_pdf(width: u32) -> Vec { + effectcraft_pdf::write::pdf( + &[ + (1, "<< /Type /Catalog /Pages 2 0 R >>".into(), None), + (2, "<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(), None), + (3, "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Resources << /Properties << /L0 5 0 R /L1 6 0 R >> >> /Contents 4 0 R >>".into(), None), + ( + 4, + "<< >>".into(), + Some( + format!("/OC /L0 BDC q 20 15 {width} 20 re W n 1 0 0 rg 0 0 120 80 re f Q EMC /OC /L1 BDC 0 0 1 RG 4 w 70 40 m 100 40 l S EMC") + .into_bytes(), + ), + ), + (5, "<< /Type /OCG /Name (Box) >>".into(), None), + (6, "<< /Type /OCG /Name (Line) >>".into(), None), + ], + 1, + ) +} + +#[test] +fn ai_composition_retains_layer_sizes_and_positions() { + use effectcraft_project::{ItemKind, LayerSource, Project}; + use effectcraft_time::Tick; + + for name in ["retain.ai", "retain.pdf"] { + let path = tmp(name); + let bytes = retain_pdf(30); + std::fs::write(&path, &bytes).unwrap(); + let mut s = effectcraft_host::session(); + let r = s.execute_checked("file.import", json!({"paths": [path], "importAs": "compositionLayerSizes"})).unwrap(); + assert_eq!(r["errors"], json!([]), "{r}"); + let cid = ItemId(r["comps"][0].as_u64().unwrap()); + let comp = s.project.comp(cid).unwrap(); + assert_eq!((comp.width, comp.height), (120, 80)); + assert_eq!(comp.layers.iter().map(|l| l.name.as_str()).collect::>(), ["Line", "Box"]); + let mut ids = vec![]; + for (name, size, anchor, position) in [("Box", (30, 20), [15.0, 10.0], [35.0, 55.0]), ("Line", (30, 4), [15.0, 2.0], [85.0, 40.0])] { + let l = comp.layer_by_name(name).unwrap(); + let LayerSource::Footage { item } = l.source else { panic!("footage") }; + let ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!("footage") }; + assert_eq!((f.width, f.height), size, "{name} uses its artwork bounds"); + assert!(f.layer.as_ref().unwrap().layer_size); + assert_eq!(l.props.prop("transform/anchor").unwrap().value.as_vec2(), anchor); + assert_eq!(l.props.prop("transform/position").unwrap().value.as_vec2(), position); + let frame = s.footage.frame(item, f, Tick::ZERO).unwrap(); + assert_eq!((frame.width, frame.height), size); + let sharp = s.footage.vector_frame(item, f, 2.0).unwrap(); + assert_eq!((sharp.width, sharp.height), (size.0 * 2, size.1 * 2)); + assert!(sharp.get(1, 1)[3] > 0.99); + ids.push(item); + } + let direct = effectcraft_svg::rasterize(&effectcraft_pdf::parse(&bytes).unwrap(), 120, 80, 1.0); + assert!(mean_diff(&s.render(cid, Tick::ZERO, RenderOpts::default()), &direct) < 0.001); + let saved = s.project.to_json(); + s.project = std::sync::Arc::new(Project::from_json(&saved).unwrap()); + assert!(mean_diff(&s.render(cid, Tick::ZERO, RenderOpts::default()), &direct) < 0.001, "reopened project"); + let box_id = s.active_comp().unwrap().layer_by_name("Box").unwrap().id.0; + s.execute_checked("layer.create", json!({"op": "shapesFromVector", "layers": [box_id]})).unwrap(); + assert!(mean_diff(&s.render(cid, Tick::ZERO, RenderOpts::default()), &direct) < 0.001, "shape conversion preserves placement"); + s.execute_checked("edit.undo", json!({})).unwrap(); + std::fs::write(&path, retain_pdf(40)).unwrap(); + s.execute_checked("file.reloadFootage", json!({"items": ids})).unwrap(); + let ItemKind::Footage(f) = &s.project.item(ids[0]).unwrap().kind else { panic!("footage") }; + assert_eq!((f.width, f.height), (40, 20), "reload refreshes the layer's own dimensions"); + let frame = s.footage.frame(ids[0], f, Tick::ZERO).unwrap(); + assert_eq!((frame.width, frame.height), (40, 20)); + assert!(frame.get(39, 10)[0] > 0.99, "reload invalidates cached pixels"); + } +} diff --git a/crates/media/src/layered.rs b/crates/media/src/layered.rs index 0fb40d81..0a70d1f7 100644 --- a/crates/media/src/layered.rs +++ b/crates/media/src/layered.rs @@ -28,7 +28,7 @@ pub fn vector_doc( Some( effectcraft_pdf::parse_page(bytes, page as usize) .map(|d| match layer { - Some(l) => effectcraft_pdf::layer_doc(&d, l.index as usize), + Some(l) => effectcraft_pdf::layer_doc_with_sizes(&d, l.index as usize, l.layer_size).0, None => d, }) .map_err(|e| format!("{path}: {e}")), diff --git a/crates/model/src/stl.rs b/crates/model/src/stl.rs index 6684858a..34e9a7f0 100644 --- a/crates/model/src/stl.rs +++ b/crates/model/src/stl.rs @@ -175,10 +175,8 @@ fn ascii(bytes: &[u8]) -> Result { let Some(key) = it.next() else { continue }; let bad = || perr(format!("STL line {}: expected three numbers", ln + 1)); match key.to_ascii_lowercase().as_str() { - "solid" => { - if name.is_none() { - name = Some(line.get(5..).unwrap_or("").trim().to_string()); - } + "solid" if name.is_none() => { + name = Some(line.get(5..).unwrap_or("").trim().to_string()); } "facet" => { // `facet normal nx ny nz` diff --git a/crates/pdf/README.md b/crates/pdf/README.md index b7678b09..b44e3165 100644 --- a/crates/pdf/README.md +++ b/crates/pdf/README.md @@ -4,7 +4,9 @@ Vector footage from PDF files, PDF-compatible Adobe Illustrator files (`.ai`, wh documents) and Encapsulated PostScript (`.eps`). Files parse into the same render tree as SVG (`effectcraft_svg::Doc`), so they rasterise at any scale (Continuously Rasterize), convert to shape layers (Layer ▸ Create ▸ Create Shapes from Vector Layer) and import as compositions with one -layer per file layer (File ▸ Import ▸ Composition). +layer per file layer (File ▸ Import ▸ Composition). Composition - Retain Layer Sizes uses each +layer's artwork bounds and keeps its placement on the page. Images keep their rectangular bounds; +clipping paths constrain the bounds, while soft masks do not change them. Specifications used: **ISO 32000-1:2008** (PDF 1.7; the public Adobe edition) for the file structure, filters, content streams, graphics state, colour spaces, functions, shadings, text, diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 39d15074..cf3f5ca6 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -194,6 +194,27 @@ pub fn layer_doc(doc: &Doc, index: usize) -> Doc { d } +/// One layer, optionally cropped to its artwork, and its original document-space top-left. +pub fn layer_doc_with_sizes(doc: &Doc, index: usize, retain: bool) -> (Doc, [f64; 2]) { + let mut d = layer_doc(doc, index); + if !retain { + return (d, [0.0, 0.0]); + } + let page = kurbo::Rect::new(0.0, 0.0, doc.width, doc.height); + let bounds = d.artwork_bounds().map(|b| b.intersect(page)).filter(|b| b.width() > 0.0 && b.height() > 0.0); + let (x, y, w, h) = match bounds { + Some(b) => { + let (x, y) = (b.x0.floor(), b.y0.floor()); + (x, y, b.x1.ceil() - x, b.y1.ceil() - y) + } + None => (0.0, 0.0, 1.0, 1.0), + }; + d.width = w; + d.height = h; + d.root.transform = Affine::translate((-x, -y)) * d.root.transform; + (d, [x, y]) +} + #[cfg(test)] mod tests; #[cfg(test)] diff --git a/crates/pdf/src/tests.rs b/crates/pdf/src/tests.rs index 57b7e044..e626ca40 100644 --- a/crates/pdf/src/tests.rs +++ b/crates/pdf/src/tests.rs @@ -1020,3 +1020,48 @@ fn multiple_pages_and_calculator_shadings() { assert!(img.get(5, 25)[0] < 0.1 && img.get(95, 25)[0] > 0.9, "{:?} {:?}", img.get(5, 25), img.get(95, 25)); assert_eq!(parse_page(&bytes, 2), Err(Error::PageOutOfRange(2))); } + +#[test] +fn retained_layers_keep_strokes_clips_and_empty_layers() { + let doc = parse(&sample_pdf(false)).unwrap(); + for i in 0..layer_names(&doc).len() { + let full = layer_doc(&doc, i); + let (cropped, offset) = layer_doc_with_sizes(&doc, i, true); + let (w, h) = cropped.pixel_size(); + let image = effectcraft_svg::rasterize(&cropped, w, h, 1.0); + let original = effectcraft_svg::rasterize(&full, 200, 100, 1.0); + for y in 0..100 { + for x in 0..200 { + let a = image.get(x - offset[0] as i64, y - offset[1] as i64); + let b = original.get(x, y); + assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-5), "layer {i} at {x},{y}: {a:?} vs {b:?}"); + } + } + } + let (empty, _) = layer_doc_with_sizes(&doc, usize::MAX, true); + assert_eq!(empty.pixel_size(), (1, 1)); +} + +#[test] +fn retained_layers_measure_transformed_artwork_without_rasterizing() { + let doc = effectcraft_svg::parse(br#""#).unwrap(); + let (rotated, offset) = layer_doc_with_sizes(&doc, 0, true); + assert_eq!(offset, [30.0, 10.0]); + assert_eq!(rotated.pixel_size(), (10, 30)); + let (stroked, offset) = layer_doc_with_sizes(&doc, 1, true); + assert_eq!(offset, [8.0, 18.0]); + assert_eq!(stroked.pixel_size(), (24, 4)); +} + +#[test] +fn retained_layers_preserve_artwork_beyond_the_bounds_depth_limit() { + let mut doc = effectcraft_svg::parse(br#""#).unwrap(); + for _ in 0..300 { + let mut group = Group::new("nested"); + group.children = std::mem::take(&mut doc.root.children); + doc.root.children.push(Node::Group(group)); + } + let (cropped, offset) = layer_doc_with_sizes(&doc, 0, true); + assert_eq!(cropped.pixel_size(), (120, 80), "unknown bounds keep the full page"); + assert_eq!(offset, [0.0, 0.0]); +} diff --git a/crates/svg/src/bounds.rs b/crates/svg/src/bounds.rs new file mode 100644 index 00000000..f0613aff --- /dev/null +++ b/crates/svg/src/bounds.rs @@ -0,0 +1,64 @@ +use kurbo::{Affine, BezPath, Rect, Shape as _}; + +use crate::{Doc, Group, Node}; + +impl Doc { + /// Geometric artwork bounds in document pixels, including strokes and clipping. + /// Opacity and soft masks do not change the artwork's dimensions. + pub fn artwork_bounds(&self) -> Option { + let page = Rect::new(0.0, 0.0, self.width, self.height); + group_bounds(&self.root, Affine::IDENTITY, 0, page) + } +} + +fn path_bounds(path: BezPath) -> Option { + if path.elements().is_empty() || !path.is_finite() { + return None; + } + let r = path.bounding_box(); + (r.is_finite() && r.width() > 0.0 && r.height() > 0.0).then_some(r) +} + +fn group_bounds(group: &Group, transform: Affine, depth: usize, page: Rect) -> Option { + if depth > 256 { + return Some(page); + } + let transform = transform * group.transform; + if !transform.as_coeffs().iter().all(|v| v.is_finite()) { + return None; + } + let mut bounds: Option = None; + for node in &group.children { + let rect = match node { + Node::Group(g) => group_bounds(g, transform, depth + 1, page), + Node::Image(i) => { + let rect = Rect::new(0.0, 0.0, f64::from(i.width), f64::from(i.height)); + path_bounds(transform * i.transform * rect.to_path(0.01)) + } + Node::Shape(s) => { + let m = transform * s.transform; + let path = s.geom.to_path(); + let mut rect = s.fill.as_ref().and_then(|_| path_bounds(m * &path)); + if let Some(stroke) = &s.stroke { + let scale = m.determinant().abs().sqrt(); + if scale.is_finite() + && path.is_finite() + && let Some(outline) = effectcraft_path::stroke_outline(std::slice::from_ref(&path), &stroke.style(), scale) + && let Some(r) = path_bounds(m * outline) + { + rect = Some(rect.map_or(r, |b| b.union(r))); + } + } + rect + } + }; + if let Some(r) = rect { + bounds = Some(bounds.map_or(r, |b| b.union(r))); + } + } + let mut bounds = bounds?; + for (clip, _) in &group.clip { + bounds = bounds.intersect(path_bounds(transform * clip)?); + } + (bounds.width() > 0.0 && bounds.height() > 0.0).then_some(bounds) +} diff --git a/crates/svg/src/lib.rs b/crates/svg/src/lib.rs index 7310814b..bcffb800 100644 --- a/crates/svg/src/lib.rs +++ b/crates/svg/src/lib.rs @@ -6,6 +6,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] +mod bounds; mod color; mod pathdata; mod raster; @@ -108,6 +109,28 @@ pub struct Stroke { pub dash: Option<(Vec, f64)>, } +impl Stroke { + fn style(&self) -> effectcraft_path::StrokeStyle { + effectcraft_path::StrokeStyle { + width: self.width, + cap: match self.cap { + Cap::Butt => effectcraft_path::Cap::Butt, + Cap::Round => effectcraft_path::Cap::Round, + Cap::Square => effectcraft_path::Cap::Square, + }, + join: match self.join { + Join::Miter => effectcraft_path::Join::Miter, + Join::Round => effectcraft_path::Join::Round, + Join::Bevel => effectcraft_path::Join::Bevel, + }, + miter: self.miter, + dash: self.dash.clone(), + taper: None, + wave: None, + } + } +} + /// Shape geometry in its user space (basic shapes are kept so they can become parametric shape /// layer items). #[derive(Clone, Debug, PartialEq)] diff --git a/crates/svg/src/raster.rs b/crates/svg/src/raster.rs index b1b9d93b..af59e33d 100644 --- a/crates/svg/src/raster.rs +++ b/crates/svg/src/raster.rs @@ -405,23 +405,7 @@ fn draw_shape(s: &Shape, m: Affine, opacity: f64, dst: &mut Image) { paint(dst, &cov, fill, (opacity * s.fill_opacity) as f32, m, bbox); } if let Some(st) = &s.stroke { - let style = effectcraft_path::StrokeStyle { - width: st.width, - cap: match st.cap { - crate::Cap::Butt => effectcraft_path::Cap::Butt, - crate::Cap::Round => effectcraft_path::Cap::Round, - crate::Cap::Square => effectcraft_path::Cap::Square, - }, - join: match st.join { - crate::Join::Miter => effectcraft_path::Join::Miter, - crate::Join::Round => effectcraft_path::Join::Round, - crate::Join::Bevel => effectcraft_path::Join::Bevel, - }, - miter: st.miter, - dash: st.dash.clone(), - taper: None, - wave: None, - }; + let style = st.style(); let cov = effectcraft_path::stroke_coverage(std::slice::from_ref(&path), &style, &mat3(m), w, h); paint(dst, &cov, &st.paint, (opacity * st.opacity) as f32, m, bbox); } diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 275e54b7..185c1033 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -228,7 +228,16 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { "file.import" if !has(p, &["importAs", "page"]) && is_pdf(p) => ( "Import PDF / Illustrator File".into(), vec![ - Field::choice("importAs", "Import Kind", &[("Footage", json!("footage")), ("Composition", json!("composition"))], 0), + Field::choice( + "importAs", + "Import Kind", + &[ + ("Footage", json!("footage")), + ("Composition", json!("composition")), + ("Composition - Retain Layer Sizes", json!("compositionLayerSizes")), + ], + 0, + ), Field::num("page", "Page", 1.0), ], ), diff --git a/crates/ui-egui/tests/ui_drag_drop.rs b/crates/ui-egui/tests/ui_drag_drop.rs index 77a3995d..9b66f745 100644 --- a/crates/ui-egui/tests/ui_drag_drop.rs +++ b/crates/ui-egui/tests/ui_drag_drop.rs @@ -584,3 +584,28 @@ fn a_dropped_folder_imports_as_one_sequence_with_sequence_interpretation() { assert!(h.state().auto.find("form.field.missingFrames").is_none()); let _ = std::fs::remove_dir_all(&dir); } + +#[test] +fn illustrator_import_offers_and_applies_retain_layer_sizes() { + let path = std::env::temp_dir().join(format!("effectcraft-retain-ui-{}.ai", std::process::id())); + std::fs::write(&path, b"%PDF-1.4\n1 0 obj << /Type /Catalog /Pages 2 0 R >> endobj\n2 0 obj << /Type /Pages /Kids [3 0 R] /Count 1 >> endobj\n3 0 obj << /Type /Page /Parent 2 0 R /MediaBox [0 0 120 80] /Contents 4 0 R >> endobj\n4 0 obj << >> stream\n1 0 0 rg 20 15 30 20 re f\nendstream endobj\n%%EOF").unwrap(); + let mut h = Harness::builder().with_size(vec2(1600.0, 1000.0)).build_eframe(|_| EffectcraftApp::new(effectcraft_host::session())); + h.run_steps(3); + let ctx = h.ctx.clone(); + effectcraft_ui_egui::menus::invoke(h.state_mut(), &ctx, "file.import", json!({"paths": [path]})).unwrap(); + h.run_steps(3); + assert!(h.query_by_label("Import PDF / Illustrator File").is_some()); + click_id(&mut h, "form.field.importAs"); + h.get_by_label("Composition - Retain Layer Sizes").click(); + h.run_steps(2); + click_id(&mut h, "form.ok"); + step_until(&mut h, |h| h.state().session.active_comp().is_some()); + let s = &h.state().session; + let comp = s.active_comp().unwrap(); + assert_eq!((comp.width, comp.height), (120, 80)); + assert_eq!(comp.layers.len(), 1); + let effectcraft_engine::project::LayerSource::Footage { item } = comp.layers[0].source else { panic!("footage") }; + let effectcraft_engine::project::ItemKind::Footage(f) = &s.project.item(item).unwrap().kind else { panic!("footage") }; + assert_eq!((f.width, f.height), (30, 20)); + assert!(f.layer.as_ref().unwrap().layer_size); +}