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