From 98d1d738a22f6d2800309ca6cf19e5824b9b6117 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 15 Oct 2026 03:51:10 -0700 Subject: [PATCH] Export Selection: the crop takes in what each object's effects draw Export Selection, Asset Export, the Assets tab of Export for Screens and the rasterized art of CSS Properties cropped each object to its visual bounds, which leave out live effects. A path with an 8 pt drop shadow was exported with the shadow cut off at the path's edge, and a group's own shadow was cut off the same way. The crop is now the bounds the renderer measures for what it draws (`vectorcraft_render::effect_bounds`): `painted_bounds` without its room for brush art. A brushed path is cropped to its visual bounds as before, and a Shaper group leaves out its transparent source art as its visual bounds do. `assets.list` reports the same crop. The renderer measures a shadow's or glow's reach on every side of the object, so the image also has transparent room on the side away from an offset shadow. Docs: docs/mcp.md, docs/target-app-parity.md. --- crates/engine/src/cmd/assets.rs | 6 +-- crates/engine/src/cmd/fileio/export.rs | 23 +++++---- crates/engine/src/cmd/fileio/mod.rs | 2 +- crates/engine/src/tests_assets.rs | 18 +++++++ crates/engine/src/tests_file.rs | 65 ++++++++++++++++++++++++++ crates/engine/src/tests_shaper.rs | 16 +++++++ crates/render/src/lib.rs | 15 ++++++ docs/mcp.md | 3 +- docs/target-app-parity.md | 2 + 9 files changed, 137 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/assets.rs b/crates/engine/src/cmd/assets.rs index 7962b2db8..beffe4e0f 100644 --- a/crates/engine/src/cmd/assets.rs +++ b/crates/engine/src/cmd/assets.rs @@ -7,7 +7,7 @@ use serde_json::{Map, Value, json}; use vectorcraft_doc::{ExportAsset, NodeId}; use super::edit::{roots_of, selected_roots}; -use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_screens, store_screen_settings}; +use super::fileio::{SCREEN_SETTINGS_KEYS, check_screen_settings, export_crop, export_screens, store_screen_settings}; use super::*; /// The longest asset name kept (characters). @@ -39,7 +39,7 @@ pub fn specs() -> Vec { "Assets", [], None, - "{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the visual bounds of its art)}]} the Asset Export panel's assets, in order", + "{} → {assets: [{id, name, nodes: [id…], bounds: [x, y, width, height] | null (the crop: the bounds the renderer measures for what its art draws, effects included)}]} the Asset Export panel's assets, in order", has_doc, list ), @@ -161,7 +161,7 @@ fn list(s: &mut Session, _: &Value) -> Result { .assets .iter() .map(|a| { - let bounds = a.nodes.iter().filter_map(|id| d.node(*id)?.visual_bounds()).reduce(|x, y| x.union(y)); + let bounds = export_crop(d, &a.nodes); json!({ "id": a.id, "name": a.name, diff --git a/crates/engine/src/cmd/fileio/export.rs b/crates/engine/src/cmd/fileio/export.rs index b41c79ffb..164e2e58a 100644 --- a/crates/engine/src/cmd/fileio/export.rs +++ b/crates/engine/src/cmd/fileio/export.rs @@ -172,13 +172,19 @@ fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom:: isolated(&st.doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export")) } -/// Objects `ids` of `doc` alone on one layer (in that order, back to front), with one artboard -/// named `name`: their visual bounds, symbol instances measured by their art (`None` when they -/// have none). -pub(crate) fn isolated(doc: &Document, ids: &[NodeId], name: &str) -> Option<(Document, vectorcraft_geom::Rect)> { - let nodes: Vec> = ids.iter().filter_map(|id| doc.node(*id).cloned().map(Arc::new)).collect(); +/// The crop of an export of objects `ids` of `doc` alone: the bounds the renderer measures for what +/// it draws for them, their effects included ([`vectorcraft_render::effect_bounds`]; symbol +/// instances are measured by their art), `None` when they have none. +pub(crate) fn export_crop(doc: &Document, ids: &[NodeId]) -> Option { let symbols = doc.symbol_boxes(); - let bounds = nodes.iter().filter_map(|n| n.visual_bounds_with(&symbols)).reduce(|a, b| a.union(b))?; + ids.iter().filter_map(|id| vectorcraft_render::effect_bounds(&symbols, doc.node(*id)?)).reduce(|a, b| a.union(b)) +} + +/// Objects `ids` of `doc` alone on one layer (in that order, back to front), with one artboard +/// named `name`: their [`export_crop`] (`None` when they have none). +pub(crate) fn isolated(doc: &Document, ids: &[NodeId], name: &str) -> Option<(Document, vectorcraft_geom::Rect)> { + let bounds = export_crop(doc, ids)?; + let nodes: Vec> = ids.iter().filter_map(|id| doc.node(*id).cloned().map(Arc::new)).collect(); let mut d = single_artboard(doc, bounds, name); let mut layer = Node::layer(d.alloc_id(), name, vectorcraft_doc::LayerColor::Preset(0)); if let Some(ch) = layer.children_mut() { @@ -188,8 +194,9 @@ pub(crate) fn isolated(doc: &Document, ids: &[NodeId], name: &str) -> Option<(Do Some((d, bounds)) } -/// File → Export Selection: the selected objects alone, cropped to their visual bounds. Objects on -/// template layers are guides, not artwork, and are left out. +/// File → Export Selection: the selected objects alone, cropped to the bounds the renderer +/// measures for what they draw, their effects included ([`isolated`]). Objects on template layers +/// are guides, not artwork, and are left out. pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result { const C: &str = "document.exportSelection"; if let Some(path) = str_param(p, "path") { diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index 28e0a2de7..ec5a110f3 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -49,7 +49,7 @@ pub use dxf::{UNSUPPORTED, Unsupported, unsupported}; pub use encode::{ARTBOARD_PARAMS, ArtboardPick, Encoded, encode, encode_all, encode_with_warnings}; pub(crate) use encode::{anti_alias, background, with_single_artboard}; pub use export::export_source; -pub(crate) use export::isolated; +pub(crate) use export::{export_crop, isolated}; pub(crate) use load::check_not_lossy_overwrite; use load::err; pub(crate) use load::import_svg; diff --git a/crates/engine/src/tests_assets.rs b/crates/engine/src/tests_assets.rs index 79c11c997..8eca18065 100644 --- a/crates/engine/src/tests_assets.rs +++ b/crates/engine/src/tests_assets.rs @@ -127,6 +127,24 @@ fn an_asset_exports_its_art_alone() { assert!(!text.to_lowercase().contains("#0000ff"), "no blue: {text}"); } +/// The crop of an asset takes in its effects: the drop shadow is inside the exported image, and +/// assets.list gives that crop. +#[test] +fn an_assets_crop_takes_in_its_drop_shadow() { + let mut s = session(); + let a = rect(&mut s, 50.0, 50.0, 20.0, 20.0); + run(&mut s, "assets.add", json!({"ids": [a]})); + assert_eq!(assets(&mut s)[0]["bounds"], json!([50.0, 50.0, 20.0, 20.0]), "the plain square"); + run(&mut s, "effect.apply", json!({"effect": "stylize.dropShadow", "ids": [a], "params": {"x": 10, "y": 0, "blur": 0}})); + // The shadow's reach (10 pt) on every side. + assert_eq!(assets(&mut s)[0]["bounds"], json!([40.0, 40.0, 40.0, 40.0])); + let out = files(&run(&mut s, "assets.export", json!({"formats": [{"format": "png", "scale": "1x"}]}))); + let img = image::load_from_memory(&out[0].1).unwrap().to_rgba8(); + assert_eq!(img.dimensions(), (40, 40)); + // The shadow's right half, x 70 to 80. + assert!(img.get_pixel(35, 20)[3] > 0, "the shadow is in the image"); +} + #[test] fn names_follow_the_object_or_count_up_and_can_be_changed() { let mut s = session(); diff --git a/crates/engine/src/tests_file.rs b/crates/engine/src/tests_file.rs index 579dba9da..debe10d51 100644 --- a/crates/engine/src/tests_file.rs +++ b/crates/engine/src/tests_file.rs @@ -206,6 +206,71 @@ fn export_selection_crops_to_the_selection() { assert_eq!(text.matches(" = v["bounds"].as_array().unwrap().iter().map(|n| n.as_f64().unwrap()).collect(); + assert!(b[0] < 99.5 && b[1] < 99.5 && b[0] + b[2] >= right && b[1] + b[3] > 120.5, "{effect}: {v}"); + let img = png(&v); + let (w, h) = img.dimensions(); + assert_eq!((w, h), (b[2].round() as u32, b[3].round() as u32), "{effect}"); + // 2 pt past the square's stroke, on the right. + assert!(img.get_pixel((122.5 - b[0]) as u32, h / 2)[3] > 0, "{effect}: the effect is in the image"); + let edge = (0..w).flat_map(|x| [(x, 0), (x, h - 1)]).chain((0..h).flat_map(|y| [(0, y), (w - 1, y)])); + assert!(edge.into_iter().all(|(x, y)| img.get_pixel(x, y)[3] == 0), "{effect}: nothing is cut off at the image's edge"); + let art = s.execute("document.export", &json!({"format": "png", "useArtboards": false, "background": "transparent"})).unwrap(); + assert!(png(&art) == img, "{effect}: the same image as the art's bounds"); + } +} + +/// A group's own drop shadow is inside the crop of the group's selection export too. +#[test] +fn export_selection_keeps_a_groups_shadow_in_the_crop() { + let mut s = session(); + s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 20, "height": 20})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 130, "y": 100, "width": 20, "height": 20})).unwrap(); + s.execute("select.all", &json!({})).unwrap(); + let group = s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap(); + let plain = s.execute("document.exportSelection", &json!({"format": "png"})).unwrap(); + assert_eq!(plain["bounds"], json!([99.5, 99.5, 51.0, 21.0]), "{plain}"); + s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [group], "params": {"x": 8, "y": 0, "blur": 0}})).unwrap(); + let v = s.execute("document.exportSelection", &json!({"format": "png", "background": "transparent"})).unwrap(); + let b: Vec = v["bounds"].as_array().unwrap().iter().map(|n| n.as_f64().unwrap()).collect(); + // The shadow of the right square reaches x = 158.5. + assert!(b[0] < 99.5 && b[0] + b[2] >= 158.5, "{v}"); + let img = image::load_from_memory(&vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap().to_rgba8(); + assert!(img.get_pixel((155.0 - b[0]) as u32, img.height() / 2)[3] > 0, "the shadow is in the image"); +} + +/// The crop of a brushed path is its visual bounds, with no room added for brush art. +#[test] +fn export_selection_crops_a_brushed_path_to_its_visual_bounds() { + let mut s = session(); + let id = s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap(); + s.execute("brush.apply", &json!({"name": "Charcoal"})).unwrap(); + let v = s.execute("document.exportSelection", &json!({"format": "png"})).unwrap(); + let b = s.doc().unwrap().doc.node(NodeId(id)).unwrap().visual_bounds().unwrap(); + assert_eq!(v["bounds"], json!([b.x0, b.y0, b.width(), b.height()]), "{v}"); +} + /// A selection export reports the encoder's warnings like a whole-document export, and an empty /// list when nothing was approximated. #[test] diff --git a/crates/engine/src/tests_shaper.rs b/crates/engine/src/tests_shaper.rs index e9a714036..e1857a7de 100644 --- a/crates/engine/src/tests_shaper.rs +++ b/crates/engine/src/tests_shaper.rs @@ -141,6 +141,22 @@ fn shaper_erases_only_protruding_stroke_pieces_keeps_fill_and_original_line() { assert_eq!(node(&s, line), &original_line); } +/// Export Selection crops a Shaper group to the art it shows, not to its transparent source art. +#[test] +fn shaper_export_selection_crops_to_the_shown_art() { + let mut s = session(); + s.execute("paint.setFill", &json!({"color":"#ffff00"})).unwrap(); + s.execute("shape.ellipse", &json!({"x":100,"y":100,"width":100,"height":100})).unwrap(); + s.execute("shape.line", &json!({"x1":50,"y1":150,"x2":250,"y2":150})).unwrap(); + scribble(&mut s, &[[60.0, 140.0], [70.0, 160.0], [80.0, 140.0], [90.0, 160.0]]); + let g = scribble(&mut s, &[[210.0, 140.0], [220.0, 160.0], [230.0, 140.0], [240.0, 160.0]]); + s.execute("select.set", &json!({"ids": [g.0]})).unwrap(); + let v = s.execute("document.exportSelection", &json!({"format": "png"})).unwrap(); + let b = node(&s, g).visual_bounds().unwrap(); + assert!(b.x0 > 90.0 && b.x1 < 210.0, "the trimmed line: {b:?}"); + assert_eq!(v["bounds"], json!([b.x0, b.y0, b.width(), b.height()]), "{v}"); +} + #[test] fn shaper_source_move_resize_native_roundtrip_and_release_preserve_live_art() { let mut s = session(); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 8856b10a2..a1c4eed74 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -49,6 +49,21 @@ pub fn painted_bounds(doc: &Document, n: &Node) -> Option { brush_fx::cull_bounds(&doc.symbol_boxes(), n) } +/// [`painted_bounds`] of `n` without the room it leaves for brush art past the brushed strokes of +/// `n` and of a symbol instance's art (the brush art of the objects in a blend, envelope or repeat +/// still counts). A group or layer without effects of its own is measured by its members, leaving +/// out the transparent source art of a Shaper group as [`Node::visual_bounds_with`] does. Symbol +/// instances are measured by their art in `symbols`. +pub fn effect_bounds(symbols: &SymbolBoxes, n: &Node) -> Option { + match &n.kind { + NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } if !fx::has_object_fx(n) => children + .iter() + .skip(usize::from(n.extras().shaper.is_some())) + .fold(None, |acc, c| vectorcraft_geom::union_opt(acc, effect_bounds(symbols, c))), + _ => fx::cull_bounds(symbols, n), + } +} + /// Largest raster an export may ask [`Renderer::render_region`] for, per side. The CPU rasteriser /// addresses at most `u16::MAX` pixels per side (and panics at that edge). pub const MAX_RASTER_SIDE: u32 = 32_768; diff --git a/docs/mcp.md b/docs/mcp.md index 5cdc44b34..8277b7e8b 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -2666,7 +2666,8 @@ crop (`bounds`); `assets.rename {asset, name}` and `assets.remove {assets}` are format row of the document's export settings, the ones Export for Screens remembers (`document.exportSettings`); any `formats`, `preset`, `settings`, `prefix` or `subfolders` given win for that export. `assets.settings.set` changes those shared settings (not an undo step). `document.exportForScreens {assets: [id…]}` does the same with its own params -(the dialog's Assets tab). +(the dialog's Assets tab). An asset's crop, and the crop of `export` with `selection: true`, is the bounds the +renderer measures for what the art draws, its effects included. ```json {"name":"run_command","arguments":{"command":"assets.add","params":{"multiple":true}}} diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0656309a0..5d9bb2405 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -378,6 +378,8 @@ since 2026-10-01. - **TIFF in screen and asset export:** both format menus expose the existing TIFF encoder and Format Settings. Headless UI regressions choose TIFF and check scaled dimensions, artboard selection, filenames, encoding options, saved settings and collected-asset output. Public reference: [Export for Screens](https://helpx.adobe.com/illustrator/desktop/save-and-export/export-files-to-different-formats/export-for-screens.html), updated 2026-09-28, lists TIFF for both artboards and assets. Adobe staff [confirmed support in 30.2.1](https://illustrator.uservoice.com/forums/333657-illustrator-desktop-feature-requests/suggestions/49081034-add-tiff-format-into-export-for-screens-assets-d) on 2026-02-19; the older 2025 Collect assets guide omits TIFF. +- **Effects in Export Selection and asset crops:** File › Export Selection, Asset Export, Export for Screens' Assets tab and CSS Properties' rasterized art crop each object to the bounds the renderer measures for what it draws: drop shadows, glows, geometry effects such as Offset Path, a group's own effects and the effects in a symbol's art. A brushed path is cropped to its visual bounds, and a Shaper group is cropped to the art it shows. `assets.list` reports the same crop. Engine tests export a square with each effect, a group with a shadow and an asset with a shadow, and check that the effect is inside the image. Public reference: a [Community Expert answer](https://community.adobe.com/t5/illustrator-discussions/exporting-assets-adds-transparent-borders/m-p/10708713/highlight/true) traces the extra area of exported assets to the raster effects' area (Document Raster Effects Settings › Add Around Object). + - **Selected artboard deletion:** the Artboards panel menu, panel trash button and Properties use the same selected set; selecting all retains artboard 1. Pointer-event tests cover nonadjacent and single selections, the original first-board survivor, ordinary artwork, Undo/Redo and the single-board guard. Public reference: [Delete artboards](https://helpx.adobe.com/illustrator/desktop/create-manage-artboards/add-edit-artboards/delete-artboards.html). - **Artboard history selection:** headless pointer/key tests in `panels/properties.rs` pass for panel New → Undo → Delete/Backspace/Properties, Duplicate → Undo → Delete, pruning removed indices and preserving valid multiselection. They assert board survivors, artwork and object selection, Move/Scale Artwork options, saved state and Undo/Redo. Full workspace CI passes. - **Character-style Auto leading:** capture and explicit style attributes retain Auto as a reset, distinct from omitted leading. Redefine and later numeric updates preserve local leading overrides; the Character Styles marker recognizes Auto correctly. Regression assertions cover partial definitions, Undo/Redo and native save/load serialization. Public references: [Character and paragraph styles](https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html) and [Leading](https://helpx.adobe.com/illustrator/using/line-character-spacing.html).