Export: the art's bounds take in the effects of groups, type and images

The bounds of the visible art (`vectorcraft_render::encode::art_bounds`)
are the crop of Export with Use Artboards off, EPS without artboards,
Export for Screens' Full Document and Save for Web (Legacy) with Clip to
Artboard off, and the size Fit to Artwork Bounds gives an artboard. They
measured a group or layer by its members and left out its own effects,
and measured type and images without their effects. A group's drop
shadow, or the shadow of a line of type, was cut off at the image's edge
and left outside the fitted artboard. They also counted a Shaper group's
transparent source art.

Each visible object is now measured by `vectorcraft_render::effect_bounds`,
the measure of Export Selection's crop. A group or layer without effects
of its own is still measured by its visible members, skipping a Shaper
group's source art. A group or layer with a drop shadow, a glow or another
raster effect is measured without its hidden members, guides and template
layers, unless it is a Shaper group or also has a geometry, Pathfinder,
Crop Marks or 3D effect or a visible effect on its own fill or stroke.
The SVG of a chosen artboard also keeps an object whose effects alone
reach the artboard.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-10-16 15:27:54 +07:00
committed by bflatastic
parent d973805269
commit bc8e42d8c7
8 changed files with 210 additions and 18 deletions
+8 -6
View File
@@ -94,11 +94,12 @@ fn selected_part(n: &Node, keep: &HashSet<NodeId>) -> Option<Node> {
}
/// `doc` with only the art over `rect` (an artboard): objects whose visual bounds miss it are left
/// out, at any depth of layers, sublayers and groups that don't clip, and objects that reach it
/// stay whole (the file's bounds clip them), as DXF's crop leaves them. Symbol instances are
/// measured by their art, and the box of one whose art holds brushed strokes grows by as far as
/// their brush art can paint ([`SymbolBoxes::with_brush_art`]). An object that holds a symbol
/// instance also stays when what the canvas paints for it reaches `rect`
/// out, at any depth of layers, sublayers and groups that don't clip, unless what the renderer
/// draws for them, their effects included ([`vectorcraft_render::effect_bounds`]), reaches it.
/// Objects that reach it stay whole (the file's bounds clip them), as DXF's crop leaves them.
/// Symbol instances are measured by their art, and the box of one whose art holds brushed strokes
/// grows by as far as their brush art can paint ([`SymbolBoxes::with_brush_art`]). An object that
/// holds a symbol instance also stays when what the canvas paints for it reaches `rect`
/// ([`vectorcraft_render::painted_bounds`]: the effects in the symbols' art and its own effects). A
/// clipping layer keeps every object, its clipping path among them. An artboard's file then holds
/// its own art, not a whole document of artboards.
@@ -122,8 +123,9 @@ pub(super) fn art_over(doc: &Document, rect: vectorcraft_geom::Rect) -> Document
let over = |b: vectorcraft_geom::Rect| b.x0 <= rect.x1 && rect.x0 <= b.x1 && b.y0 <= rect.y1 && rect.y0 <= b.y1;
let mut holds_instance = false;
n.walk(&mut |c| holds_instance |= matches!(c.kind, NodeKind::SymbolInstance { .. }));
let drawn = || vectorcraft_render::effect_bounds(symbols, n).is_some_and(over);
let painted = || holds_instance && vectorcraft_render::painted_bounds(doc, n).is_some_and(over);
(n.visual_bounds_with(symbols).is_none_or(over) || painted()).then(|| n.clone())
(n.visual_bounds_with(symbols).is_none_or(over) || drawn() || painted()).then(|| n.clone())
}
let symbols = doc.symbol_boxes().with_brush_art();
let mut d = doc.clone();
@@ -91,3 +91,20 @@ fn a_chosen_artboard_s_svg_leaves_out_its_neighbours_art_in_a_shared_group() {
assert_eq!(third_only.matches("<path").count() + third_only.matches("<rect").count(), 1, "{third_only}");
assert!(third_only.contains(" C") || third_only.contains("<ellipse"), "{third_only}");
}
/// An object off a chosen artboard whose drop shadow reaches onto it is in that artboard's SVG.
#[test]
fn a_chosen_artboard_s_svg_keeps_an_object_whose_shadow_reaches_it() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 100, "height": 100})).unwrap();
let x0 = s.doc().unwrap().doc.artboards[0].rect.x0;
let id = s.execute("shape.rectangle", &json!({"x": x0 - 30.0, "y": 10, "width": 20, "height": 20})).unwrap()["id"].as_u64().unwrap();
let paths = |s: &mut Session| {
let svg = s.execute("document.serialize", &json!({"format": "svg", "artboard": 0})).unwrap();
let text = svg["text"].as_str().unwrap().to_string();
text.matches("<path").count() + text.matches("<rect").count()
};
assert_eq!(paths(&mut s), 0, "the plain rectangle is off the artboard");
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id], "params": {"x": 20, "y": 0, "blur": 0}})).unwrap();
assert_eq!(paths(&mut s), 1, "its shadow reaches 10 pt onto the artboard");
}
+33
View File
@@ -260,6 +260,39 @@ fn export_selection_keeps_a_groups_shadow_in_the_crop() {
assert!(img.get_pixel((155.0 - b[0]) as u32, img.height() / 2)[3] > 0, "the shadow is in the image");
}
/// Export with Use Artboards off and Fit to Artwork Bounds take in the drop shadow of a group and
/// of type.
#[test]
fn the_arts_bounds_take_in_the_shadow_of_a_group_and_of_type() {
for kind in ["group", "type"] {
let mut s = session();
let id = if kind == "group" {
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();
s.execute("object.group", &json!({})).unwrap()["id"].as_u64().unwrap()
} else {
s.execute("text.create", &json!({"x": 100, "y": 120, "text": "Hill"})).unwrap()["id"].as_u64().unwrap()
};
let plain = s.doc().unwrap().doc.node(NodeId(id)).unwrap().visual_bounds().unwrap();
s.execute("effect.apply", &json!({"effect": "stylize.dropShadow", "ids": [id], "params": {"x": 8, "y": 0, "blur": 0}})).unwrap();
s.execute("select.set", &json!({"ids": [id]})).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 art = s.execute("document.export", &json!({"format": "png", "useArtboards": false, "background": "transparent"})).unwrap();
let sel = s.execute("document.exportSelection", &json!({"format": "png", "background": "transparent"})).unwrap();
let x0 = sel["bounds"][0].as_f64().unwrap();
let img = png(&art);
assert!(png(&sel) == img, "{kind}: the same image as Export Selection's");
let past = (plain.x1 + 1.0 - x0).ceil() as u32;
assert!(img.enumerate_pixels().any(|(x, _, p)| x >= past && p[3] > 0), "{kind}: the shadow is in the image, right of the object");
s.execute("artboard.fitToArt", &json!({})).unwrap();
let board = s.doc().unwrap().doc.artboards[0].rect;
assert!(board.x0 <= plain.x0 && board.x1 >= plain.x1 + 8.0, "{kind}: the artboard holds the shadow 8 pt right of {plain:?}: {board:?}");
}
}
/// 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() {
+14
View File
@@ -157,6 +157,20 @@ fn shaper_export_selection_crops_to_the_shown_art() {
assert_eq!(v["bounds"], json!([b.x0, b.y0, b.width(), b.height()]), "{v}");
}
/// Fit to Artwork Bounds fits the art a Shaper group shows, not its transparent source art.
#[test]
fn shaper_fit_to_artwork_bounds_fits_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("artboard.fitToArt", &json!({})).unwrap();
let b = node(&s, g).visual_bounds().unwrap();
assert_eq!(s.doc().unwrap().doc.artboards[0].rect, b, "the circle and the trimmed line");
}
#[test]
fn shaper_source_move_resize_native_roundtrip_and_release_preserve_live_art() {
let mut s = session();
+50 -9
View File
@@ -15,6 +15,8 @@ pub mod tga;
pub mod tiff;
pub mod web;
use std::sync::Arc;
use vectorcraft_doc::{Document, Node, NodeKind, SymbolBoxes};
use vectorcraft_geom::Rect;
@@ -191,8 +193,9 @@ pub(crate) fn webp(img: &Rendered) -> Result<Vec<u8>, String> {
}
/// Bounds of the art an export draws: visible objects off template layers (guides left out),
/// with their strokes and live effects, symbol instances measured by their art. `None` when
/// nothing would be drawn.
/// each measured by [`crate::effect_bounds`]: with its strokes and its effects (the effects of
/// groups, layers, type and images included), symbol instances by their art, and a Shaper group
/// without effects of its own by the art it shows. `None` when nothing would be drawn.
pub fn art_bounds(doc: &Document) -> Option<Rect> {
let symbols = doc.symbol_boxes();
doc.layers.iter().fold(None, |acc, l| vectorcraft_geom::union_opt(acc, drawn_bounds(&symbols, l)))
@@ -222,16 +225,54 @@ fn drawn_bounds(symbols: &SymbolBoxes, n: &Node) -> Option<Rect> {
}
match &n.kind {
NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. } => None,
NodeKind::Layer { children, clip: false, .. } | NodeKind::Group { children, clip: false } => {
children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, drawn_bounds(symbols, c)))
}
_ if fx::has_fx(n) => fx::visual_bounds(symbols, n),
// An instance's box takes in its art, the effects in the art and its own effects.
NodeKind::SymbolInstance { .. } => fx::cull_bounds(symbols, n),
_ => n.visual_bounds_with(symbols),
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, drawn_bounds(symbols, c))),
_ => crate::effect_bounds(symbols, without_undrawn(n).as_ref().unwrap_or(n)),
}
}
/// `n`, a group or layer that doesn't clip, without the hidden objects, guides and template layers
/// in it and in the groups and layers in it that don't clip, or `None` when it holds none of them
/// or is anything else. A Shaper group keeps all its members, and so does a group or layer with a
/// visible geometry, Pathfinder, Crop Marks or 3D effect, or with a visible effect on one of its
/// own visible fills or strokes.
fn without_undrawn(n: &Node) -> Option<Node> {
use crate::effects;
let keeps_all = n.extras().shaper.is_some()
|| effects::has_geometry(&n.appearance.effects)
|| effects::has_pathfinder(n)
|| effects::has_crop_marks(n)
|| effects::has_revolve(n)
|| effects::has_solid(n)
|| n.appearance.items.iter().any(|i| i.visible() && i.effects().iter().any(|e| e.visible));
if keeps_all || !matches!(n.kind, NodeKind::Layer { clip: false, .. } | NodeKind::Group { clip: false, .. }) {
return None;
}
let undrawn = |c: &Node| !c.visible || matches!(c.kind, NodeKind::Layer { template: true, .. } | NodeKind::Path { guide: true, .. });
let mut changed = false;
let kept: Vec<Arc<Node>> = n
.children()?
.iter()
.filter_map(|c| {
if undrawn(c) {
changed = true;
return None;
}
let part = without_undrawn(c);
changed |= part.is_some();
Some(part.map_or_else(|| c.clone(), Arc::new))
})
.collect();
if !changed {
return None;
}
let mut out = n.clone();
*out.children_mut()? = kept;
Some(out)
}
#[cfg(test)]
mod tests;
#[cfg(test)]
+81
View File
@@ -244,6 +244,87 @@ fn art_bounds_take_in_the_effects_in_a_symbols_art() {
assert!(b.x0 <= 20.0 && b.x1 >= 38.0, "the art from x 20 and its shadow to x 38: {b:?}");
}
/// A drop shadow 8 pt to the right, without blur.
fn shadow_right() -> vectorcraft_doc::Effect {
vectorcraft_doc::Effect { id: "stylize.dropShadow".into(), params: serde_json::json!({"x": 8, "y": 0, "blur": 0}), visible: true }
}
#[test]
fn art_bounds_take_in_the_effects_of_groups_type_and_images() {
let black = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
let mut d = Document::new(100.0, 100.0);
let square = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), black);
let group = Node::group(d.alloc_id(), vec![std::sync::Arc::new(square)]);
let t = TextObject::point(Point::new(10.0, 40.0), "Hill", CharStyle { size: 12.0, ..CharStyle::default() });
let text = Node::new(d.alloc_id(), NodeKind::Text(Box::new(t)));
let im = vectorcraft_doc::ImageObject {
key: "image".into(),
width: 30,
height: 30,
xf: vectorcraft_geom::Affine::translate((20.0, 50.0)),
link: None,
placement: Default::default(),
};
let image = Node::new(d.alloc_id(), NodeKind::Image(Box::new(im)));
let cut_off: Vec<_> = [("group", group), ("type", text), ("image", image)]
.into_iter()
.filter_map(|(what, mut n)| {
let mut d = d.clone();
let plain = n.visual_bounds().unwrap();
n.appearance.effects.push(shadow_right());
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let b = art_bounds(&d).unwrap();
(b.x0 > plain.x0 || b.x1 < plain.x1 + 8.0).then(|| format!("{what}: {b:?}, the shadow 8 pt right of {plain:?}"))
})
.collect();
assert!(cut_off.is_empty(), "{cut_off:?}");
}
#[test]
fn art_bounds_leave_out_the_hidden_members_of_a_group_with_effects() {
let black = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
let mut d = Document::new(100.0, 100.0);
let square = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(10.0, 10.0, 20.0, 20.0)), black.clone());
let mut hidden = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(200.0, 10.0, 220.0, 20.0)), black.clone());
hidden.visible = false;
let mut guide = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(-300.0, 10.0, -200.0, 20.0)), black);
if let NodeKind::Path { guide: g, .. } = &mut guide.kind {
*g = true;
}
let mut group = Node::group(d.alloc_id(), [square, hidden, guide].map(std::sync::Arc::new).to_vec());
// A geometry effect on the group's own fill that is turned off.
group.appearance = Appearance::basic(Paint::None, Paint::None, 0.0).into();
group.appearance.effects.push(shadow_right());
let off = vectorcraft_doc::Effect { id: "distort.transform".into(), params: serde_json::json!({"scaleH": 50}), visible: false };
group.appearance.fill_mut().unwrap().effects.push(off);
let l = d.layers[0].id;
d.insert(Some(l), 0, group).unwrap();
let b = art_bounds(&d).unwrap();
assert!(b.x0 >= 0.0 && (28.0..50.0).contains(&b.x1), "the square and its shadow, not the hidden square or the guide: {b:?}");
}
#[test]
fn art_bounds_hold_a_groups_geometry_effect_measured_with_its_hidden_members() {
// The Transform effect halves the group's width about the center of all its members, the
// hidden one too: the visible rectangle is drawn from x 30 to x 80.
let black = Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0);
let mut d = Document::new(200.0, 100.0);
let shown = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(0.0, 10.0, 100.0, 20.0)), black.clone());
let mut hidden = Node::path(d.alloc_id(), shapes::rectangle(Rect::new(110.0, 10.0, 120.0, 20.0)), black);
hidden.visible = false;
let mut group = Node::group(d.alloc_id(), [shown, hidden].map(std::sync::Arc::new).to_vec());
let half = serde_json::json!({"scaleH": 50, "scaleV": 100});
group.appearance.effects.push(vectorcraft_doc::Effect { id: "distort.transform".into(), params: half, visible: true });
let l = d.layers[0].id;
d.insert(Some(l), 0, group).unwrap();
let b = art_bounds(&d).unwrap();
assert!(b.x0 <= 30.0 && b.x1 >= 80.0, "{b:?}");
let img = Renderer::new().render_region(&d, Rect::new(0.0, 0.0, 200.0, 100.0), 1.0, false);
let drawn = |x: u32| (0..100).any(|y| img.pixels[((y * 200 + x) * 4 + 3) as usize] > 0);
assert!(drawn(31) && drawn(78) && !drawn(28) && !drawn(82), "the rectangle is drawn from x 30 to x 80");
}
/// A black path round `r` in the first layer of `d`, made a guide when `guide`.
fn add_rect(d: &mut Document, r: Rect, guide: bool) {
let id = d.alloc_id();
+5 -3
View File
@@ -502,7 +502,9 @@ Draw, look, export:
Export As goes through `options` too: for PNG, JPEG and WebP `useArtboards: true` writes one file per artboard (all,
or `range`), named `<file>-<artboard>.<ext>`, and returns `{path, files}` (a PDF keeps them as pages of one file; an
SVG writes one file per artboard of a `range`); `useArtboards: false` exports the bounds of the visible art.
SVG writes one file per artboard of a `range`); `useArtboards: false` exports the bounds of the visible art with what
its effects paint, the effects of groups, type and images included. `artboard.fitToArt` measures the art the same
way.
Raster exports take more options in `options`: `ppi` (72, 150, 300…; stored in the file), `background`
(`transparent`, `white`, `black` or `"#rrggbb"`), `antiAlias` (`none`, `art`, `type`: text snapped to pixels) and,
@@ -1581,8 +1583,8 @@ its symbol is missing, when its symbol or a symbol nested in its art holds an in
nest 32 deep in its art. PDF and EPS export and the SVG of an artboard leave out art outside the page or artboard, and
Skip Blank Artboards skips an artboard with no art on it. For both, the box of an instance whose art holds brushed
strokes grows by 16 times the widest stroke (at least 1 pt) of the objects in its art that have a brushed stroke,
plus 8 pt. The SVG of an artboard also keeps an object that holds an instance when the effects in the symbol's art, or
the instance's own, paint onto the artboard.
plus 8 pt. The SVG of an artboard also keeps an object whose effects paint onto the artboard, and an object that holds
an instance when the effects in the symbol's art, or the instance's own, paint onto it.
## Expand
+2
View File
@@ -384,6 +384,8 @@ since 2026-10-01.
- **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).
- **Effects in the art's bounds:** Export with Use Artboards off, Fit to Artwork Bounds, and the other exports of the whole art (EPS without artboards, Export for Screens' Full Document, Save for Web (Legacy) with Clip to Artboard off) measure each visible object as the export crops above do. The drop shadows and other effects of groups, layers, type and images are inside the image or the artboard. A Shaper group without effects of its own is measured by the art it shows, and the hidden members of a group with a drop shadow, a glow or another raster effect do not count unless it is a Shaper group or also has a geometry, Pathfinder, Crop Marks or 3D effect or a visible effect on its own fill or stroke. The SVG of an artboard keeps an object whose effects alone reach the artboard. Tests measure a group, type and an image with a drop shadow, export a group and type with Use Artboards off and fit an artboard to them.
- **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.
- **Artboard tab-switch follow-up:** changing the active document through its tab aligns the Artboard tool with the displayed board before Properties Delete; clicking the already-active tab preserves multiselection and Move/Scale Artwork options. Three headless `chrome::tests::artboard_document_tab_*` regressions cover actual tab A → B → A clicks followed by Properties Delete, surviving boards and guides, preserved artwork/symbols and Undo/Redo; controls cover same-tab multiselection, Move/Scale Artwork options and other tools. Historical focused validation on `dbd2497b` recorded one expected RED failure with two passing controls, then all three GREEN tests passed. All three also passed in historical full CI on `a0671777`. A full CI retry on the five-unit combined source at `a0671777` passed all seven stages with 6,613 test outcomes on 2026-10-14. An earlier full-CI run on the identical source panicked in the randomized `ai_mutated_save_never_panics` test; the cause remains unresolved. This five-fix batch does not claim to repair that failure.