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:
parent
1c1fd0f139
commit
09fc2eedd9
|
|
@ -4186,6 +4186,7 @@ dependencies = [
|
|||
"thiserror 2.0.21",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-text",
|
||||
]
|
||||
|
|
@ -4216,11 +4217,13 @@ dependencies = [
|
|||
"image",
|
||||
"kurbo 0.13.1",
|
||||
"proptest",
|
||||
"serde_json",
|
||||
"svgtypes 0.16.1",
|
||||
"thiserror 2.0.21",
|
||||
"usvg 0.48.1",
|
||||
"vectorcraft-color",
|
||||
"vectorcraft-doc",
|
||||
"vectorcraft-effects",
|
||||
"vectorcraft-geom",
|
||||
"vectorcraft-testkit",
|
||||
]
|
||||
|
|
|
|||
16
ROADMAP.md
16
ROADMAP.md
|
|
@ -32,14 +32,14 @@ _Last updated: 2026-10-01._
|
|||
- **Tools:**
|
||||
- **Selection:** Selection, Direct/Group Selection, Magic Wand, Lasso.
|
||||
- **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.
|
||||
- **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.
|
||||
- **Drawing aids:** Smart Guides and snapping, and Draw Normal / Behind / Inside modes.
|
||||
- **Geometry and effects:**
|
||||
- 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.
|
||||
- **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).
|
||||
|
|
@ -56,16 +56,16 @@ _Last updated: 2026-10-01._
|
|||
|---|---|---|---|
|
||||
| M0 | Skeleton + vertical slice | ✅ done | — |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@
|
|||
//! noise (`seed` parameter, default 0).
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod bake;
|
||||
mod distort;
|
||||
mod group;
|
||||
mod raster;
|
||||
|
|
@ -29,6 +30,7 @@ use serde_json::{Map, Value, json};
|
|||
use vectorcraft_doc::Effect;
|
||||
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 raster::{RasterFx, outset, raster_effects};
|
||||
pub use warp::{WarpStyle, warp_point};
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ workspace = true
|
|||
[dependencies]
|
||||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
vectorcraft-effects = { workspace = true }
|
||||
vectorcraft-color = { workspace = true }
|
||||
vectorcraft-text = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
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() {
|
||||
return Err(PdfError::NoArtboards);
|
||||
}
|
||||
|
|
@ -358,8 +361,8 @@ impl Exporter<'_> {
|
|||
None if !n.is_container() => return,
|
||||
_ => {}
|
||||
}
|
||||
if !n.appearance.effects.is_empty() {
|
||||
self.warn("live effects are not exported to PDF yet");
|
||||
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");
|
||||
}
|
||||
let container = n.is_container() && !matches!(n.kind, NodeKind::Compound { .. });
|
||||
let mut pushes = 0;
|
||||
|
|
@ -464,8 +467,8 @@ impl Exporter<'_> {
|
|||
if !fl.visible || fl.paint.is_none() {
|
||||
continue;
|
||||
}
|
||||
if !fl.effects.is_empty() {
|
||||
self.warn("live effects are not exported to PDF yet");
|
||||
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");
|
||||
}
|
||||
// Pattern fills: the tile instances covering the shape, clipped to it.
|
||||
let doc = self.doc;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ workspace = true
|
|||
vectorcraft-geom = { workspace = true }
|
||||
vectorcraft-color = { workspace = true }
|
||||
vectorcraft-doc = { workspace = true }
|
||||
vectorcraft-effects = { workspace = true }
|
||||
kurbo = { workspace = true }
|
||||
usvg = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
|
@ -23,3 +24,4 @@ svgtypes = "0.16"
|
|||
vectorcraft-testkit = { workspace = true }
|
||||
proptest = { workspace = true }
|
||||
image = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
|
|
|
|||
|
|
@ -14,6 +14,9 @@ use crate::{ExportOptions, Styling, base64_encode, fmt_num, xml_escape};
|
|||
type Props = Vec<(&'static str, 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
|
||||
.artboard
|
||||
.and_then(|i| doc.artboards.get(i))
|
||||
|
|
@ -589,6 +592,9 @@ impl Writer<'_> {
|
|||
if !n.visible {
|
||||
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()
|
||||
&& !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 {
|
||||
let mut p = Props::new();
|
||||
let fam =
|
||||
|
|
|
|||
|
|
@ -693,3 +693,21 @@ fn opentype_features_export_as_css() {
|
|||
let s = export(&d, &ExportOptions::default());
|
||||
assert!(s.contains("font-feature-settings:"liga" 0, "dlig" 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:?}");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue