Merge pull request #6396 from dotCooCoo/fix/export-selection-keeps-clip

Export: exported objects keep the clips, masks and effects around them
This commit is contained in:
BFlat
2026-11-10 13:13:14 +07:00
committed by GitHub
8 changed files with 473 additions and 38 deletions
+1 -1
View File
@@ -44,7 +44,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Assets",
[],
None,
"{} → {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), format3D: usda|usdz|gltf|obj | null (a 3D asset's mesh format; null for art exported as pictures)}]} 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, with the clips of the groups and layers that hold it), format3D: usda|usdz|gltf|obj | null (a 3D asset's mesh format; null for art exported as pictures)}]} the Asset Export panel's assets, in order",
has_doc,
list
),
+1 -1
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export CSS",
[],
None,
"{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of the object's visual bounds, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once",
"{path?, scope?: selection|all (default selection; ids? picks the objects), …options as css.selection, id?: object id (default: the selection)} write the CSS (css.selection, or css.generate for all) to path and, with rasterize, each picture its rules refer to next to it (PNG of what the object draws, with the clips, masks and effects of the groups and layers that hold it, 1 px per point) → {path, bytes, rules: n, images: [path…], warnings}; no path → {data: the CSS, name: <document>.css, rules, images: [{name, dataBase64}], warnings}; warnings are css.selection's and the pictures' as document.export gives them (an image that couldn't be decoded, type in the fallback font or that doesn't fit its frame), each once",
has_doc,
export
),
+2
View File
@@ -184,6 +184,8 @@ mod tests_batch;
#[cfg(test)]
mod tests_screens;
#[cfg(test)]
mod tests_selexport;
#[cfg(test)]
mod tests_text;
#[cfg(test)]
+3 -3
View File
@@ -27,7 +27,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Serialize Document",
[],
None,
"{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in their layers)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored",
"{format?: vectorcraft (default)|template|svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd (ait, a .ait template, is written by file.saveAsTemplate), …the format's options (see document.formats; SVG ones also as svg: {…}), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own)} → {text, warnings} for svg (plus dataBase64, the file, when its encoding isn't UTF-8), else {dataBase64, warnings}; an SVG of several artboards also gives files: [{name, text}], linked images linked: [{name, dataBase64}]. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored",
has_doc,
export::serialize
),
@@ -36,7 +36,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Document",
[],
None,
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in their layers), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path",
"{path?, format?: svg|svgz|pdf|ai|png|jpg|webp|gif|png8|txt|dxf|eps|emf|wmf|tiff|bmp|tga|psd|vectorcraft|template (default: from the path's extension, else png; png8 writes an indexed .png; ait, a .ait template, is written by file.saveAsTemplate; an exporter plug-in's format is plugin.<plug-in id>, which an extension only it lists picks too), params?: {…} (an exporter plug-in's parameters; missing ones take their defaults, see plugin.info), selectedOnly?: false (the selected objects alone, in the layers and groups that hold them; an object of a blend, envelope, repeat or intertwine on its own), includeArtboardFill?: true (raster, PDF, SVG and EPS draw each exported artboard's background color behind its art, a transparent background too; false leaves it out), artboard?: 0, artboards?: [i…], rect?: [x, y, width, height] (raster formats: that area of the page in points, y down, instead of an artboard; not with artboard, artboards, range or useArtboards), range?: \"1-3, 5\" | \"all\" (1-based; PDF and .ai write one page per artboard, default all; SVG writes one file per artboard, {stem}-{artboard}.svg; raster formats write one artboard), useArtboards?: true (raster: one file per chosen artboard, default all, {stem}-{artboard}.{ext}; pdf/ai: every page) | false (pdf/ai/raster: the bounds of the visible art; SVG has it as an SVG option), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), background?: transparent|white|black|\"#rrggbb\" (jpg: white when transparent; #rrggbbaa and #rgba are refused here and in matte), antiAlias?: none|art (default)|type (text snapped to pixels), interlaced?: false (png, Adam7), embedIcc?: true (png: an sRGB chunk marks the colours as sRGB), quality?: 90 (jpg: 0–100), colorModel?: rgb|cmyk|gray (jpg), method?: baseline|optimized|progressive, scans?: 3 (3–5, progressive), embedIcc?: true, imageMap?: none|client|server (an HTML or NCSA map of the objects with a URL, written as <stem>.html / <stem>.map), colors?: 256 (gif/png8: 2–256), reduction?: perceptual|selective (default)|adaptive|web|blackWhite|gray, dither?: none|diffusion (default)|pattern|noise, ditherAmount?: 100, transparency?: true, matte?: white|\"#rrggbb\"|none, interlaced?, lossless?: true (webp: lossy WebP isn't available yet: written lossless, with a warning), encoding?: utf8|utf16 (txt: the stories in stacking order, back to front, a thread once; utf16 with a byte order mark), lineEndings?: lf|crlf, selectionOnly?: false, svg?: {styling, outlineText, images, objectIds, decimals, minify, responsive, useArtboards, preserveEditing, metadata, fewerTspans, hiddenLayers, encoding, profile, embedFonts} (see document.formats), …the SVG options given flat instead, …the PDF options of document.exportPdf, …the DXF options of document.exportDxf (useArtboards: one drawing per artboard), …the EPS options of document.exportEps (useArtboards: one file per artboard, {stem}_{artboard}.eps, after a central {stem}.eps of the whole document when there are two or more and art shows; else the visible art), …emf and wmf write one picture of the artboard (useArtboards: true one file per chosen artboard, false the bounds of the visible art; EMF keeps curves, clipping, transparent images and gradients as images clipped to their shape; WMF flattens curves into polygons behind a placeable header; what a format leaves out comes back in warnings), colorModel?: rgb|cmyk|gray (tiff: rgb keeps transparency as an alpha channel), lzw?: true, byteOrder?: little|big, embedIcc?: true, colorModel?: rgb|gray (bmp), depth?: 24 (bmp: 1 black and white, 4|8 a palette by reduction and dither, 16, 24, 32 with alpha), fileFormat?: windows|os2 (1, 4, 8 or 24 bits), rle?: false (4 and 8 bits, windows), flipRows?: false (top-down rows), depth?: 24 (tga: 16 one-bit alpha, 24, 32 with alpha), colorModel?: rgb|cmyk|gray (psd), layers?: true (each top-level layer a pixel layer with its opacity and blend mode; a background colour is a Background layer; false: one flat image, on white), maxEditability?: false (layers and sublayers become groups, each object a layer named as in the Layers panel, text by its text; clipping, masked or knockout layers stay one layer), hiddenLayers?: false (hidden layers and objects written hidden instead of left out), embedIcc?: true (at most 30000 pixels a side; formats without alpha are flattened on white)} → {path, format, bytes, warnings, files?: [path…] (several), linked?: [path…] (linked images, image maps)}; no path → {dataBase64, format, bytes, warnings, files?: [{name, dataBase64}], linked?: [{name, dataBase64}]}. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored. Never changes the document's path",
has_doc,
export::export
),
@@ -45,7 +45,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Export Selection…",
["File"],
None,
"{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects cropped to their bounds (template layers left out) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored",
"{path?, format?: png|jpg|webp|svg|svgz|pdf|ai|gif|png8|tiff|bmp|tga|psd|txt|dxf|eps|emf|wmf|vectorcraft|template (default: from the extension, else png), ppi?: number (raster formats: pixels per inch, stored in the file; wins over scale; without either, the document's raster effects resolution), scale?: number (pixels per point), …the format's options} the selected objects (template layers left out), each in the layers and groups that hold it, whose clipping paths, opacity masks, effects, opacity and blend modes apply as on the canvas (an object of a blend, envelope, repeat or intertwine on its own; txt: the selected type alone), cropped to the bounds the renderer measures for that art (a clip group by its clip) → {path, format, bytes, warnings, bounds} (no path → {dataBase64, format, bytes, warnings, bounds}); warnings say what the format approximated or left out, as for document.export. A key no format takes, and an unknown key inside a PDF or EPS option's object, are refused; a key only another format takes is ignored",
has_selection,
export::export_selection
),
+83 -32
View File
@@ -94,27 +94,63 @@ pub fn export_source<'a>(st: &'a DocState, p: &Value) -> Result<Cow<'a, Document
return Err(bad("document.export", "select something to export (or turn off selectedOnly)"));
}
let mut d = Document::clone(&st.doc);
d.layers = st.doc.layers.iter().filter_map(|l| selected_part(l, &keep)).map(Arc::new).collect();
d.layers = enclosed(&st.doc, &keep, false);
Ok(Cow::Owned(d))
}
/// `n` with only the parts in `keep` (whole, with everything inside them), `None` when none is.
fn selected_part(n: &Node, keep: &HashSet<NodeId>) -> Option<Node> {
/// The layers of `doc` with only the objects in `keep` (whole, with everything inside them), each
/// in the layers, groups and compound paths that hold it. Those keep what they apply to the object
/// on the canvas: their clipping paths, opacity masks, effects, opacity and blend modes. An object
/// of a blend, envelope, repeat or intertwine goes in on its own, without the art made from it. With
/// `shown`, the layers and groups that hold the objects are visible, and those layers print and are
/// not template layers.
fn enclosed(doc: &Document, keep: &HashSet<NodeId>, shown: bool) -> Vec<Arc<Node>> {
let holders: HashSet<NodeId> = keep.iter().filter_map(|id| doc.ancestry(*id)).flatten().filter(|id| !keep.contains(id)).collect();
let mut out = vec![];
for l in &doc.layers {
selected_part(l, keep, &holders, shown, &mut out);
}
out
}
/// The parts of `n` in `keep` (whole, with everything inside them), added to `out`: `n` itself, or
/// `n` with only those parts when it is one of their `holders`, made visible, printable and not a
/// template with `shown`.
fn selected_part(n: &Arc<Node>, keep: &HashSet<NodeId>, holders: &HashSet<NodeId>, shown: bool, out: &mut Vec<Arc<Node>>) {
if keep.contains(&n.id) {
return Some(n.clone());
out.push(n.clone());
return;
}
let Some(children) = n.children().filter(|_| holders.contains(&n.id)) else { return };
let mut kept = vec![];
for c in children {
selected_part(c, keep, holders, shown, &mut kept);
}
if !matches!(n.kind, NodeKind::Layer { .. } | NodeKind::Group { .. } | NodeKind::Compound { .. }) {
out.extend(kept);
return;
}
let children = n.children()?;
let mut kept: Vec<Arc<Node>> = children.iter().filter_map(|c| selected_part(c, keep).map(Arc::new)).collect();
if kept.is_empty() {
return None;
return;
}
let clip = matches!(n.kind, NodeKind::Group { clip: true, .. } | NodeKind::Layer { clip: true, .. });
if let Some(first) = children.first().filter(|f| clip && kept.first().is_none_or(|k| k.id != f.id)) {
// A clipping group or layer keeps its clipping path; a Shaper group its source art, which
// draws nothing.
let first_stays = n.clips() || n.extras().shaper.is_some();
if let Some(first) = children.first().filter(|f| first_stays && kept.first().is_none_or(|k| k.id != f.id)) {
kept.insert(0, first.clone());
}
let mut out = n.clone();
*out.children_mut()? = kept;
Some(out)
let mut part = Node::clone(n);
if let Some(ch) = part.children_mut() {
*ch = kept;
}
if shown {
part.visible = true;
if let NodeKind::Layer { template, printable, .. } = &mut part.kind {
*template = false;
*printable = true;
}
}
out.push(Arc::new(part));
}
/// `doc` with only the art over `rect` (an artboard): objects whose visual bounds miss it are left
@@ -202,7 +238,7 @@ fn expand_presets<'a>(s: &Session, cmd: &str, p: &'a Value) -> Result<Cow<'a, Va
/// with `selectionOnly` the selected objects alone.
fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(Cow<'a, Document>, bool)> {
if f.id == "txt" && bool_or(p, "selectionOnly", false) {
let doc = selection(s, cmd)?.0;
let doc = selection(s, cmd, true)?.0;
let display_doc_title = super::has_own_title(&doc);
return Ok((Cow::Owned(doc), display_doc_title));
}
@@ -212,11 +248,21 @@ fn source<'a>(s: &'a mut Session, f: &Format, p: &Value, cmd: &str) -> Result<(C
Ok((super::titled_for_export(&doc, st.path.as_deref(), f.id).map_or(doc, Cow::Owned), display_doc_title))
}
/// The selected objects (not on template layers) alone on one layer, with one artboard: their
/// visual bounds.
fn selection(s: &mut Session, cmd: &str) -> Result<(Document, vectorcraft_geom::Rect)> {
/// The selected objects (not on template layers) in the layers and groups that hold them
/// ([`isolated`]), with one artboard: their crop. With `text`, the objects alone on one layer, so a
/// text export holds the type of the selected objects and not that of a clipping path around them.
fn selection(s: &mut Session, cmd: &str, text: bool) -> Result<(Document, vectorcraft_geom::Rect)> {
let ids = selected_artwork(s)?;
isolated(&s.doc()?.doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export"))
let doc = &s.doc()?.doc;
let (mut d, bounds) = isolated(doc, &ids, "Selection").ok_or_else(|| bad(cmd, "select something to export"))?;
if text {
let mut layer = Node::layer(d.alloc_id(), "Selection", vectorcraft_doc::LayerColor::Preset(0));
if let Some(ch) = layer.children_mut() {
*ch = ids.iter().filter_map(|id| doc.node(*id).cloned().map(Arc::new)).collect();
}
d.layers = vec![Arc::new(layer)];
}
Ok((d, bounds))
}
/// The selected objects (their top-most selected ancestors) that are artwork: those on template
@@ -231,25 +277,30 @@ pub(crate) fn selected_artwork(s: &Session) -> Result<Vec<NodeId>> {
Ok(ids.into_iter().filter(|id| !on_template(*id)).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.
/// The crop of an export of objects `ids` of `doc` alone ([`isolated`]): 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), with what the layers and groups that hold them
/// apply (a clipping group or layer is measured by its clip), `None` when they have none.
pub(crate) fn export_crop(doc: &Document, ids: &[NodeId]) -> Option<vectorcraft_geom::Rect> {
let symbols = doc.symbol_boxes();
ids.iter().filter_map(|id| vectorcraft_render::effect_bounds(&symbols, doc.node(*id)?)).reduce(|a, b| a.union(b))
isolated_art(doc, ids).1
}
/// 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).
/// The layers of [`isolated`] and their crop.
fn isolated_art(doc: &Document, ids: &[NodeId]) -> (Vec<Arc<Node>>, Option<vectorcraft_geom::Rect>) {
let keep: HashSet<NodeId> = ids.iter().copied().collect();
let layers = enclosed(doc, &keep, true);
let symbols = doc.symbol_boxes();
let bounds = layers.iter().filter_map(|l| vectorcraft_render::effect_bounds(&symbols, l)).reduce(|a, b| a.union(b));
(layers, bounds)
}
/// Objects `ids` of `doc` alone, each in the layers and groups that hold it ([`enclosed`], made
/// visible), 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 (layers, bounds) = isolated_art(doc, ids);
let bounds = bounds?;
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() {
*ch = nodes;
}
d.layers = vec![Arc::new(layer)];
d.layers = layers;
Some((d, bounds))
}
@@ -265,7 +316,7 @@ pub(super) fn export_selection(s: &mut Session, p: &Value) -> Result<Value> {
let f = writable(C, str_param(p, "format"), path)?;
let expanded = expand_presets(s, C, p)?;
let p = with_black_output(s, &expanded);
let (d, bounds) = selection(s, C)?;
let (d, bounds) = selection(s, C, f.id == "txt")?;
// The encoder's warnings (options accepted but not applied, features approximated or left
// out), as document.export reports them.
let (bytes, warnings) = encode_with_warnings(&d, f.id, &without_artboards(&p))?;
@@ -0,0 +1,375 @@
//! Exports of objects alone (Export Selection, Export for Screens by selection or by layer, Asset
//! Export, a CSS rule's picture and `selectedOnly`) draw the objects as the canvas draws them: with
//! the clipping paths, opacity masks, effects, opacity and blend modes of the layers and groups that
//! hold them.
use serde_json::{Value, json};
use vectorcraft_geom::Rect;
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 500, "height": 500})).unwrap();
s
}
fn run(s: &mut Session, cmd: &str, p: Value) -> Value {
s.execute(cmd, &p).unwrap_or_else(|e| panic!("{cmd}: {e}"))
}
fn id(v: &Value) -> u64 {
v["id"].as_u64().unwrap_or_else(|| panic!("no id in {v}"))
}
/// A red 300 pt square at (100, 100) clipped by a 200 pt circle at (150, 150): (square, clip group).
fn clipped(s: &mut Session) -> (u64, u64) {
let square = id(&run(s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 300, "height": 300})));
run(s, "paint.setFill", json!({"color": "#ff0000"}));
let circle = id(&run(s, "shape.ellipse", json!({"x": 150, "y": 150, "width": 200, "height": 200})));
let group = id(&run(s, "object.clippingMask.make", json!({"ids": [circle, square]})));
(square, group)
}
fn b64(v: &Value) -> Vec<u8> {
vectorcraft_format::base64_decode(v["dataBase64"].as_str().unwrap_or_else(|| panic!("no dataBase64 in {v}"))).unwrap()
}
fn rect_of(v: &Value) -> Rect {
let n = |i: usize| v[i].as_f64().unwrap_or_else(|| panic!("bad bounds {v}"));
Rect::new(n(0), n(1), n(0) + n(2), n(1) + n(3))
}
/// What the canvas draws of the whole document over `r`, at 1 px per point.
fn canvas(s: &Session, r: Rect) -> image::RgbaImage {
let img = vectorcraft_render::Renderer::new().render_region(&s.doc().unwrap().doc, r, 1.0, false);
image::load_from_memory(&img.to_png().unwrap()).unwrap().to_rgba8()
}
/// The pixels of `png` that differ from `want` by more than 64 in a channel (anti-aliased edges
/// differ by less), and every pixel when the sizes differ.
fn differing(png: &[u8], want: &image::RgbaImage) -> usize {
let got = image::load_from_memory(png).unwrap().to_rgba8();
if got.dimensions() != want.dimensions() {
return (want.width() * want.height()) as usize;
}
got.pixels().zip(want.pixels()).filter(|(a, b)| a.0.iter().zip(b.0.iter()).any(|(x, y)| x.abs_diff(*y) > 64)).count()
}
/// Export Selection of what is selected, as a PNG, matches the canvas over its crop: fewer than 200
/// pixels differ (anti-aliased edges), or 200 times `edges` with [`selection_png_matches_canvas_with`].
fn selection_png_matches_canvas(s: &mut Session, want: Rect, what: &str) {
selection_png_matches_canvas_with(s, want, what, 1);
}
fn selection_png_matches_canvas_with(s: &mut Session, want: Rect, what: &str, edges: usize) {
let r = run(s, "document.exportSelection", json!({"format": "png", "scale": 1}));
let got = rect_of(&r["bounds"]);
assert!((got.x0 - want.x0).abs() < 1.5 && (got.y0 - want.y0).abs() < 1.5, "{what}: crop {got:?}, want {want:?}");
assert!((got.width() - want.width()).abs() < 3.0 && (got.height() - want.height()).abs() < 3.0, "{what}: crop {got:?}, want {want:?}");
let off = differing(&b64(&r), &canvas(s, got));
assert!(off < 200 * edges, "{what}: {off} pixels differ from the canvas");
}
#[test]
fn export_selection_of_a_clipped_object_keeps_its_clip() {
let mut s = session();
let (square, _) = clipped(&mut s);
run(&mut s, "select.set", json!({"ids": [square]}));
selection_png_matches_canvas(&mut s, Rect::new(150.0, 150.0, 350.0, 350.0), "the square alone");
let svg = b64(&run(&mut s, "document.exportSelection", json!({"format": "svg"})));
let text = String::from_utf8_lossy(&svg);
assert!(text.contains("<clipPath") && text.contains("clip-path"), "the SVG clips the square: {text}");
// Opened again, the SVG draws what the canvas draws.
let mut t = Session::new();
run(&mut t, "document.open", json!({"name": "sel.svg", "dataBase64": vectorcraft_format::base64_encode(&svg)}));
let board = t.doc().unwrap().doc.artboards[0].rect;
let reopened = canvas(&t, board);
let mut png = vec![];
reopened.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let off = differing(&png, &canvas(&s, Rect::new(150.0, 150.0, 350.0, 350.0)));
assert!(off < 200, "the reopened SVG: {off} pixels differ from the canvas");
}
#[test]
fn export_selection_of_a_clipping_layer_keeps_the_layer_clip() {
let mut s = session();
let layer = id(&run(&mut s, "layer.new", json!({"name": "L"})));
run(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 300, "height": 300}));
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
run(&mut s, "shape.ellipse", json!({"x": 150, "y": 150, "width": 200, "height": 200}));
run(&mut s, "layer.clippingMask.toggle", json!({"id": layer}));
run(&mut s, "select.all", json!({}));
selection_png_matches_canvas(&mut s, Rect::new(150.0, 150.0, 350.0, 350.0), "a clipping layer's objects");
}
#[test]
fn export_selection_keeps_the_opacity_effects_and_mask_of_the_groups_around_it() {
let mut s = session();
let (square, group) = clipped(&mut s);
let other = id(&run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 40, "height": 40})));
let outer = id(&run(&mut s, "object.group", json!({"ids": [group, other]})));
run(&mut s, "transparency.set", json!({"ids": [outer], "opacity": 50}));
run(&mut s, "select.set", json!({"ids": [square]}));
selection_png_matches_canvas(&mut s, Rect::new(150.0, 150.0, 350.0, 350.0), "inside a group at 50%");
// A drop shadow on the clip group reaches past its clip.
run(&mut s, "effect.apply", json!({"ids": [group], "effect": "stylize.dropShadow"}));
let r = run(&mut s, "document.exportSelection", json!({"format": "png", "scale": 1}));
let got = rect_of(&r["bounds"]);
assert!(got.x1 > 352.0 && got.y1 > 352.0, "the crop takes in the clip group's shadow: {got:?}");
assert!(differing(&b64(&r), &canvas(&s, got)) < 200, "with the shadow");
run(&mut s, "edit.undo", json!({}));
// An opacity mask on the clip group.
let mask = id(&run(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 300, "height": 300})));
run(
&mut s,
"paint.setFill",
json!({"gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#ffffff"}, {"offset": 1, "color": "#000000"}]}}),
);
run(&mut s, "transparency.makeOpacityMask", json!({"ids": [mask, group]}));
run(&mut s, "select.set", json!({"ids": [square]}));
let r = run(&mut s, "document.exportSelection", json!({"format": "png", "scale": 1}));
let got = rect_of(&r["bounds"]);
assert!(differing(&b64(&r), &canvas(&s, got)) < 200, "with an opacity mask on the clip group");
}
#[test]
fn export_selection_across_clip_groups_and_of_whole_ones() {
let mut s = session();
let (a, ga) = clipped(&mut s);
let b = id(&run(&mut s, "shape.rectangle", json!({"x": 400, "y": 400, "width": 90, "height": 90})));
let c = id(&run(&mut s, "shape.rectangle", json!({"x": 420, "y": 420, "width": 50, "height": 50})));
let gb = id(&run(&mut s, "object.clippingMask.make", json!({"ids": [c, b]})));
// A member of each clip group: each keeps its own clip.
run(&mut s, "select.set", json!({"ids": [a, b]}));
selection_png_matches_canvas(&mut s, Rect::new(150.0, 150.0, 470.0, 470.0), "a member of each of two clip groups");
// The clip groups themselves.
run(&mut s, "select.set", json!({"ids": [ga, gb]}));
selection_png_matches_canvas(&mut s, Rect::new(150.0, 150.0, 470.0, 470.0), "two clip groups");
// Nested clip groups: the inner group's member is clipped by both, cropped to the outer clip.
let strip = id(&run(&mut s, "shape.rectangle", json!({"x": 0, "y": 200, "width": 500, "height": 100})));
run(&mut s, "object.clippingMask.make", json!({"ids": [strip, ga]}));
run(&mut s, "select.set", json!({"ids": [a]}));
selection_png_matches_canvas(&mut s, Rect::new(0.0, 200.0, 500.0, 300.0), "a member of nested clip groups");
}
#[test]
fn a_selected_clipping_path_and_a_blend_object_export_on_their_own() {
let mut s = session();
let (square, group) = clipped(&mut s);
let circle = s.doc().unwrap().doc.node(vectorcraft_doc::NodeId(group)).unwrap().children().unwrap()[0].id.0;
assert_ne!(circle, square);
// The clipping path alone: the clip group's other art stays out.
run(&mut s, "select.set", json!({"ids": [circle]}));
let r = run(&mut s, "document.exportSelection", json!({"format": "png", "scale": 1}));
let img = image::load_from_memory(&b64(&r)).unwrap().to_rgba8();
assert_eq!(img.dimensions(), (200, 200), "cropped to the clipping path");
assert!(!img.pixels().any(|p| p[0] > 200 && p[1] < 80 && p[3] > 0), "the clipped square is left out");
// An object of a blend goes in on its own, without the blend's steps.
let mut s = session();
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
let a = id(&run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 60, "height": 60})));
let b = id(&run(&mut s, "shape.rectangle", json!({"x": 400, "y": 400, "width": 60, "height": 60})));
run(&mut s, "select.set", json!({"ids": [a, b]}));
run(&mut s, "object.blend.make", json!({}));
run(&mut s, "select.set", json!({"ids": [a]}));
let r = run(&mut s, "document.exportSelection", json!({"format": "png", "scale": 1}));
let got = rect_of(&r["bounds"]);
assert!(got.x1 < 90.0 && got.y1 < 90.0, "the blend object's own bounds: {got:?}");
let img = image::load_from_memory(&b64(&r)).unwrap().to_rgba8();
let centre = img.get_pixel(img.width() / 2, img.height() / 2);
assert!(centre[0] > 200 && centre[3] > 200, "the object is drawn: {centre:?}");
}
#[test]
fn a_css_picture_of_a_clipped_object_is_clipped() {
let mut s = session();
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
let star = id(&run(&mut s, "shape.star", json!({"cx": 250, "cy": 250, "radius1": 150, "radius2": 70, "points": 5})));
let circle = id(&run(&mut s, "shape.ellipse", json!({"x": 200, "y": 200, "width": 100, "height": 100})));
run(&mut s, "object.clippingMask.make", json!({"ids": [circle, star]}));
let r = run(&mut s, "css.export", json!({"ids": [star], "rasterize": true, "unnamed": true}));
let image = r["images"].as_array().and_then(|i| i.first()).unwrap_or_else(|| panic!("no picture: {r}"));
let png = b64(image);
let size = image::load_from_memory(&png).unwrap().to_rgba8().dimensions();
assert_eq!(size, (100, 100), "the picture is cropped to the clip");
assert!(differing(&png, &canvas(&s, Rect::new(200.0, 200.0, 300.0, 300.0))) < 100, "the picture matches the canvas");
}
#[test]
fn an_asset_on_a_hidden_layer_is_drawn_clipped() {
let mut s = session();
let layer = s.doc().unwrap().doc.layers[0].id.0;
let (square, _) = clipped(&mut s);
let asset = run(&mut s, "assets.add", json!({"ids": [square]}))["assets"][0].as_u64().unwrap();
run(&mut s, "layer.setProps", json!({"id": layer, "visible": false}));
let r = run(&mut s, "assets.export", json!({"assets": [asset], "formats": [{"format": "png", "scale": 1}]}));
let img = image::load_from_memory(&b64(&r["files"][0])).unwrap().to_rgba8();
assert_eq!(img.dimensions(), (200, 200), "cropped to the clip");
let (centre, corner) = (img.get_pixel(100, 100), img.get_pixel(3, 3));
assert!(centre[0] > 200 && centre[3] > 200, "the square is drawn: {centre:?}");
assert_eq!(corner[3], 0, "outside the circle stays clear: {corner:?}");
}
#[test]
fn export_for_screens_and_assets_by_selection_keep_the_clip() {
let mut s = session();
let (square, _) = clipped(&mut s);
run(&mut s, "select.set", json!({"ids": [square]}));
let r = run(&mut s, "document.exportForScreens", json!({"selection": "one", "formats": [{"format": "png", "scale": 1}, {"format": "svg"}]}));
let want = canvas(&s, Rect::new(150.0, 150.0, 350.0, 350.0));
let png = r["files"].as_array().unwrap().iter().find(|f| f["name"].as_str().is_some_and(|n| n.ends_with(".png"))).unwrap();
assert!(differing(&b64(png), &want) < 200, "Export for Screens by selection: {}", png["name"]);
let svg = r["files"].as_array().unwrap().iter().find(|f| f["name"].as_str().is_some_and(|n| n.ends_with(".svg"))).unwrap();
let text = String::from_utf8(b64(svg)).unwrap();
assert!(text.contains("<clipPath"), "the SVG clips the square: {text}");
let asset = run(&mut s, "assets.add", json!({"ids": [square]}))["assets"][0].as_u64().unwrap();
let list = run(&mut s, "assets.list", json!({}));
let bounds = rect_of(&list["assets"][0]["bounds"]);
assert!((bounds.width() - 200.0).abs() < 3.0, "the asset's crop is its clip: {bounds:?}");
let r = run(&mut s, "assets.export", json!({"assets": [asset], "formats": [{"format": "png", "scale": 1}]}));
assert!(differing(&b64(&r["files"][0]), &want) < 200, "Asset Export: {}", r["files"][0]["name"]);
}
/// The red pixels of `png`.
fn red(png: &[u8]) -> usize {
image::load_from_memory(png).unwrap().to_rgba8().pixels().filter(|p| p[0] > 200 && p[1] < 80 && p[3] > 200).count()
}
#[test]
fn a_selection_on_a_non_printing_layer_exports_to_pdf() {
let mut s = session();
let layer = s.doc().unwrap().doc.layers[0].id.0;
let (square, _) = clipped(&mut s);
run(&mut s, "layer.setProps", json!({"id": layer, "printable": false}));
run(&mut s, "select.set", json!({"ids": [square]}));
let pdf = b64(&run(&mut s, "document.exportSelection", json!({"format": "pdf", "createLayers": false, "preserveEditing": false})));
let mut t = Session::new();
run(&mut t, "document.open", json!({"name": "sel.pdf", "dataBase64": vectorcraft_format::base64_encode(&pdf)}));
let page = t.doc().unwrap().doc.artboards[0].rect;
let mut png = vec![];
canvas(&t, page).write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
assert!(red(&png) > 20_000, "the PDF draws the square: {} red pixels", red(&png));
assert!(differing(&png, &canvas(&s, Rect::new(150.0, 150.0, 350.0, 350.0))) < 300, "the PDF draws what the canvas draws");
}
#[test]
fn export_for_screens_of_layers_draws_them_as_the_canvas_does() {
let mut s = session();
let layer = s.doc().unwrap().doc.layers[0].id.0;
clipped(&mut s);
// A layer at 50% opacity: its art is drawn at 50%.
run(&mut s, "transparency.set", json!({"ids": [layer], "opacity": 50}));
let r = run(&mut s, "document.exportForScreens", json!({"layers": [layer], "formats": [{"format": "png", "scale": 1}]}));
let png = b64(&r["files"][0]);
assert!(differing(&png, &canvas(&s, Rect::new(150.0, 150.0, 350.0, 350.0))) < 200, "the layer's opacity applies");
// A template layer's art is still drawn.
run(&mut s, "transparency.set", json!({"ids": [layer], "opacity": 100}));
run(&mut s, "layer.setProps", json!({"id": layer, "template": true}));
let r = run(&mut s, "document.exportForScreens", json!({"layers": [layer], "formats": [{"format": "png", "scale": 1}, {"format": "svg"}]}));
let files = r["files"].as_array().unwrap();
let file = |ext: &str| b64(files.iter().find(|f| f["name"].as_str().is_some_and(|n| n.ends_with(ext))).unwrap());
assert!(red(&file(".png")) > 20_000, "the template layer's art is drawn: {} red pixels", red(&file(".png")));
assert!(String::from_utf8(file(".svg")).unwrap().contains("<clipPath"), "the SVG holds the clipped art");
}
#[test]
fn a_text_export_of_the_selection_leaves_out_a_type_clipping_path() {
let mut s = session();
// The front object, the type "Clip", becomes the clipping path.
let inner = id(&run(&mut s, "text.create", json!({"x": 100, "y": 110, "text": "Inner"})));
let clip = id(&run(&mut s, "text.create", json!({"x": 100, "y": 100, "text": "Clip"})));
run(&mut s, "object.clippingMask.make", json!({"ids": [clip, inner]}));
let mut first = None;
s.doc().unwrap().doc.layers[0].walk(&mut |n| {
if matches!(n.kind, vectorcraft_doc::NodeKind::Group { clip: true, .. }) {
first = n.children().and_then(|c| c.first()).map(|c| c.id.0);
}
});
assert_eq!(first, Some(clip), "the type is the clipping path");
run(&mut s, "select.set", json!({"ids": [inner]}));
let txt = b64(&run(&mut s, "document.export", json!({"format": "txt", "selectionOnly": true})));
assert_eq!(String::from_utf8(txt).unwrap(), "Inner\n");
let txt = b64(&run(&mut s, "document.exportSelection", json!({"format": "txt"})));
assert_eq!(String::from_utf8(txt).unwrap(), "Inner\n");
}
#[test]
fn deep_clip_groups_symbols_and_large_selections_keep_their_clips() {
// Five clip groups inside each other, each clip a little smaller.
let mut s = session();
let (square, mut group) = clipped(&mut s);
for i in 1..5 {
let inset = 10.0 * f64::from(i);
let clip = id(&run(&mut s, "shape.rectangle", json!({"x": 150.0 + inset, "y": 150.0 + inset, "width": 200.0 - 2.0 * inset, "height": 400})));
group = id(&run(&mut s, "object.clippingMask.make", json!({"ids": [clip, group]})));
}
run(&mut s, "select.set", json!({"ids": [square]}));
selection_png_matches_canvas(&mut s, Rect::new(190.0, 190.0, 310.0, 590.0), "a member of five nested clip groups");
// A symbol instance in a clip group.
let mut s = session();
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
let art = id(&run(&mut s, "shape.rectangle", json!({"x": 100, "y": 100, "width": 300, "height": 300})));
run(&mut s, "select.set", json!({"ids": [art]}));
let instance = id(&run(&mut s, "symbol.new", json!({"name": "Red"})));
let circle = id(&run(&mut s, "shape.ellipse", json!({"x": 150, "y": 150, "width": 200, "height": 200})));
run(&mut s, "object.clippingMask.make", json!({"ids": [circle, instance]}));
run(&mut s, "select.set", json!({"ids": [instance]}));
selection_png_matches_canvas(&mut s, Rect::new(150.0, 150.0, 350.0, 350.0), "a symbol instance in a clip group");
// 200 clip groups, a member of each selected.
let mut s = session();
run(&mut s, "paint.setFill", json!({"color": "#ff0000"}));
let mut members = vec![];
for i in 0..200 {
let (x, y) = (f64::from(i % 20) * 25.0, f64::from(i / 20) * 25.0);
let m = id(&run(&mut s, "shape.rectangle", json!({"x": x, "y": y, "width": 20, "height": 20})));
let c = id(&run(&mut s, "shape.ellipse", json!({"x": x, "y": y, "width": 20, "height": 20})));
run(&mut s, "object.clippingMask.make", json!({"ids": [c, m]}));
members.push(m);
}
run(&mut s, "select.set", json!({"ids": members}));
// The circles' anti-aliased edges differ by up to about 30 pixels each; without their clips the
// squares' corners differ by 86 pixels each.
selection_png_matches_canvas_with(&mut s, Rect::new(0.0, 0.0, 495.0, 245.0), "members of 200 clip groups", 40);
}
#[test]
fn an_asset_in_a_hidden_or_locked_clip_group_is_drawn_clipped() {
for change in ["object.hide", "object.lock"] {
let mut s = session();
let (square, group) = clipped(&mut s);
let asset = run(&mut s, "assets.add", json!({"ids": [square]}))["assets"][0].as_u64().unwrap();
let want = canvas(&s, Rect::new(150.0, 150.0, 350.0, 350.0));
run(&mut s, "select.set", json!({"ids": [group]}));
run(&mut s, change, json!({}));
let r = run(&mut s, "assets.export", json!({"assets": [asset], "formats": [{"format": "png", "scale": 1}]}));
assert!(differing(&b64(&r["files"][0]), &want) < 200, "{change}: the asset draws as the canvas did");
}
}
#[test]
fn selected_only_keeps_holders_and_takes_a_blend_object_alone() {
let mut s = session();
let (square, _) = clipped(&mut s);
run(&mut s, "select.set", json!({"ids": [square]}));
let r = run(&mut s, "document.export", json!({"format": "png", "scale": 1, "selectedOnly": true}));
let board = s.doc().unwrap().doc.artboards[0].rect;
assert!(differing(&b64(&r), &canvas(&s, board)) < 200, "selectedOnly keeps the clip");
// An object of a blend is written on its own.
let mut s = session();
let a = id(&run(&mut s, "shape.rectangle", json!({"x": 20, "y": 20, "width": 60, "height": 60})));
let b = id(&run(&mut s, "shape.rectangle", json!({"x": 400, "y": 400, "width": 60, "height": 60})));
run(&mut s, "select.set", json!({"ids": [a, b]}));
run(&mut s, "object.blend.make", json!({}));
run(&mut s, "select.set", json!({"ids": [a]}));
let svg = run(&mut s, "document.serialize", json!({"format": "svg", "selectedOnly": true}))["text"].as_str().unwrap().to_string();
let mut t = Session::new();
run(&mut t, "document.open", json!({"name": "a.svg", "dataBase64": vectorcraft_format::base64_encode(svg.as_bytes())}));
let mut paths = 0;
for l in &t.doc().unwrap().doc.layers {
l.walk(&mut |n| paths += usize::from(matches!(n.kind, vectorcraft_doc::NodeKind::Path { .. })));
}
assert_eq!(paths, 1, "the blend object alone, without the steps: {svg}");
}
+6 -1
View File
@@ -5191,7 +5191,12 @@ fallback font…), each note once; the export still writes or returns every file
Batch exports (#1935):
- **What:** `layers: [id…]` (each layer's art alone) or `selection: "one" | "each"` instead of artboards
(`assets`, `fullDocument` as before).
(`assets`, `fullDocument` as before). A selected object, a layer's object or an asset's object stays in the layers
and groups that hold it, so their clipping paths, opacity masks, effects, opacity and blend modes apply to it as on
the canvas, and the file is cropped to the bounds the renderer measures for that art (a clip group by its clip); an
object of a blend, envelope, repeat or intertwine is drawn on its own. Those layers are drawn even when they are
hidden, template or non-printing layers, and a PSD of the file names its layers after them.
`document.exportSelection` and the pictures of `css.export {rasterize: true}` draw the selected objects the same way.
- **Names:** `name` is a pattern for every file: `<filename>` (the document's file name without its extension, or
its title before it is saved; the Full Document file and the `.zip` use the same name), `<name>` (the artboard,
asset, layer or object), `<index>`, `####` (a counter zero-padded to the run of `#`, from `counterStart`; `counterPer: "item"`
+2
View File
@@ -596,6 +596,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).
- **Clips and masks around exported selections:** File › Export Selection, Export for Screens by selection or by layer, Asset Export and CSS Properties' rasterized art draw each object in the layers and groups that hold it. A clip group's or clipping layer's clip, an opacity mask, and a group's or layer's effects, opacity and blend mode apply as on the canvas. The crop is the bounds the renderer measures for that art, a clip group by its clip. These are VectorCraft's own rules. An object of a blend, envelope, repeat or intertwine is exported on its own, and a text export of the selection holds only the selected type. Engine tests compare the PNG exports with the canvas over the same area, reopen the SVG and PDF exports, and cover nested clip groups, symbol instances, 200 clip groups and assets in hidden or locked groups.
- **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.
- **Raster export backgrounds (#6125):** a raster export draws its background under the finished art, and blend modes read only the art below them, as on the canvas. An Outer Glow in Screen mode on the bare page shows in a PNG on white and in a JPEG. Flatten Transparency puts its white under the finished art the same way. Evidence: `tests::file::a_screen_outer_glow_is_in_raster_exports`, `tests::objectfx::a_screen_outer_glow_on_the_bare_page_shows_on_a_background`, `tests::knockout::page_knockout_and_isolated_blending`, `tests::knockout::no_blend_mode_reads_the_background`, `tests::cmykflatten::every_blend_mode_flattens_to_the_canvas_pixels_in_cmyk`.