M4.3: live effects survive SVG, PDF and clipboard export (vectorcraft_effects::bake_document evaluates object, per-fill/stroke and Pathfinder effects on a copy; SVG raster effects become filters: drop shadow, outer/inner glow, Gaussian blur, feather)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-01 21:39:20 -04:00
parent 1c1fd0f139
commit 09fc2eedd9
9 changed files with 329 additions and 12 deletions

3
Cargo.lock generated
View File

@ -4186,6 +4186,7 @@ dependencies = [
"thiserror 2.0.21", "thiserror 2.0.21",
"vectorcraft-color", "vectorcraft-color",
"vectorcraft-doc", "vectorcraft-doc",
"vectorcraft-effects",
"vectorcraft-geom", "vectorcraft-geom",
"vectorcraft-text", "vectorcraft-text",
] ]
@ -4216,11 +4217,13 @@ dependencies = [
"image", "image",
"kurbo 0.13.1", "kurbo 0.13.1",
"proptest", "proptest",
"serde_json",
"svgtypes 0.16.1", "svgtypes 0.16.1",
"thiserror 2.0.21", "thiserror 2.0.21",
"usvg 0.48.1", "usvg 0.48.1",
"vectorcraft-color", "vectorcraft-color",
"vectorcraft-doc", "vectorcraft-doc",
"vectorcraft-effects",
"vectorcraft-geom", "vectorcraft-geom",
"vectorcraft-testkit", "vectorcraft-testkit",
] ]

View File

@ -32,14 +32,14 @@ _Last updated: 2026-10-01._
- **Tools:** - **Tools:**
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso. - **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso.
- **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join. - **Drawing:** Pen, Curvature, anchor tools, Pencil, Paintbrush, Blob Brush, Smooth, Path Eraser, Join.
- **Shapes:** all shape tools and the line, arc, spiral and grid tools. - **Shapes:** all shape tools (including Flare) and the line, arc, spiral and grid tools.
- **Cutting:** Eraser, Scissors, Knife. - **Cutting:** Eraser, Scissors, Knife.
- **Transform:** Rotate, Reflect, Scale, Shear, Free Transform (distort/perspective). - **Transform:** Rotate, Reflect, Scale, Shear, Reshape, Free Transform (distort/perspective).
- **Other:** Eyedropper, Gradient annotator, Artboard, Measure, Type, Hand, Zoom, Rotate View. - **Other:** Eyedropper, Gradient annotator, Artboard, Measure, Type, Hand, Zoom, Rotate View.
- **Drawing aids:** Smart Guides and snapping, and Draw Normal / Behind / Inside modes. - **Drawing aids:** Smart Guides and snapping, and Draw Normal / Behind / Inside modes.
- **Geometry and effects:** - **Geometry and effects:**
- Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below. - Pathfinder (10 exact curve booleans), Offset, Outline Stroke, Simplify, Clean Up, Split Into Grid, Divide Objects Below.
- Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, and raster drop shadow, glows and feather. - Live effects with previewing dialogs: Distort & Transform, Path, Convert to Shape, 15 Warp styles, Round Corners, Scribble, Effect → Pathfinder (all 10 operations, live on groups), and raster drop shadow, glows and feather. SVG and PDF export keep live effects (geometry baked; SVG raster effects as filters).
- **Type:** Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline. - **Type:** Character and Paragraph Styles (override-preserving redefine), Area Type Options (rows/columns/inset/first baseline), threaded text across any closed shapes, Fit Headline.
- **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks. - **Transparency:** opacity masks (clip/invert/disable/link), exported as SVG `<mask>` and PDF soft masks.
- **Advanced art:** live Blends (steps/distance/smooth colour, spine), Envelope Distort (warp/mesh/top object), Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror). - **Advanced art:** live Blends (steps/distance/smooth colour, spine), Envelope Distort (warp/mesh/top object), Gradient Mesh, Shape Builder, Live Paint, Image Trace (12 presets), pattern swatches with pattern editing mode, live Repeat (radial/grid/mirror).
@ -56,16 +56,16 @@ _Last updated: 2026-10-01._
|---|---|---|---| |---|---|---|---|
| M0 | Skeleton + vertical slice | ✅ done | — | | M0 | Skeleton + vertical slice | ✅ done | — |
| M1 | Selection, transform, layers, MCP | ✅ mostly done (rotated persistent bbox pending) | 3–5 | | M1 | Selection, transform, layers, MCP | ✅ mostly done (rotated persistent bbox pending) | 3–5 |
| M2 | Drawing tools + smart guides | ✅ mostly done (Shaper, Pen modifier nuances) | 5–10 | | M2 | Drawing tools + smart guides | ✅ mostly done (Flare, Reshape landed; Shaper, Pen modifier nuances) | 4–8 |
| M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | 🟡 panels done; opacity masks done (make/release, clip, invert, disable, link; render + SVG `<mask>` in/out + PDF soft mask); mask-editing mode, freeform gradients pending | 6–10 | | M3 | Paint & appearance (swatches, color, gradient, stroke, appearance, transparency, styles) | 🟡 panels done; opacity masks done (make/release, clip, invert, disable, link; render + SVG `<mask>` in/out + PDF soft mask); mask-editing mode, freeform gradients pending | 6–10 |
| M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟡 Export for Screens (PNG/JPG/WebP/SVG/PDF × scales) done; headless CLI/MCP export every format; system clipboard: copy puts SVG markup on it, paste takes SVG from other apps (Ctrl/Cmd+C/X/V now also work off macOS); PNG/PDF clipboard flavours, EPS/DXF pending | 8–12 | | M4 | Files (native, SVG, PDF, raster, Export for Screens, clipboard interop) | 🟡 Export for Screens (PNG/JPG/WebP/SVG/PDF × scales) done; headless CLI/MCP export every format; system clipboard: copy puts SVG markup on it, paste takes SVG from other apps (Ctrl/Cmd+C/X/V now also work off macOS); live effects now survive SVG/PDF/clipboard export (geometry baked, SVG filters for shadows/glows/blur/feather); PDF raster effects, PNG/PDF clipboard flavours, EPS/DXF pending | 7–11 |
| M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 10–20 | | M5 | Performance | 🟡 background render + caches + MT done; `vectorcraft-cli bench` and `vectorcraft-cli perf` (budget suite); file format v2 opens 3× faster (50k paths: 722 → 244 ms); raster effects (glows, shadows, blur, feather) no longer force the whole frame single-threaded (filtered offscreen per effect, verified equal to the single-threaded reference); effect-heavy demos need a clean-machine benchmark; dirty-region rendering, GPU backend spike pending | 10–20 |
| M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Shape Builder edge erase, large-offset bug open) | 3–6 | | M6 | Path operations (Pathfinder, Shape Builder, offset…) | ✅ mostly done (Shape Builder edge erase, large-offset bug open) | 3–6 |
| M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs done; OpenType/Tabs panels, text wrap, Find Font, spell check pending | 12–20 | | M7 | Type (point/area/path, editing, styles, OpenType, threading, glyphs) | 🟡 Character/Paragraph Styles, Area Type Options, threaded text, Fit Headline, Glyphs, OpenType panel, Find Font done; Tabs panel, text wrap, spell check, vertical type pending | 10–17 |
| M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass pending | 8–12 | | M8 | Transform & distort (Puppet Warp, Liquify tools, Envelopes, Blends, Perspective Grid) | 🟡 live Blends, Envelopes (warp/mesh/top object), Width tool, Liquify tools, Puppet Warp and Perspective Grid landed; fidelity pass pending | 8–12 |
| M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done; 3D pending | 25–40 | | M9 | Live effects (+ 3D & Materials) | 🟡 2D effects done incl. Effect → Pathfinder; SVG Filters, Document Raster Effects Settings, 3D pending | 24–38 |
| M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 8–15 | | M10 | Brushes, symbols, patterns, Repeat | 🟡 pattern swatches (5 tile types, Pattern Options, editing mode, SVG `<pattern>`/PDF export) and live Repeat (radial/grid/mirror) done; brushes/symbols in progress | 8–15 |
| M11 | Artboards & views (artboard panel/tool done; print tiling, multiple windows, presentation polish) | 🟡 | 10–15 | | M11 | Artboards & views (artboard panel/tool done, Trim View; print tiling, multiple windows, presentation polish) | 🟡 | 9–14 |
| M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview) done; Graphs pending | 12–20 | | M12 | Advanced color & art (CMYK/ICC, separations, Gradient Mesh, Live Paint, Image Trace, Graphs) | 🟡 Gradient Mesh, Live Paint, Image Trace (12 presets, 18 ms/1k² image), Recolor Artwork, colour management (ICC, soft proofing, separations preview) done; Graphs pending | 12–20 |
| M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 8–12 | | M13 | Automation (Actions ✅ record/playback, persisted; variables, scripting, batch) | 🟡 | 8–12 |
| M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces done; accessibility, Windows/Linux packaging pending | 12–20 | | M14 | 1.0 polish (preferences, shortcut editor, workspaces, accessibility, packaging for all OSes) | 🟡 Preferences, shortcut editor, workspaces done; accessibility, Windows/Linux packaging pending | 12–20 |

210
crates/effects/src/bake.rs Normal file
View File

@ -0,0 +1,210 @@
//! Baking live geometry effects for export.
//!
//! 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,
//! glows, blur) stay on the objects for the exporter to translate (e.g. SVG filters).
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};
fn item_effects(item: &AppearanceItem) -> &[vectorcraft_doc::Effect] {
match item {
AppearanceItem::Fill(f) => &f.effects,
AppearanceItem::Stroke(s) => &s.effects,
}
}
fn clear_item_effects(item: &mut AppearanceItem) {
match item {
AppearanceItem::Fill(f) => f.effects.retain(|e| !is_geometry(&e.id)),
AppearanceItem::Stroke(s) => s.effects.retain(|e| !is_geometry(&e.id)),
}
}
/// Does anything in `n`'s subtree need baking?
pub fn needs_bake(n: &Node) -> bool {
has_pathfinder(n)
|| has_geometry(&n.appearance.effects)
|| n.appearance.items.iter().any(|i| has_geometry(item_effects(i)))
|| n.children().is_some_and(|ch| ch.iter().any(|c| needs_bake(c)))
}
fn geometry(n: &Node) -> Option<(PathData, FillRule)> {
match &n.kind {
NodeKind::Path { path, rule, guide: false, .. } => 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,
}
}
fn apply(effects: &[vectorcraft_doc::Effect], path: &PathData, ctx: &GeomContext) -> PathData {
if !has_geometry(effects) || path.is_empty() {
return path.clone();
}
let b = vectorcraft_geom::Shape::bounding_box(&path.to_bezpath());
apply_geometry_with(effects, path, b, ctx)
}
/// A path node of `kind`'s flavour (compound when the result has several subpaths).
fn path_kind(d: &mut Document, path: PathData, rule: FillRule) -> NodeKind {
if path.subpaths.len() > 1 {
let children = path.subpaths.into_iter().map(|sp| Arc::new(Node::path(d.alloc_id(), PathData::single(sp), Appearance::default()))).collect();
NodeKind::Compound { children, rule }
} else {
NodeKind::Path { path, rule, live: None, clipping: false, guide: false }
}
}
/// `n` with its geometry effects evaluated (`None` = unchanged).
fn bake_node(d: &mut Document, n: &Node) -> Option<Node> {
if !needs_bake(n) {
return None;
}
if let Some(result) = pathfinder_children(n, None) {
let mut m = n.clone();
m.appearance.effects.retain(|e| !is_pathfinder(&e.id));
let children = result
.into_iter()
.map(|c| {
let mut c = Arc::unwrap_or_clone(c);
c.id = d.alloc_id();
Arc::new(bake_node(d, &c).unwrap_or(c))
})
.collect();
if let Some(ch) = m.children_mut() {
*ch = children;
}
return Some(m);
}
if let Some(ch) = n.children() {
if matches!(n.kind, NodeKind::Compound { .. })
&& (has_geometry(&n.appearance.effects) || n.appearance.items.iter().any(|i| has_geometry(item_effects(i))))
{
return bake_leaf(d, n);
}
let mut m = n.clone();
let baked: Vec<Arc<Node>> = ch.iter().map(|c| bake_node(d, c).map(Arc::new).unwrap_or_else(|| c.clone())).collect();
if let Some(ch) = m.children_mut() {
*ch = baked;
}
return Some(m);
}
bake_leaf(d, n)
}
fn bake_leaf(d: &mut Document, n: &Node) -> Option<Node> {
let (base, rule) = geometry(n)?;
let w = n.appearance.stroke_width();
let ctx = GeomContext { stroke_width: if w > 0.0 { w } else { 1.0 } };
let g = apply(&n.appearance.effects, &base, &ctx);
let mut m = n.clone();
m.appearance.effects.retain(|e| !is_geometry(&e.id));
if !n.appearance.items.iter().any(|i| has_geometry(item_effects(i))) {
m.kind = path_kind(d, g, rule);
return Some(m);
}
// Per-item geometry: one path per fill/stroke, grouped under the object's transparency and
// raster effects (what Expand Appearance produces).
let children = n
.appearance
.items
.iter()
.map(|item| {
let ig = apply(item_effects(item), &g, &ctx);
let mut it = item.clone();
clear_item_effects(&mut it);
let id = d.alloc_id();
let kind = path_kind(d, ig, rule);
Arc::new(Node {
id,
name: None,
appearance: Appearance { items: vec![it], effects: vec![] },
opacity: 1.0,
blend: Default::default(),
isolate: false,
knockout: false,
mask: None,
trace: None,
kind,
..n.clone()
})
})
.collect();
m.appearance.items.clear();
m.kind = NodeKind::Group { children, clip: false };
Some(m)
}
/// A copy of `doc` with every live geometry effect baked into plain paths, or `None` when the
/// document has none (export it as is).
pub fn bake_document(doc: &Document) -> Option<Document> {
if !doc.layers.iter().any(|l| needs_bake(l)) {
return None;
}
let mut d = doc.clone();
let layers = doc.layers.clone();
d.layers = layers.iter().map(|l| bake_node(&mut d, l).map(Arc::new).unwrap_or_else(|| l.clone())).collect();
Some(d)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use vectorcraft_doc::{Effect, NodeId};
use vectorcraft_geom::{Rect, shapes};
fn fx(id: &str, params: serde_json::Value) -> Effect {
Effect { id: id.into(), params, visible: true }
}
fn doc_with(n: Node) -> Document {
let mut d = Document::new(400.0, 400.0);
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
d
}
#[test]
fn plain_documents_are_not_copied() {
let mut d = Document::new(100.0, 100.0);
let id = d.alloc_id();
let d = doc_with(Node::path(id, shapes::rectangle(Rect::new(0.0, 0.0, 10.0, 10.0)), Appearance::default_art()));
assert!(bake_document(&d).is_none());
}
#[test]
fn object_effects_become_geometry_and_raster_effects_stay() {
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
n.appearance.effects.push(fx("path.offsetPath", json!({"offset": 10.0})));
n.appearance.effects.push(fx("stylize.dropShadow", json!({})));
let d = bake_document(&doc_with(n)).unwrap();
let m = d.node(NodeId(50)).unwrap();
let b = m.geometric_bounds().unwrap();
assert!((b.width() - 120.0).abs() < 0.5, "{b:?}");
assert_eq!(m.appearance.effects.len(), 1);
assert_eq!(m.appearance.effects[0].id, "stylize.dropShadow");
}
#[test]
fn item_effects_split_into_one_path_per_item() {
let mut n = Node::path(NodeId(50), shapes::rectangle(Rect::new(0.0, 0.0, 100.0, 100.0)), Appearance::default_art());
if let AppearanceItem::Stroke(s) = &mut n.appearance.items[1] {
s.effects.push(fx("path.offsetPath", json!({"offset": 5.0})));
}
let d = bake_document(&doc_with(n)).unwrap();
let m = d.node(NodeId(50)).unwrap();
let ch = m.children().unwrap();
assert_eq!(ch.len(), 2);
assert!((ch[0].geometric_bounds().unwrap().width() - 100.0).abs() < 1e-6);
assert!((ch[1].geometric_bounds().unwrap().width() - 110.0).abs() < 0.5);
assert!(ch[0].id != ch[1].id && ch[0].id != NodeId(50));
}
}

View File

@ -15,6 +15,7 @@
//! noise (`seed` parameter, default 0). //! noise (`seed` parameter, default 0).
#![forbid(unsafe_code)] #![forbid(unsafe_code)]
mod bake;
mod distort; mod distort;
mod group; mod group;
mod raster; mod raster;
@ -29,6 +30,7 @@ use serde_json::{Map, Value, json};
use vectorcraft_doc::Effect; use vectorcraft_doc::Effect;
use vectorcraft_geom::{BezPath, PathData, Rect}; use vectorcraft_geom::{BezPath, PathData, Rect};
pub use bake::{bake_document, needs_bake};
pub use group::{OutlineHook, PATHFINDER_EFFECTS, has_pathfinder, is_pathfinder, pathfinder_children}; pub use group::{OutlineHook, PATHFINDER_EFFECTS, has_pathfinder, is_pathfinder, pathfinder_children};
pub use raster::{RasterFx, outset, raster_effects}; pub use raster::{RasterFx, outset, raster_effects};
pub use warp::{WarpStyle, warp_point}; pub use warp::{WarpStyle, warp_point};

View File

@ -11,6 +11,7 @@ workspace = true
[dependencies] [dependencies]
vectorcraft-geom = { workspace = true } vectorcraft-geom = { workspace = true }
vectorcraft-doc = { workspace = true } vectorcraft-doc = { workspace = true }
vectorcraft-effects = { workspace = true }
vectorcraft-color = { workspace = true } vectorcraft-color = { workspace = true }
vectorcraft-text = { workspace = true } vectorcraft-text = { workspace = true }
kurbo = { workspace = true } kurbo = { workspace = true }

View File

@ -25,6 +25,9 @@ pub fn export(doc: &Document, opts: &PdfOptions) -> Result<Vec<u8>, PdfError> {
/// Like [`export`], also returning warnings about approximated or dropped features. /// Like [`export`], also returning warnings about approximated or dropped features.
pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportReport, PdfError> { pub fn export_with_report(doc: &Document, opts: &PdfOptions) -> Result<ExportReport, PdfError> {
// Live geometry effects export as their result; raster effects are reported below.
let baked = vectorcraft_effects::bake_document(doc);
let doc = baked.as_ref().unwrap_or(doc);
if doc.artboards.is_empty() { if doc.artboards.is_empty() {
return Err(PdfError::NoArtboards); return Err(PdfError::NoArtboards);
} }
@ -358,8 +361,8 @@ impl Exporter<'_> {
None if !n.is_container() => return, None if !n.is_container() => return,
_ => {} _ => {}
} }
if !n.appearance.effects.is_empty() { if n.appearance.effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) {
self.warn("live effects are not exported to PDF yet"); self.warn("raster effects (shadows, glows, blur, feather) are not exported to PDF yet");
} }
let container = n.is_container() && !matches!(n.kind, NodeKind::Compound { .. }); let container = n.is_container() && !matches!(n.kind, NodeKind::Compound { .. });
let mut pushes = 0; let mut pushes = 0;
@ -464,8 +467,8 @@ impl Exporter<'_> {
if !fl.visible || fl.paint.is_none() { if !fl.visible || fl.paint.is_none() {
continue; continue;
} }
if !fl.effects.is_empty() { if fl.effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) {
self.warn("live effects are not exported to PDF yet"); self.warn("raster effects (shadows, glows, blur, feather) are not exported to PDF yet");
} }
// Pattern fills: the tile instances covering the shape, clipped to it. // Pattern fills: the tile instances covering the shape, clipped to it.
let doc = self.doc; let doc = self.doc;

View File

@ -12,6 +12,7 @@ workspace = true
vectorcraft-geom = { workspace = true } vectorcraft-geom = { workspace = true }
vectorcraft-color = { workspace = true } vectorcraft-color = { workspace = true }
vectorcraft-doc = { workspace = true } vectorcraft-doc = { workspace = true }
vectorcraft-effects = { workspace = true }
kurbo = { workspace = true } kurbo = { workspace = true }
usvg = { workspace = true } usvg = { workspace = true }
thiserror = { workspace = true } thiserror = { workspace = true }
@ -23,3 +24,4 @@ svgtypes = "0.16"
vectorcraft-testkit = { workspace = true } vectorcraft-testkit = { workspace = true }
proptest = { workspace = true } proptest = { workspace = true }
image = { workspace = true } image = { workspace = true }
serde_json = { workspace = true }

View File

@ -14,6 +14,9 @@ use crate::{ExportOptions, Styling, base64_encode, fmt_num, xml_escape};
type Props = Vec<(&'static str, String)>; type Props = Vec<(&'static str, String)>;
pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> String { pub(crate) fn export(doc: &Document, opts: &ExportOptions) -> String {
// Live geometry effects (Roughen, Warp, Offset Path, Effect → Pathfinder…) export as their result.
let baked = vectorcraft_effects::bake_document(doc);
let doc = baked.as_ref().unwrap_or(doc);
let rect = opts let rect = opts
.artboard .artboard
.and_then(|i| doc.artboards.get(i)) .and_then(|i| doc.artboards.get(i))
@ -589,6 +592,9 @@ impl Writer<'_> {
if !n.visible { if !n.visible {
return; return;
} }
if n.appearance.effects.iter().any(|e| e.visible && vectorcraft_effects::is_raster(&e.id)) {
return self.filtered(n);
}
if let Some(m) = n.mask.as_deref() if let Some(m) = n.mask.as_deref()
&& !m.disabled && !m.disabled
{ {
@ -689,6 +695,78 @@ impl Writer<'_> {
} }
} }
/// Raster effects (drop shadow, glows, Gaussian blur, feather) as SVG filters: one `<g filter>`
/// per effect, the first effect innermost (Illustrator'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 reach: f64 = fx.iter().map(RasterFx::outset).sum::<f64>() + 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 {
Some(r) => format!(
" filterUnits=\"userSpaceOnUse\" x=\"{}\" y=\"{}\" width=\"{}\" height=\"{}\"",
self.num(r.x0),
self.num(r.y0),
self.num(r.width()),
self.num(r.height())
),
None => String::new(),
};
let sd = |blur: f64| fmt_num((blur / 2.0).max(0.0), 3);
let flood = |c: &vectorcraft_color::Color, o: f32| {
format!("<feFlood flood-color=\"{}\" flood-opacity=\"{}\"/>", c.to_hex(), fmt_num(o as f64, 3))
};
let body = match f {
RasterFx::DropShadow { opacity, dx, dy, blur, color, .. } => format!(
"<feGaussianBlur in=\"SourceAlpha\" stdDeviation=\"{}\"/><feOffset dx=\"{}\" dy=\"{}\" result=\"shadow\"/>{}<feComposite in2=\"shadow\" operator=\"in\" result=\"paint\"/><feMerge><feMergeNode in=\"paint\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>",
sd(*blur),
self.num(*dx),
self.num(*dy),
flood(color, *opacity)
),
RasterFx::OuterGlow { opacity, blur, color, .. } => format!(
"<feGaussianBlur in=\"SourceAlpha\" stdDeviation=\"{}\" result=\"glow\"/>{}<feComposite in2=\"glow\" operator=\"in\" result=\"paint\"/><feMerge><feMergeNode in=\"paint\"/><feMergeNode in=\"SourceGraphic\"/></feMerge>",
sd(*blur),
flood(color, *opacity)
),
RasterFx::InnerGlow { opacity, blur, color, center, .. } => {
// Edge: the blurred inverse silhouette; Center: the blurred silhouette; both clipped to the shape.
let src = if *center {
"<feGaussianBlur in=\"SourceAlpha\" stdDeviation=\"SD\" result=\"glow\"/>".replace("SD", &sd(*blur))
} else {
format!(
"<feComponentTransfer in=\"SourceAlpha\"><feFuncA type=\"table\" tableValues=\"1 0\"/></feComponentTransfer><feGaussianBlur stdDeviation=\"{}\" result=\"glow\"/>",
sd(*blur)
)
};
format!(
"{src}{}<feComposite in2=\"glow\" operator=\"in\"/><feComposite in2=\"SourceAlpha\" operator=\"in\" result=\"paint\"/><feMerge><feMergeNode in=\"SourceGraphic\"/><feMergeNode in=\"paint\"/></feMerge>",
flood(color, *opacity)
)
}
RasterFx::Feather { radius } => format!(
"<feGaussianBlur in=\"SourceAlpha\" stdDeviation=\"{}\" result=\"soft\"/><feComposite in=\"SourceGraphic\" in2=\"soft\" operator=\"in\" result=\"f\"/><feComposite in=\"f\" in2=\"SourceAlpha\" operator=\"in\"/>",
sd(*radius)
),
RasterFx::GaussianBlur { radius } => format!("<feGaussianBlur in=\"SourceGraphic\" stdDeviation=\"{}\"/>", sd(*radius)),
};
self.def(1, &format!("<filter id=\"{fid}\"{region_attr} color-interpolation-filters=\"sRGB\">{body}</filter>"));
self.line(&format!("<g filter=\"url(#{fid})\">"));
self.depth += 1;
opened += 1;
}
self.node(&inner);
for _ in 0..opened {
self.depth -= 1;
self.line("</g>");
}
}
fn char_props(&mut self, st: &CharStyle) -> Props { fn char_props(&mut self, st: &CharStyle) -> Props {
let mut p = Props::new(); let mut p = Props::new();
let fam = let fam =

View File

@ -693,3 +693,21 @@ fn opentype_features_export_as_css() {
let s = export(&d, &ExportOptions::default()); let s = export(&d, &ExportOptions::default());
assert!(s.contains("font-feature-settings:&quot;liga&quot; 0, &quot;dlig&quot; 1"), "{s}"); assert!(s.contains("font-feature-settings:&quot;liga&quot; 0, &quot;dlig&quot; 1"), "{s}");
} }
#[test]
fn live_effects_export_as_geometry_and_filters() {
let mut d = Document::new(400.0, 400.0);
let mut n = rect_node(&mut d, Rect::new(100.0, 100.0, 200.0, 200.0), Appearance::default_art());
let fx = |id: &str, p: serde_json::Value| vectorcraft_doc::Effect { id: id.into(), params: p, visible: true };
n.appearance.effects.push(fx("path.offsetPath", serde_json::json!({"offset": 10.0})));
n.appearance.effects.push(fx("stylize.dropShadow", serde_json::json!({"x": 7.0, "y": 7.0, "blur": 5.0})));
let l = d.layers[0].id;
d.insert(Some(l), 0, n).unwrap();
let s = export(&d, &ExportOptions { artboard: Some(0), ..Default::default() });
assert!(s.contains("<filter id=") && s.contains("feGaussianBlur") && s.contains("dx=\"7\""), "{s}");
assert!(s.contains("filter=\"url(#"), "{s}");
// The offset path (120 × 120) comes back on import.
let back = import(&s).unwrap();
let b = art(&back).iter().filter_map(|n| n.geometric_bounds()).fold(None, |a: Option<Rect>, b| Some(a.map_or(b, |a| a.union(b)))).unwrap();
assert!((b.width() - 120.0).abs() < 1.0, "{b:?}");
}