diff --git a/crates/effects/src/bake.rs b/crates/effects/src/bake.rs index 3b66df25..c6f7f375 100644 --- a/crates/effects/src/bake.rs +++ b/crates/effects/src/bake.rs @@ -2,7 +2,8 @@ //! //! SVG, PDF and the clipboard have no notion of Illustrator's live effects, so exporters receive a //! copy of the document in which every geometry effect — object level, per fill/stroke and -//! Effect → Pathfinder — is evaluated exactly like the renderer does. Raster effects (shadows, +//! Effect → Pathfinder, and those on type, images, symbol instances and live objects (through their +//! outlines, [`reshape`]) — is evaluated exactly like the renderer does. Raster effects (shadows, //! glows, blur) stay on the objects for the exporter to translate (e.g. SVG filters). use std::sync::Arc; @@ -10,7 +11,9 @@ use std::sync::Arc; use vectorcraft_doc::{Appearance, AppearanceItem, Document, Node, NodeKind}; use vectorcraft_geom::{FillRule, PathData}; -use crate::{GeomContext, apply_geometry_with, has_geometry, has_pathfinder, is_geometry, is_pathfinder, pathfinder_children}; +use crate::{ + GeomContext, apply_geometry_with, has_geometry, has_pathfinder, is_geometry, is_pathfinder, needs_outline, pathfinder_children, reshape, +}; fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] { match item { @@ -67,6 +70,15 @@ fn bake_node(d: &mut Document, n: &Node) -> Option { if !needs_bake(n) { return None; } + // Type, images, symbol instances and live objects: reshaped through their outlines. + if needs_outline(n) && has_geometry(&n.appearance.effects) { + let symbol = match &n.kind { + NodeKind::SymbolInstance { symbol, .. } => d.symbols.iter().find(|s| s.name == *symbol).map(|s| s.art.clone()), + _ => None, + }; + let m = reshape(n, symbol.as_deref())?; + return Some(bake_node(d, &m).unwrap_or(m)); + } if let Some(result) = pathfinder_children(n, None) { let mut m = n.clone(); m.appearance.effects.retain(|e| !is_pathfinder(&e.id)); diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index 425d3167..1e91a4dd 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -12,6 +12,8 @@ //! renderer, the exporters and Outline Stroke. //! - [`effect_catalog`] lists every effect with its menu path, parameter documentation and the //! defaults of Illustrator's dialogs. Missing parameters always fall back to those defaults. +//! - [`reshape`] applies geometry effects to type, images, symbol instances and live objects +//! through their outlines. //! - [`clip_outline`] is the region a clip group clips to, shared by the renderer and the SVG and //! PDF writers. //! @@ -24,6 +26,7 @@ mod clip; mod distort; mod group; mod raster; +mod reshape; pub mod stroke; mod stylize; mod util; @@ -40,6 +43,7 @@ pub use bake::{bake_document, needs_bake}; pub use clip::clip_outline; pub use group::{OutlineHook, PATHFINDER_EFFECTS, has_pathfinder, is_pathfinder, pathfinder_children}; pub use raster::{RasterFx, outset, raster_effects}; +pub use reshape::{needs_outline, outline_art, outline_text, reshape}; pub use warp::{WarpStyle, warp_point}; /// Catalogue entry for one effect. @@ -312,7 +316,7 @@ pub fn apply_geometry_bez(effects: &[Effect], path: &BezPath, bounds: Rect, ctx: apply_geometry_with(effects, &PathData::from_bezpath(path), bounds, ctx).to_bezpath() } -fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -> PathData { +pub(crate) fn apply_one(id: &str, p: &Value, path: &PathData, b: Rect, ctx: &GeomContext) -> PathData { use util::*; match id { "distort.freeDistort" => distort::free_distort(path, b, p), diff --git a/crates/effects/src/reshape.rs b/crates/effects/src/reshape.rs new file mode 100644 index 00000000..a178a91f --- /dev/null +++ b/crates/effects/src/reshape.rs @@ -0,0 +1,182 @@ +//! Object-level effects on objects that aren't paths: type, placed images, symbol instances and +//! live objects (blends, envelopes, meshes, repeats). +//! +//! Geometry effects reach them through outlines: [`outline_art`] turns the object into plain paths +//! (glyph outlines, the evaluated live object, the symbol's art) and [`reshape`] runs each effect +//! over all of them as one piece, with the whole art's bounds as the effect's reference box, so a +//! warp bends a line of type as a whole. An image is clipped by its reshaped frame. Raster effects +//! (shadows, glows, blur, feather) stay on the result for the renderer and exporters. + +use std::sync::Arc; + +use vectorcraft_doc::{Appearance, AppearanceItem, FillLayer, Node, NodeKind, StrokeLayer}; +use vectorcraft_geom::{FillRule, PathData, Rect, shapes}; + +use crate::{GeomContext, apply_one, is_geometry, merged_params}; + +/// Can object-level effects apply to `n` only through [`outline_art`] / an offscreen layer (it is +/// neither a path nor a container)? +pub fn needs_outline(n: &Node) -> bool { + matches!( + n.kind, + NodeKind::Text(_) + | NodeKind::Image(_) + | NodeKind::SymbolInstance { .. } + | NodeKind::Blend { .. } + | NodeKind::Envelope { .. } + | NodeKind::Mesh(_) + | NodeKind::Repeat(_) + ) +} + +/// Type as glyph outlines in document space: one path per run with the run's fill and stroke, +/// then (when the object has its own fills/strokes) the whole outline with the object's stack, +/// painted over the characters as the renderer does. +pub fn outline_text(n: &Node) -> Option { + let NodeKind::Text(t) = &n.kind else { return None }; + let layout = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t); + let mut runs = vec![kurbo::BezPath::new(); t.runs.len()]; + let mut all = kurbo::BezPath::new(); + for g in &layout.glyphs { + if let Some(r) = runs.get_mut(g.run) { + r.extend(g.outline.iter()); + } + all.extend(g.outline.iter()); + } + let path = |bp: &kurbo::BezPath| PathData::from_bezpath(bp).transformed(t.xf); + let mut children: Vec> = runs + .iter() + .zip(&t.runs) + .filter(|(bp, _)| !bp.elements().is_empty()) + .map(|(bp, run)| { + let mut ap = Appearance { items: vec![AppearanceItem::Fill(FillLayer::new(run.style.fill.clone()))], effects: vec![] }; + if !run.style.stroke.is_none() && run.style.stroke_width > 0.0 { + ap.items.push(AppearanceItem::Stroke(StrokeLayer::new(run.style.stroke.clone(), run.style.stroke_width))); + } + Arc::new(Node::path(n.id, path(bp), ap)) + }) + .collect(); + if !n.appearance.items.is_empty() && !all.elements().is_empty() { + let ap = Appearance { items: n.appearance.items.clone(), effects: vec![] }; + children.push(Arc::new(Node::path(n.id, path(&all), ap))); + } + Some(Node::group(n.id, children)) +} + +/// `n` as plain art that geometry effects can reshape, without its transparency and object +/// effects: type outlined ([`outline_text`]), live objects evaluated (type inside them outlined), +/// a symbol instance as `symbol_art` (the symbol's art, stained as the caller draws it) placed by +/// the instance's transform. `None` for other kinds (images, paths, containers). +pub fn outline_art(n: &Node, symbol_art: Option<&Node>) -> Option { + let hook: &dyn Fn(&Node) -> Option = &outline_text; + let mut art = match &n.kind { + NodeKind::Text(_) => outline_text(n)?, + NodeKind::SymbolInstance { xf, .. } => { + let mut a = symbol_art?.clone(); + a.transform(*xf, false); + Node::group(n.id, vec![Arc::new(a)]) + } + _ if vectorcraft_doc::live::is_live(n) => vectorcraft_doc::live::expand_deep(n, Some(hook)), + _ => return None, + }; + outline_nested_text(&mut art); + art.opacity = 1.0; + art.blend = Default::default(); + art.isolate = false; + art.knockout = false; + art.mask = None; + art.appearance.effects.clear(); + Some(art) +} + +/// Replace type anywhere inside `n` by its outlines. +fn outline_nested_text(n: &mut Node) { + let Some(ch) = n.children_mut() else { return }; + for c in ch.iter_mut() { + if let Some(o) = outline_text(c) { + *c = Arc::new(o); + } else if c.children().is_some() { + outline_nested_text(Arc::make_mut(c)); + } + } +} + +/// `n` (see [`needs_outline`]) with its object-level geometry effects applied: its [`outline_art`] +/// reshaped as one piece, or for an image the image clipped by its reshaped frame. The result is +/// a group with `n`'s id, name, transparency, opacity mask and remaining (raster) effects; `None` +/// when `n` has no visible geometry effect or no art (a symbol instance without `symbol_art`). +pub fn reshape(n: &Node, symbol_art: Option<&Node>) -> Option { + let fx: Vec<_> = n.appearance.effects.iter().filter(|e| e.visible && is_geometry(&e.id)).collect(); + if fx.is_empty() || !needs_outline(n) { + return None; + } + let mut art = match &n.kind { + NodeKind::Image(im) => { + let frame = shapes::rectangle(Rect::new(0.0, 0.0, im.width as f64, im.height as f64)).transformed(im.xf); + let mut clip = Node::path(n.id, frame, Appearance::default()); + if let NodeKind::Path { clipping, .. } = &mut clip.kind { + *clipping = true; + } + let image = Node { appearance: Appearance::default(), ..Node::new(n.id, n.kind.clone()) }; + Node::new(n.id, NodeKind::Group { children: vec![Arc::new(clip), Arc::new(image)], clip: true }) + } + _ => outline_art(n, symbol_art)?, + }; + for e in fx { + // Every leaf takes the effect with the whole art's bounds as its reference box. + let Some(bounds) = art.geometric_bounds() else { break }; + let params = merged_params(&e.id, &e.params); + for_each_leaf(&mut art, &mut |leaf| { + let Some((path, rule)) = leaf_geometry(leaf) else { return }; + let w = leaf.appearance.stroke_width(); + let ctx = GeomContext { stroke_width: if w > 0.0 { w } else { 1.0 } }; + let out = apply_one(&e.id, ¶ms, &path, bounds, &ctx); + set_geometry(leaf, out, rule); + }); + } + // The art is a group with `n`'s id (a clip group for an image). + let mut out = art; + out.name = n.name.clone(); + out.opacity = n.opacity; + out.blend = n.blend; + out.isolate = n.isolate; + out.knockout = n.knockout; + out.mask = n.mask.clone(); + out.appearance.effects = n.appearance.effects.iter().filter(|e| !is_geometry(&e.id)).cloned().collect(); + Some(out) +} + +/// Run `f` on every path and compound path under `n` (guides skipped). +fn for_each_leaf(n: &mut Node, f: &mut dyn FnMut(&mut Node)) { + match &mut n.kind { + NodeKind::Path { guide: false, .. } | NodeKind::Compound { .. } => f(n), + NodeKind::Group { children, .. } | NodeKind::Layer { children, .. } => { + for c in children.iter_mut() { + for_each_leaf(Arc::make_mut(c), f); + } + } + _ => {} + } +} + +fn leaf_geometry(n: &Node) -> Option<(PathData, FillRule)> { + match &n.kind { + NodeKind::Path { path, rule, .. } => Some((path.clone(), *rule)), + NodeKind::Compound { children, rule } => { + Some((PathData::new(children.iter().filter_map(|c| c.path_data()).flat_map(|p| p.subpaths.iter().cloned()).collect()), *rule)) + } + _ => None, + } +} + +/// Replace the geometry of path or compound `n` (a compound when `path` has several subpaths). +fn set_geometry(n: &mut Node, path: PathData, rule: FillRule) { + let clipping = matches!(n.kind, NodeKind::Path { clipping: true, .. }); + n.kind = if path.subpaths.len() > 1 { + let id = n.id; + let children = path.subpaths.into_iter().map(|sp| Arc::new(Node::path(id, PathData::single(sp), Appearance::default()))).collect(); + NodeKind::Compound { children, rule } + } else { + NodeKind::Path { path, rule, live: None, clipping, guide: false } + }; +} diff --git a/crates/pdf/src/export.rs b/crates/pdf/src/export.rs index 0d34a750..35c9e444 100644 --- a/crates/pdf/src/export.rs +++ b/crates/pdf/src/export.rs @@ -227,6 +227,13 @@ impl Exporter<'_> { SepColor::new(q(tint), SeparationSpace::new(SeparationColorant::Custom(name), alt)).into() } + /// Warn when `effects` (an object's, a fill's or a stroke's) has a visible raster effect. + fn warn_raster(&mut self, effects: &[vectorcraft_doc::Effect]) { + if effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) { + self.warn("raster effects (shadows, glows, blur, feather) are not exported to PDF yet"); + } + } + fn warn(&mut self, w: impl Into) { let w = w.into(); if !self.warnings.contains(&w) { @@ -360,9 +367,7 @@ impl Exporter<'_> { None if !n.is_container() => return, _ => {} } - if n.appearance.effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) { - self.warn("raster effects (shadows, glows, blur, feather) are not exported to PDF yet"); - } + self.warn_raster(&n.appearance.effects); let container = n.is_container() && !matches!(n.kind, NodeKind::Compound { .. }); let mut pushes = 0; if n.blend != BlendMode::Normal { @@ -449,9 +454,7 @@ impl Exporter<'_> { if !fl.visible || fl.paint.is_none() { continue; } - if fl.effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) { - self.warn("raster effects (shadows, glows, blur, feather) are not exported to PDF yet"); - } + self.warn_raster(&fl.effects); // Pattern fills: the tile instances covering the shape, clipped to it. let doc = self.doc; if let Paint::Pattern { pattern, xf } = &fl.paint @@ -486,6 +489,7 @@ impl Exporter<'_> { if !st.visible || st.paint.is_none() || st.width <= 0.0 { continue; } + self.warn_raster(&st.effects); self.stroke(s, bp, &path, r, st, bounds); } } diff --git a/crates/pdf/src/lib.rs b/crates/pdf/src/lib.rs index 680c0076..4a45d350 100644 --- a/crates/pdf/src/lib.rs +++ b/crates/pdf/src/lib.rs @@ -106,4 +106,6 @@ pub enum PdfError { #[cfg(test)] mod tests; #[cfg(test)] +mod tests_fx; +#[cfg(test)] mod tests_stroke; diff --git a/crates/pdf/src/tests_fx.rs b/crates/pdf/src/tests_fx.rs new file mode 100644 index 00000000..cd73c01b --- /dev/null +++ b/crates/pdf/src/tests_fx.rs @@ -0,0 +1,29 @@ +//! Raster effects in PDF export: not written yet, so a fill's, a stroke's or the object's own +//! raster effect is reported. + +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, Document, Effect, Node, NodeId}; +use vectorcraft_geom::{Rect, shapes}; + +use crate::*; + +fn warnings(item: Option) -> Vec { + let mut d = Document::new(100.0, 100.0); + let mut n = Node::path( + NodeId(0), + shapes::rectangle(Rect::new(20.0, 20.0, 80.0, 80.0)), + Appearance::basic(Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 2.0), + ); + n.appearance.effects_mut(item).unwrap().push(Effect { id: "stylize.outerGlow".into(), params: Default::default(), visible: true }); + n.id = d.alloc_id(); + let layer = d.default_layer().unwrap(); + d.insert(Some(layer), 0, n).unwrap(); + export_with_report(&d, &PdfOptions::default()).unwrap().warnings +} + +#[test] +fn raster_effects_warn_on_fills_strokes_and_objects() { + for item in [None, Some(0), Some(1)] { + assert!(warnings(item).iter().any(|w| w.starts_with("raster effects")), "{item:?}"); + } +} diff --git a/crates/render/src/fx.rs b/crates/render/src/fx.rs index c0d3f4af..d0364732 100644 --- a/crates/render/src/fx.rs +++ b/crates/render/src/fx.rs @@ -12,10 +12,16 @@ //! - Feather: like Gaussian Blur but clipped to the shape, so the edge fades inward; //! - Inner Glow: a blurred inverse silhouette (Edge) or the blurred silhouette (Center), clipped //! to the shape and painted above it. +//! +//! The same applies to one fill or stroke's own raster effects (around that item alone) and to +//! type, images, symbol instances and live objects ([`Renderer::draw_object_fx`]): their art is +//! drawn offscreen in the filter layers, reshaped first by their geometry effects. + +use std::sync::Arc; use vectorcraft_doc::{AppearanceItem, Effect, Node, NodeKind}; use vectorcraft_effects::{self as effects, GeomContext, RasterFx}; -use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape}; +use vectorcraft_geom::{Affine, BezPath, FillRule, Rect, Shape, Vec2}; use vello_common::filter_effects::{EdgeMode, Filter, FilterPrimitive}; use vello_cpu::RenderContext; use vello_cpu::peniko; @@ -38,6 +44,30 @@ pub(crate) fn has_fx(n: &Node) -> bool { }) } +/// Does `n` carry visible object-level effects that only apply through its art (type, images, +/// symbol instances, live objects; see [`Renderer::draw_object_fx`])? +pub(crate) fn has_object_fx(n: &Node) -> bool { + effects::needs_outline(n) && visible(&n.appearance.effects) +} + +/// What [`Renderer::raster_fx`] paints effects around. +struct Content<'a> { + /// The object the effects belong to (cached shadows are checked against it). + node: &'a Node, + /// First shadow-cache slot of these effects (`None`: don't cache). + cache: Option, + /// Document-space bounds of the painted content. + reach: Rect, + /// Its silhouette: Feather and Inner Glow clip to it (without one, Feather only blurs and + /// Inner Glow is skipped). + outline: Option<(&'a BezPath, FillRule)>, +} + +/// `f` with the content moved by (`dx`, `dy`) document units (a shadow's offset). +fn shifted<'a>(f: &Frame<'a>, dx: f64, dy: f64) -> Frame<'a> { + Frame { view: f.view * Affine::translate((dx, dy)), visible: f.visible - Vec2::new(dx, dy), ..*f } +} + fn node_bezpath(n: &Node) -> Option { match &n.kind { NodeKind::Path { path, .. } => Some(path.to_bezpath()), @@ -106,6 +136,12 @@ pub(crate) fn cull_bounds(n: &Node) -> Option { children.iter().fold(None, |acc, c| vectorcraft_geom::union_opt(acc, cull_bounds(c))) } _ if has_fx(n) => visual_bounds(n), + // The reshaped art (a symbol's art needs the document: its instance box stands in). + _ if has_object_fx(n) => { + let b = effects::reshape(n, None).and_then(|r| r.visual_bounds()).or_else(|| n.visual_bounds())?; + let o = effects::outset(&n.appearance.effects); + Some(b.inflate(o, o)) + } _ => n.visual_bounds(), } } @@ -209,7 +245,7 @@ fn blur_alpha(a: &mut Vec, w: usize, h: usize, sigma: f64) { } impl Renderer { - /// Drop shadow / outer glow `fx` (index `i` in the object's raster effects) from a cached raster: + /// Drop shadow / outer glow `fx` (shadow-cache slot `i` of `n`) from a cached raster: /// the silhouette is rendered once into a small crop, blurred and tinted on the CPU, and reused /// while the object and the zoom/rotation are unchanged (pans only move it). Returns false when /// the effect isn't a shadow/glow or the raster would be too large (caller falls back). @@ -221,10 +257,8 @@ impl Renderer { n: &Node, i: usize, fx: &RasterFx, - g: &BezPath, - rule: FillRule, - gctx: &GeomContext, reach: Rect, + paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), ) -> bool { let (mode, opacity, dx, dy, blur, color) = match fx { RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => (*mode, *opacity, *dx, *dy, *blur, *color), @@ -244,7 +278,7 @@ impl Renderer { } None => { let spread = blur.max(0.0) * 1.5 / f.px + 2.0; - let reach = reach + vectorcraft_geom::Vec2::new(dx, dy); + let reach = reach + Vec2::new(dx, dy); let r = f.view.transform_rect_bbox(reach).inflate(spread, spread); let (x0, y0) = (r.x0.floor(), r.y0.floor()); let (w, h) = (r.x1.ceil() - x0, r.y1.ceil() - y0); @@ -254,8 +288,8 @@ impl Renderer { let (w, h) = (w as u16, h as u16); // Silhouette (the object's painted alpha), offset by the shadow distance. let mut off = crate::single_threaded_context(w, h); - let shifted = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view * Affine::translate((dx, dy)), ..*f }; - self.paint_items(&mut off, &shifted, n, g, rule, gctx); + let crop = Frame { mt: false, view: Affine::translate((-x0, -y0)) * f.view, ..*f }; + paint(self, &mut off, &shifted(&crop, dx, dy)); off.flush(); let mut pm = vello_cpu::Pixmap::new(w, h); off.render(&mut pm, &mut self.resources); @@ -303,33 +337,118 @@ impl Renderer { // Document-space reach of the painted geometry (strokes, arrowheads…). let outset = n.appearance.outset(); let reach = g.bounding_box().inflate(outset, outset); - // Below the object: shadows and outer glows. + let content = Content { node: n, cache: Some(0), reach, outline: Some((&g, rule)) }; + self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_items(c, fr, n, &g, rule, &gctx)); + ctx.set_transform(Affine::IDENTITY); + } + + /// Draw type, an image, a symbol instance or a live object with object-level effects + /// ([`has_object_fx`]) inside its transparency group: its art (reshaped by the geometry + /// effects, see [`effects::reshape`]) with the raster effects painted around it as for paths, + /// the art standing for the object's silhouette. + pub(crate) fn draw_object_fx(&mut self, ctx: &mut RenderContext, f: &Frame, a: &Arc, cache: bool) { + let layered = !f.opts.outline && (a.opacity < 1.0 || a.blend != vectorcraft_doc::color::BlendMode::Normal || a.isolate); + if layered { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(blend_mode(a.blend)), Some(a.opacity), None, None); + } + let art = self.fx_art(f.doc, a, cache); + let rfx = effects::raster_effects(&a.appearance.effects); + match cull_bounds(&art) { + Some(reach) if !f.opts.outline && !rfx.is_empty() => { + // Feather and Inner Glow clip to the art's outline. + let clipped = rfx.iter().any(|x| matches!(x, RasterFx::Feather { .. } | RasterFx::InnerGlow { .. })); + let region = if clipped { self.clip_of(&art) } else { None }; + // A symbol's art can change while the instance doesn't: no cached shadows. + let cache = (cache && !matches!(a.kind, NodeKind::SymbolInstance { .. })).then_some(0); + let content = Content { node: a, cache, reach, outline: region.as_deref().map(|r| (&r.0, r.1)) }; + self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.draw_node(c, fr, &art, true)); + ctx.set_transform(Affine::IDENTITY); + } + _ => self.draw_node(ctx, f, &art, true), + } + if layered { + ctx.pop_layer(); + } + self.stats.drawn += 1; + } + + /// The art [`Self::draw_object_fx`] draws for `a` (cached by `Arc` identity when `cache`): + /// reshaped by its geometry effects, else type and images as themselves and the others as + /// their evaluated art; without the object's transparency and effects. + fn fx_art(&mut self, doc: &vectorcraft_doc::Document, a: &Arc, cache: bool) -> Arc { + let key = Arc::as_ptr(a) as usize; + if cache + && let Some((node, art)) = self.fx_arts.get(&key) + && Arc::ptr_eq(node, a) + { + return art.clone(); + } + let symbol = match &a.kind { + NodeKind::SymbolInstance { symbol, .. } => { + doc.symbols.iter().find(|s| s.name == *symbol).map(|s| crate::brush_fx::instance_art(&s.art, a)) + } + _ => None, + }; + let art = match effects::reshape(a, symbol.as_ref()) { + Some(r) => r, + None if matches!(a.kind, NodeKind::Text(_) | NodeKind::Image(_)) => (**a).clone(), + None => effects::outline_art(a, symbol.as_ref()).unwrap_or_else(|| Node::group(a.id, vec![])), + }; + let art = Arc::new(Node { + opacity: 1.0, + blend: Default::default(), + isolate: false, + knockout: false, + mask: None, + appearance: vectorcraft_doc::Appearance { effects: vec![], ..art.appearance }, + ..art + }); + if cache { + if self.fx_arts.len() > 1024 { + self.fx_arts.clear(); + } + self.fx_arts.insert(key, (a.clone(), art.clone())); + } + art + } + + /// Paint `content` with raster effects `rfx` (see the module docs): shadows and outer glows + /// below it, the content blurred or feathered, inner glows above it (clipped to its outline). + /// `paint` draws the content in the frame it is given. + fn raster_fx( + &mut self, + ctx: &mut RenderContext, + f: &Frame, + content: &Content, + rfx: &[RasterFx], + paint: &mut dyn FnMut(&mut Self, &mut RenderContext, &Frame), + ) { + let reach = content.reach; + // Below the content: shadows and outer glows. for (i, fx) in rfx.iter().enumerate().filter(|(_, x)| x.is_below()) { - if self.draw_shadow_cached(ctx, f, n, i, fx, &g, rule, &gctx, reach) { + if let Some(slot) = content.cache + && self.draw_shadow_cached(ctx, f, content.node, slot + i, fx, reach, paint) + { continue; } - // The offset is applied to the geometry rather than in the filter: vello_cpu drops - // layer content that lies entirely outside the viewport before filtering. - let (mode, opacity, dx, dy, blur, filter) = match fx { - RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => { - (*mode, *opacity, *dx, *dy, *blur, shadow_filter(0.0, 0.0, *blur, pcolor(color))) - } - RasterFx::OuterGlow { mode, opacity, blur, color } => { - (*mode, *opacity, 0.0, 0.0, *blur, shadow_filter(0.0, 0.0, *blur, pcolor(color))) - } + let (mode, opacity, dx, dy, blur, color) = match fx { + RasterFx::DropShadow { mode, opacity, dx, dy, blur, color } => (*mode, *opacity, *dx, *dy, *blur, *color), + RasterFx::OuterGlow { mode, opacity, blur, color } => (*mode, *opacity, 0.0, 0.0, *blur, *color), _ => continue, }; - let mut gs = g.clone(); - gs.apply_affine(Affine::translate((dx, dy))); - let reach = reach + vectorcraft_geom::Vec2::new(dx, dy); + let filter = shadow_filter(0.0, 0.0, blur, pcolor(&color)); + // The offset moves the content rather than the filter: vello_cpu drops layer content + // that lies entirely outside the viewport before filtering. + let reach = reach + Vec2::new(dx, dy); self.with_filters(ctx, f, reach, blur, Some((mode, opacity)), |r, c, fr, comp| { c.set_transform(fr.view); c.push_layer(None, comp.map(|m| blend_mode(m.0)), comp.map(|m| m.1), None, Some(filter)); - r.paint_items(c, fr, n, &gs, rule, &gctx); + paint(r, c, &shifted(fr, dx, dy)); c.pop_layer(); }); } - // The object itself (blurred / feathered). + // The content itself (blurred / feathered). let blur: f64 = rfx .iter() .map(|fx| match fx { @@ -340,14 +459,17 @@ impl Renderer { if blur > 0.0 { self.with_filters(ctx, f, reach, blur, None, |r, c, fr, _| { let mut layers = 0; - for fx in &rfx { + for fx in rfx { match fx { RasterFx::Feather { radius } if *radius > 0.0 => { c.set_transform(fr.view); - c.set_fill_rule(fill_rule(rule)); - c.push_clip_layer(&g); + if let Some((g, rule)) = content.outline { + c.set_fill_rule(fill_rule(rule)); + c.push_clip_layer(g); + layers += 1; + } c.push_layer(None, None, None, None, Some(blur_filter(radius / 2.0))); - layers += 2; + layers += 1; } RasterFx::GaussianBlur { radius } if *radius > 0.0 => { c.set_transform(fr.view); @@ -357,33 +479,34 @@ impl Renderer { _ => {} } } - r.paint_items(c, fr, n, &g, rule, &gctx); + paint(r, c, fr); for _ in 0..layers { c.pop_layer(); } }); } else { - self.paint_items(ctx, f, n, &g, rule, &gctx); + paint(self, ctx, f); } - // Above the object: inner glows, clipped to the shape. - for fx in &rfx { + // Above the content: inner glows, clipped to its outline. + let Some((g, rule)) = content.outline else { return }; + for fx in rfx { let RasterFx::InnerGlow { mode, opacity, blur, color, center } = fx else { continue }; let filter = shadow_filter(0.0, 0.0, *blur, pcolor(color)); self.with_filters(ctx, f, g.bounding_box(), *blur, Some((*mode, *opacity)), |_, c, fr, comp| { // The blend layer goes outside the clip: a clip layer is isolated, so a blend - // inside it would mix with nothing instead of the object below. + // inside it would mix with nothing instead of the content below. if let Some((m, o)) = comp { c.set_transform(Affine::IDENTITY); c.push_layer(None, Some(blend_mode(m)), Some(o), None, None); } c.set_transform(fr.view); c.set_fill_rule(fill_rule(rule)); - c.push_clip_layer(&g); + c.push_clip_layer(g); c.push_layer(None, None, None, None, Some(filter)); c.set_paint(peniko::Color::BLACK); if *center { c.set_fill_rule(fill_rule(rule)); - c.fill_path(&g); + c.fill_path(g); } else { // Everything outside the shape, so the glow bleeds in from the edges. let pad = blur * 2.0 + 4.0 * fr.px; @@ -399,7 +522,6 @@ impl Renderer { } }); } - ctx.set_transform(Affine::IDENTITY); } /// Run `draw`, which pushes filter layers. Single-threaded contexts draw directly and `draw` @@ -448,41 +570,66 @@ impl Renderer { ctx.set_transform(Affine::IDENTITY); } - /// Paint the fills and strokes of `n` on geometry `g`, applying per-item geometry effects. + /// Paint the fills and strokes of `n` on geometry `g`, applying per-item geometry effects and + /// painting per-item raster effects around that item alone. fn paint_items(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, g: &BezPath, rule: FillRule, gctx: &GeomContext) { let bounds = g.bounding_box(); - for item in &n.appearance.items { + for (j, item) in n.appearance.items.iter().enumerate() { + if !item.visible() || item.paint().is_none() { + continue; + } let ig = apply(item_effects(item), g, gctx); let ib = if effects::has_geometry(item_effects(item)) && !ig.elements().is_empty() { ig.bounding_box() } else { bounds }; - match item { - AppearanceItem::Fill(fl) => { - if !fl.visible || fl.paint.is_none() { - continue; - } - let layered = fl.opacity < 1.0 || fl.blend != vectorcraft_doc::color::BlendMode::Normal; - if layered { - ctx.set_transform(Affine::IDENTITY); - ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None); - } - ctx.set_transform(f.view); - if paint::set_paint(ctx, &fl.paint, ib, f.doc) { - ctx.set_fill_rule(fill_rule(rule)); - ctx.fill_path(&ig); - } - if layered { - ctx.pop_layer(); - } - } + let rfx = effects::raster_effects(item_effects(item)); + if rfx.is_empty() { + self.paint_item(ctx, f, n, item, &ig, rule, ib); + continue; + } + // The item's silhouette: its fill area, or the outline of its stroke. + let outline = match item { + AppearanceItem::Fill(_) => ig.clone(), AppearanceItem::Stroke(st) => { - if !st.visible || st.paint.is_none() || st.width <= 0.0 { - continue; - } - // Brushed strokes keep their brush art under effects (e.g. a glowing scatter brush). - if st.brush.is_some() && self.draw_brush(ctx, f, n, &ig, st) { - continue; - } - self.draw_stroke(ctx, f, &ig, rule, st, ib); + let w = effects::stroke::aligned_width(st, effects::stroke::is_closed(&ig)); + effects::stroke::line_outline(&effects::stroke::stroke_pieces(&ig, st).line, st, w, f.px * 0.25) } + }; + let outset = n.appearance.outset(); + let orule = if item.is_fill() { rule } else { FillRule::NonZero }; + // Each item's shadows get their own cache slots. + let content = Content { node: n, cache: Some((j + 1) << 8), reach: ib.inflate(outset, outset), outline: Some((&outline, orule)) }; + self.raster_fx(ctx, f, &content, &rfx, &mut |r, c, fr| r.paint_item(c, fr, n, item, &ig, rule, ib)); + ctx.set_transform(Affine::IDENTITY); + } + } + + /// Paint one fill or stroke of `n` on its geometry `ig` (bounds `ib`). + #[allow(clippy::too_many_arguments)] + fn paint_item(&mut self, ctx: &mut RenderContext, f: &Frame, n: &Node, item: &AppearanceItem, ig: &BezPath, rule: FillRule, ib: Rect) { + match item { + AppearanceItem::Fill(fl) => { + let layered = fl.opacity < 1.0 || fl.blend != vectorcraft_doc::color::BlendMode::Normal; + if layered { + ctx.set_transform(Affine::IDENTITY); + ctx.push_layer(None, Some(blend_mode(fl.blend)), Some(fl.opacity), None, None); + } + ctx.set_transform(f.view); + if paint::set_paint(ctx, &fl.paint, ib, f.doc) { + ctx.set_fill_rule(fill_rule(rule)); + ctx.fill_path(ig); + } + if layered { + ctx.pop_layer(); + } + } + AppearanceItem::Stroke(st) => { + if st.width <= 0.0 { + return; + } + // Brushed strokes keep their brush art under effects (e.g. a glowing scatter brush). + if st.brush.is_some() && self.draw_brush(ctx, f, n, ig, st) { + return; + } + self.draw_stroke(ctx, f, ig, rule, st, ib); } } } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b07e703c..17c6ebc9 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -230,6 +230,8 @@ pub struct Renderer { live: live::LiveCache, /// Blurred, tinted drop shadow / outer glow rasters per object and effect (see `fx`). shadows: PtrMap<(usize, usize), fx::ShadowEntry>, + /// The art drawn for objects with effects that apply through it (see `fx::has_object_fx`). + fx_arts: PtrMap, Arc)>, } #[derive(Clone, Copy, Debug, Default)] @@ -277,6 +279,7 @@ impl Renderer { brushes: Default::default(), shadows: PtrMap::default(), live: live::LiveCache::default(), + fx_arts: PtrMap::default(), } } @@ -527,6 +530,9 @@ impl Renderer { self.stats.drawn += 1; return; } + if fx::has_object_fx(a) { + return self.draw_object_fx(ctx, f, a, true); + } if let NodeKind::Text(t) = &a.kind && a.opacity >= 1.0 && a.blend == vectorcraft_color::BlendMode::Normal @@ -558,6 +564,9 @@ impl Renderer { } else if !n.is_container() { return; } + if fx::has_object_fx(n) { + return self.draw_object_fx(ctx, f, &Arc::new(n.clone()), false); + } let outline = f.opts.outline; let mut layers = 0; let template_dim = matches!(n.kind, NodeKind::Layer { template: true, .. }) && f.opts.dim_templates; @@ -1012,3 +1021,5 @@ fn now() -> u64 { mod tests; #[cfg(test)] mod tests_clip; +#[cfg(test)] +mod tests_objectfx; diff --git a/crates/render/src/tests_objectfx.rs b/crates/render/src/tests_objectfx.rs new file mode 100644 index 00000000..eae7df07 --- /dev/null +++ b/crates/render/src/tests_objectfx.rs @@ -0,0 +1,152 @@ +//! Effects on every object kind: raster effects on type, images and symbol instances through an +//! offscreen layer of their art, geometry effects through their outlines, and one fill or +//! stroke's own raster effects. + +use std::sync::Arc; + +use serde_json::{Value, json}; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::text::{CharStyle, TextObject}; +use vectorcraft_doc::{Appearance, Effect, ImageBlob, ImageObject, Node, NodeKind, Symbol}; +use vectorcraft_geom::{Point, shapes}; + +use super::*; + +fn fx(id: &str, params: Value) -> Effect { + Effect { id: id.into(), params, visible: true } +} + +/// A 100×100 document with `n` (its id allocated) on the first layer. +fn doc_with(mut d: Document, mut n: Node) -> Document { + n.id = d.alloc_id(); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d +} + +fn render_threads(d: &Document, threads: u16) -> Rendered { + let mut r = Renderer::new(); + r.threads = threads; + r.render(d, 100, 100, Affine::IDENTITY, &RenderOptions { background: Some([255, 255, 255, 255]), ..Default::default() }) +} + +fn render(d: &Document) -> Rendered { + render_threads(d, 0) +} + +fn lum(p: [u8; 4]) -> u32 { + p[0] as u32 + p[1] as u32 + p[2] as u32 +} + +const WHITE: u32 = 765; + +/// A 30×30 red image placed at (20, 20). +fn image_doc(effects: Vec) -> Document { + let mut d = Document::new(100.0, 100.0); + let img = image::RgbaImage::from_pixel(30, 30, image::Rgba([255, 0, 0, 255])); + let mut png = vec![]; + img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap(); + d.images.insert("red".into(), ImageBlob { mime: "image/png".into(), bytes: Arc::new(png) }); + let mut n = Node::new( + NodeId(0), + NodeKind::Image(ImageObject { key: "red".into(), width: 30, height: 30, xf: Affine::translate((20.0, 20.0)), link: None }), + ); + n.appearance.effects = effects; + doc_with(d, n) +} + +/// An instance at (50, 50) of a symbol whose art is a black 20×20 square centred on its origin. +fn symbol_doc(effects: Vec) -> Document { + let mut d = Document::new(100.0, 100.0); + let art = Node::path( + d.alloc_id(), + shapes::rectangle(Rect::new(-10.0, -10.0, 10.0, 10.0)), + Appearance::basic(Paint::solid(Color::BLACK), Paint::None, 0.0), + ); + d.symbols.push(Symbol { name: "Square".into(), art: Arc::new(art) }); + let mut n = Node::new(NodeId(0), NodeKind::SymbolInstance { symbol: "Square".into(), xf: Affine::translate((50.0, 50.0)) }); + n.appearance.effects = effects; + doc_with(d, n) +} + +/// Black 40 pt "IIII" with its baseline at (5, 60). +fn text_doc(effects: Vec) -> Document { + let style = CharStyle { size: 40.0, fill: Paint::solid(Color::BLACK), ..Default::default() }; + let mut n = Node::new(NodeId(0), NodeKind::Text(Box::new(TextObject::point(Point::new(5.0, 60.0), "IIII", style)))); + n.appearance.effects = effects; + doc_with(Document::new(100.0, 100.0), n) +} + +/// Columns (x) holding any dark pixel. +fn inked_columns(img: &Rendered) -> Vec { + (0..100).filter(|x| (0..100).any(|y| lum(img.pixel(*x, y)) < 300)).collect() +} + +#[test] +fn a_shadow_on_an_image_paints_offset_pixels() { + let plain = render(&image_doc(vec![])); + assert_eq!(lum(plain.pixel(55, 55)), WHITE); + let d = image_doc(vec![fx("stylize.dropShadow", json!({"x": 10, "y": 10, "blur": 0, "opacity": 100, "mode": "normal"}))]); + let img = render(&d); + assert!(lum(img.pixel(55, 55)) < 100, "shadow below-right: {:?}", img.pixel(55, 55)); + assert_eq!(img.pixel(35, 35), [255, 0, 0, 255], "the image stays on top"); + assert_eq!(lum(img.pixel(15, 15)), WHITE); + // The multithreaded pipeline filters offscreen and matches. + let mt = render_threads(&d, 3); + let worst = img.pixels.iter().zip(&mt.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap(); + assert!(worst <= 4, "max channel difference {worst}"); +} + +#[test] +fn blur_on_a_symbol_instance_softens_its_edges() { + let plain = render(&symbol_doc(vec![])); + assert_eq!(lum(plain.pixel(38, 50)), WHITE); + assert_eq!(lum(plain.pixel(50, 50)), 0); + let img = render(&symbol_doc(vec![fx("blur.gaussian", json!({"radius": 8}))])); + let edge = lum(img.pixel(38, 50)); + assert!(edge > 0 && edge < WHITE, "the blur spreads past the edge: {edge}"); + assert!(lum(img.pixel(50, 50)) < 200, "the centre stays dark"); +} + +#[test] +fn geometry_effects_reshape_type_through_its_outlines() { + let plain = inked_columns(&render(&text_doc(vec![]))); + assert!(!plain.is_empty() && plain[0] < 30, "{plain:?}"); + // Transform moves the outlines as one piece. + let moved = inked_columns(&render(&text_doc(vec![fx("distort.transform", json!({"moveH": 30}))]))); + assert_eq!(moved.first().copied(), plain.first().map(|x| x + 30)); + // Roughen redraws the glyphs as rough outlines. + let d = text_doc(vec![fx("distort.roughen", json!({"size": 4, "relative": false, "detail": 30, "points": "corner"}))]); + let rough = render(&d); + let smooth = render(&text_doc(vec![])); + let changed = rough.pixels.chunks(4).zip(smooth.pixels.chunks(4)).filter(|(a, b)| a != b).count(); + assert!(changed > 100, "{changed} pixels changed"); + // The reshaped art is plain paths painted with the text's fill. + let n = &d.layers[0].children().unwrap()[0]; + let art = vectorcraft_effects::reshape(n, None).unwrap(); + let (mut text, mut black) = (0, 0); + art.walk(&mut |m| { + text += usize::from(matches!(m.kind, NodeKind::Text(_))); + black += usize::from(m.appearance.fill_paint() == Paint::solid(Color::BLACK)); + }); + assert!(text == 0 && black > 0, "{art:?}"); +} + +#[test] +fn a_strokes_own_glow_paints_around_that_stroke_only() { + let mut d = Document::new(100.0, 100.0); + let mut n = Node::path( + NodeId(0), + shapes::rectangle(Rect::new(30.0, 30.0, 70.0, 70.0)), + Appearance::basic(Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 2.0), + ); + n.appearance.items[1].effects_mut().push(fx("stylize.outerGlow", json!({"color": "#00ff00", "mode": "normal", "opacity": 100, "blur": 6}))); + d = doc_with(d, n); + let img = render(&d); + let g = img.pixel(27, 50); + assert!(g[1] > g[0] + 20 && g[1] > g[2] + 20, "green glow outside the stroke: {g:?}"); + assert_eq!(img.pixel(50, 50), [255, 255, 255, 255], "the fill has no glow"); + let mt = render_threads(&d, 3); + let worst = img.pixels.iter().zip(&mt.pixels).map(|(a, b)| a.abs_diff(*b)).max().unwrap(); + assert!(worst <= 4, "max channel difference {worst}"); +} diff --git a/crates/svg/src/export.rs b/crates/svg/src/export.rs index 60064d69..e5072b0b 100644 --- a/crates/svg/src/export.rs +++ b/crates/svg/src/export.rs @@ -446,6 +446,7 @@ impl Writer<'_> { let strokes = items.len() - fills; let simple = fills <= 1 && strokes <= 1 + && !items.iter().any(|i| has_raster(i.effects())) && items.iter().all(|i| match i { AppearanceItem::Fill(f) => f.blend == BlendMode::Normal, AppearanceItem::Stroke(s) => s.blend == BlendMode::Normal && s.align == StrokeAlign::Center, @@ -476,6 +477,8 @@ impl Writer<'_> { self.line(&format!("")); self.depth += 1; for it in items { + // The item's own raster effects filter that item alone. + let filters = self.open_filters(n, it.effects()); let mut p = Props::new(); match it { AppearanceItem::Fill(f) => { @@ -525,6 +528,7 @@ impl Writer<'_> { self.line(&format!("")); } } + self.close_filters(filters); } self.depth -= 1; self.line(""); @@ -573,7 +577,7 @@ impl Writer<'_> { if !n.visible { return; } - if n.appearance.effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) { + if has_raster(&n.appearance.effects) { return self.filtered(n); } if let Some(m) = n.mask.as_deref() @@ -679,15 +683,22 @@ impl Writer<'_> { } /// Raster effects (drop shadow, glows, Gaussian blur, feather) as SVG filters: one `` - /// per effect, the first effect innermost (Illustrator's stacking order). + /// per effect, the first effect innermost (the reference app's stacking order). fn filtered(&mut self, n: &Node) { - use vectorcraft_effects::RasterFx; - let fx = vectorcraft_effects::raster_effects(&n.appearance.effects); let mut inner = n.clone(); inner.appearance.effects.retain(|e| !vectorcraft_effects::is_raster(&e.id)); + let opened = self.open_filters(n, &n.appearance.effects); + self.node(&inner); + self.close_filters(opened); + } + + /// Open one `` per visible raster effect of `effects` (an object's, or one fill or + /// stroke's) on `n`, outermost last effect; returns how many to close. + fn open_filters(&mut self, n: &Node, effects: &[vectorcraft_doc::Effect]) -> usize { + use vectorcraft_effects::RasterFx; + let fx = vectorcraft_effects::raster_effects(effects); let reach: f64 = fx.iter().map(RasterFx::outset).sum::() + 2.0; let region = n.visual_bounds().map(|b| self.xf.transform_rect_bbox(b).inflate(reach, reach)); - let mut opened = 0; for f in fx.iter().rev() { let fid = self.fresh_id("filter"); let region_attr = match region { @@ -741,9 +752,11 @@ impl Writer<'_> { self.def(1, &format!("{body}")); self.line(&format!("")); self.depth += 1; - opened += 1; } - self.node(&inner); + fx.len() + } + + fn close_filters(&mut self, opened: usize) { for _ in 0..opened { self.depth -= 1; self.line(""); @@ -948,3 +961,8 @@ impl Writer<'_> { out } } + +/// Any visible raster effect (shadow, glow, blur, feather) in `effects`? +fn has_raster(effects: &[vectorcraft_doc::Effect]) -> bool { + effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) +} diff --git a/crates/svg/tests/effects_export.rs b/crates/svg/tests/effects_export.rs new file mode 100644 index 00000000..911c2342 --- /dev/null +++ b/crates/svg/tests/effects_export.rs @@ -0,0 +1,58 @@ +//! Live effects in SVG export: a fill or stroke's own raster effects become filters on that item's +//! element, and geometry effects on type are baked through its outlines. + +use serde_json::json; +use vectorcraft_color::{Color, Paint}; +use vectorcraft_doc::{Appearance, CharStyle, Document, Effect, Node, NodeId, NodeKind, TextObject}; +use vectorcraft_geom::{Point, Rect, shapes}; +use vectorcraft_svg::{ExportOptions, export}; + +fn doc_with(mut n: Node) -> Document { + let mut d = Document::new(200.0, 200.0); + n.id = d.alloc_id(); + let l = d.layers[0].id; + d.insert(Some(l), 0, n).unwrap(); + d +} + +fn fx(id: &str) -> Effect { + Effect { id: id.into(), params: json!({}), visible: true } +} + +#[test] +fn a_strokes_own_glow_filters_the_stroke_element_only() { + let mut n = Node::path( + NodeId(0), + shapes::rectangle(Rect::new(20.0, 20.0, 80.0, 80.0)), + Appearance::basic(Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 2.0), + ); + n.appearance.items[1].effects_mut().push(fx("stylize.outerGlow")); + let svg = export(&doc_with(n), &ExportOptions::default()); + assert_eq!(svg.matches("").map(|i| open + i).unwrap()]; + assert!(inside.contains("stroke=\"#000000\"") && !inside.contains("fill=\"#ffffff\""), "{inside}"); + assert!(svg[..open].contains("fill=\"#ffffff\""), "{svg}"); + // A hidden effect exports nothing. + let mut n = Node::path( + NodeId(0), + shapes::rectangle(Rect::new(20.0, 20.0, 80.0, 80.0)), + Appearance::basic(Paint::solid(Color::WHITE), Paint::solid(Color::BLACK), 2.0), + ); + n.appearance.items[1].effects_mut().push(Effect { visible: false, ..fx("stylize.outerGlow") }); + assert!(!export(&doc_with(n), &ExportOptions::default()).contains("