Merge pull request #2215 from dotCooCoo/fix/export-selection-keeps-effects

Export Selection: the crop takes in what each object's effects draw
This commit is contained in:
BFlat
2026-10-16 03:17:05 +07:00
committed by GitHub
9 changed files with 137 additions and 13 deletions
+3 -3
View File
@@ -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<CommandSpec> {
"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<Value> {
.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,
+15 -8
View File
@@ -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<Arc<Node>> = 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<vectorcraft_geom::Rect> {
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<Arc<Node>> = 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<Value> {
const C: &str = "document.exportSelection";
if let Some(path) = str_param(p, "path") {
+1 -1
View File
@@ -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;
+18
View File
@@ -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();
+65
View File
@@ -206,6 +206,71 @@ fn export_selection_crops_to_the_selection() {
assert_eq!(text.matches("<path").count(), 1, "{text}");
}
/// The crop of a selection export takes in the object's effects: a drop shadow, an outer glow and
/// an Offset Path effect paint inside the image, which is the image of the art that Export with
/// Use Artboards off writes.
#[test]
fn export_selection_keeps_the_effects_in_the_crop() {
// The right edge of what the effect paints: the shadow 8 pt to the right, the glow and the
// grown path 6 pt past the square (the stroke's half point included).
for (effect, params, right) in [
("stylize.dropShadow", json!({"x": 8, "y": 0, "blur": 0}), 128.5),
("stylize.outerGlow", json!({"blur": 4}), 126.5),
("path.offsetPath", json!({"offset": 6}), 126.5),
] {
let mut s = session();
s.execute("shape.rectangle", &json!({"x": 100, "y": 100, "width": 20, "height": 20})).unwrap();
let plain = s.execute("document.exportSelection", &json!({"format": "png"})).unwrap();
assert_eq!(plain["bounds"], json!([99.5, 99.5, 21.0, 21.0]), "a plain path keeps its crop: {plain}");
s.execute("effect.apply", &json!({"effect": effect, "params": params})).unwrap();
let png = |v: &serde_json::Value| {
image::load_from_memory(&vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap()).unwrap()).unwrap().to_rgba8()
};
let v = s.execute("document.exportSelection", &json!({"format": "png", "background": "transparent"})).unwrap();
let b: Vec<f64> = 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<f64> = 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]
+16
View File
@@ -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();
+15
View File
@@ -49,6 +49,21 @@ pub fn painted_bounds(doc: &Document, n: &Node) -> Option<Rect> {
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<Rect> {
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;
+2 -1
View File
@@ -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}}}
+2
View File
@@ -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).