diff --git a/ROADMAP.md b/ROADMAP.md index 9fb0fde..b7aa57e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -107,10 +107,12 @@ interoperability, editing, CLI inspection, and export. ### Clipping, masks, and effects -Add editable clipping first, then masks, then a deliberately bounded subset of -SVG filters. Unsupported SVG constructs should remain preserved through a safe -visual fallback rather than forcing the editor to implement the entire SVG -effects model. +Inkfinite supports editable path clipping, alpha and luminance masks composed +from path subpaths, and an initial SVG filter subset: blur, colour adjustments, +opacity, and drop shadows. The editor, canvas renderer, deterministic SVG +exporter, and canonical document projection use the same effect properties. +Unsupported SVG effect primitives remain available as sanitized static fallback +content rather than being discarded. ### Expressive strokes diff --git a/TODO.md b/TODO.md index a90da23..04aff6c 100644 --- a/TODO.md +++ b/TODO.md @@ -156,15 +156,15 @@ selection, labels, and arrowheads. ### Clips, masks, and filters -- [ ] Define which SVG clip, mask, and filter constructs Inkfinite can represent +- [x] Define which SVG clip, mask, and filter constructs Inkfinite can represent natively -- [ ] Preserve unsupported constructs as sanitized fallback content instead of +- [x] Preserve unsupported constructs as sanitized fallback content instead of silently discarding them -- [ ] Add native clip-path editing before exposing more general mask editing -- [ ] Add basic mask composition once clip-path behavior is stable -- [ ] Define the initial editable filter subset rather than attempting the full +- [x] Add native clip-path editing before exposing more general mask editing +- [x] Add basic mask composition once clip-path behavior is stable +- [x] Define the initial editable filter subset rather than attempting the full SVG filter graph at once -- [ ] Verify deterministic import, editing, save/reopen, and SVG export +- [x] Verify deterministic import, editing, save/reopen, and SVG export ### Variable-width strokes diff --git a/apps/web/src/content/docs/development/svg-import.md b/apps/web/src/content/docs/development/svg-import.md index 92f8b99..f4db0e7 100644 --- a/apps/web/src/content/docs/development/svg-import.md +++ b/apps/web/src/content/docs/development/svg-import.md @@ -97,14 +97,16 @@ values. SVG defaults are a black fill and no stroke. `none` and `transparent` become absent native paint values. Supported linear and radial gradients are converted to native paint properties -and remain editable after import. Patterns, clip paths, masks, and filters -remain visible through the sanitized static fallback when they affect the -visual result. Stylesheet blocks, event-handler attributes, scripts, and SVG -animation elements are removed with warnings. External image URLs and other -resource references are also omitted. No script, animation, stylesheet, or -resource is executed, inserted into a live DOM, or fetched during import. The -retained source asset is input data for provenance and future re-import, not -executable document content. +and remain editable after import. Basic user-space clip paths, path-based alpha +and luminance masks, and the initial filter subset (blur, colour adjustments, +opacity, and drop shadows) are stored as native effect properties. Unsupported +clip units, mask content, and filter primitives remain visible through the +sanitized static fallback when they affect the visual result. Stylesheet blocks, +event-handler attributes, scripts, and SVG animation elements are removed with +warnings. External image URLs and other resource references are also omitted. +No script, animation, stylesheet, or resource is executed, inserted into a live +DOM, or fetched during import. The retained source asset is input data for +provenance and future re-import, not executable document content. ## Text @@ -150,15 +152,6 @@ It requires the matching `wasm-bindgen` CLI. The CLI accepts `inkfinite import svg FILE --input ARTWORK.svg` and can validate the transaction with `--dry-run` before saving. -Patterns, clip paths, masks, and filters are not converted to native shape -properties. They remain visible through the sanitized static fallback when they -affect the visual result. Supported gradients remain native and editable. -Stylesheet blocks, event-handler attributes, scripts, and SVG animation elements -are removed with warnings. External image URLs and other resource references are -also omitted. No script, animation, stylesheet, or resource is executed, -inserted into a live DOM, or fetched during import. The retained source asset is -input data for provenance and future re-import, not executable document content. - ## Browser WASM facade `@inkfinite/wasm` exposes the Rust document session, importer, and deterministic diff --git a/apps/web/src/content/docs/guide/vector-editing.md b/apps/web/src/content/docs/guide/vector-editing.md index b7c1e0a..272a1af 100644 --- a/apps/web/src/content/docs/guide/vector-editing.md +++ b/apps/web/src/content/docs/guide/vector-editing.md @@ -36,11 +36,25 @@ segment's geometry. A path may contain more than one subpath and may mix line an Closing a path adds a segment from its final anchor to its first anchor. Opening it removes the closing segment. Split creates a new anchor on a segment. Join connects compatible open endpoints. +## Clipping, masks, and filters + +Select a path and another object, then choose **Use path as clip** in the selection controls. Inkfinite +converts the path to local clip geometry and removes the source path. **Remove clip** restores the +object without changing its other properties. + +SVG imports also preserve path-based alpha and luminance masks. Their subpaths form one mask +geometry, and the selection controls expose the mask mode. The initial filter controls cover blur, grayscale, drop shadows, brightness, contrast, +hue rotation, inversion, saturation, sepia, and opacity. These effects render in the canvas and SVG +exports from the same stored properties. + +SVG filters and mask forms outside this subset stay in a sanitized static fallback asset, with an +import warning. They are not silently dropped. + ## Imported SVG paths SVG paths and supported vector primitives become native Inkfinite geometry during import. Their -anchors and segments can then be edited with Direct Select. Inkfinite reports and omits unsupported -visual content. +anchors and segments can then be edited with Direct Select. Inkfinite reports unsupported visual +content and keeps its static fallback when the native model cannot represent it. Read [Import and export](/docs/guide/import-and-export/) for the supported workflow. Maintainers can read [Native path geometry](/docs/development/native-path-geometry/) for the stored representation diff --git a/crates/inkfinite-cli/src/bin/generate-bindings.rs b/crates/inkfinite-cli/src/bin/generate-bindings.rs index b287dd0..7ae640e 100644 --- a/crates/inkfinite-cli/src/bin/generate-bindings.rs +++ b/crates/inkfinite-cli/src/bin/generate-bindings.rs @@ -196,6 +196,10 @@ fn model_bindings() -> String { append_declaration::(&mut output, &config); append_declaration::(&mut output, &config); append_declaration::(&mut output, &config); + append_declaration::(&mut output, &config); + append_declaration::(&mut output, &config); + append_declaration::(&mut output, &config); + append_declaration::(&mut output, &config); append_clean_declaration::(&mut output, &config); append_clean_declaration::(&mut output, &config); append_clean_declaration::(&mut output, &config); diff --git a/crates/inkfinite-core/src/lib.rs b/crates/inkfinite-core/src/lib.rs index 394de7b..642d182 100644 --- a/crates/inkfinite-core/src/lib.rs +++ b/crates/inkfinite-core/src/lib.rs @@ -363,6 +363,9 @@ pub enum ShapePropertyError { /// Image properties do not decode or fail image validation. #[error("shape kind {kind} has invalid image properties: {message}")] InvalidImage { kind: String, message: String }, + /// Clip, mask, or filter properties do not decode or fail effect validation. + #[error("shape kind {kind} has invalid vector effects: {message}")] + InvalidEffects { kind: String, message: String }, /// Reference properties do not decode or fail reference validation. #[error("shape kind {kind} has invalid reference properties: {message}")] InvalidReference { kind: String, message: String }, @@ -685,6 +688,107 @@ pub struct PathGeometry { pub fill_rule: PathFillRule, } +/// Whether a native mask reads source alpha or source luminance. +#[derive(Clone, Copy, Debug, Eq, JsonSchema, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum MaskMode { + /// Use the mask geometry as an alpha mask. + Alpha, + /// Use the rendered luminance of the mask geometry. + Luminance, +} + +/// A native non-destructive mask based on editable path geometry. +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct MaskEffect { + /// How the mask contributes opacity. + pub mode: MaskMode, + /// Mask geometry in the target shape's local coordinates. + pub geometry: PathGeometry, + /// Overall mask opacity. + #[serde(default = "default_mask_opacity")] + pub opacity: f64, +} + +/// One supported, editable SVG filter primitive. +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize, TS)] +#[serde(tag = "type", rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum FilterPrimitive { + /// Gaussian blur radius in local user units. + Blur { + /// Standard deviation used by the blur. + radius: f64, + }, + /// CSS/SVG brightness multiplier. + Brightness { + /// Multiplier where `1` leaves the source unchanged. + amount: f64, + }, + /// CSS/SVG contrast multiplier. + Contrast { + /// Multiplier where `1` leaves the source unchanged. + amount: f64, + }, + /// Desaturate the source by the supplied amount. + Grayscale { + /// Amount from zero to one. + amount: f64, + }, + /// Rotate source hue in degrees. + HueRotate { + /// Hue rotation in degrees. + degrees: f64, + }, + /// Invert source colours by the supplied amount. + Invert { + /// Amount from zero to one. + amount: f64, + }, + /// Adjust colour saturation. + Saturate { + /// Multiplier where `1` leaves the source unchanged. + amount: f64, + }, + /// Convert source colours toward sepia. + Sepia { + /// Amount from zero to one. + amount: f64, + }, + /// Adjust source opacity. + Opacity { + /// Multiplier from zero to one. + amount: f64, + }, + /// Draw a blurred coloured shadow behind the source. + DropShadow { + /// Horizontal offset. + dx: f64, + /// Vertical offset. + dy: f64, + /// Blur radius. + radius: f64, + /// Shadow colour. + color: String, + /// Shadow opacity. + opacity: f64, + }, +} + +/// An ordered list of supported filter primitives. +#[derive(Clone, Debug, JsonSchema, PartialEq, Serialize, Deserialize, TS)] +#[serde(rename_all = "camelCase")] +pub struct FilterEffect { + /// Filter primitives in application order. + pub primitives: Vec, +} + +fn default_mask_opacity() -> f64 { + 1.0 +} + /// Error returned when normalized path geometry violates its representation. #[derive(Clone, Debug, Eq, Error, PartialEq)] pub enum PathGeometryError { @@ -1324,7 +1428,10 @@ pub fn validate_shape_properties(kind: &str, properties: &ShapeProperties) -> Re } else if kind == IMAGE_KIND { validate_image_properties(properties) .map_err(|message| ShapePropertyError::InvalidImage { kind: kind.to_owned(), message })?; - } else if kind == REFERENCE_KIND { + } + validate_vector_effects(properties) + .map_err(|message| ShapePropertyError::InvalidEffects { kind: kind.to_owned(), message })?; + if kind == REFERENCE_KIND { validate_reference_properties(properties) .map_err(|message| ShapePropertyError::InvalidReference { kind: kind.to_owned(), message })?; } @@ -1392,6 +1499,55 @@ pub fn normalize_shape_properties( Ok(normalized) } +fn validate_vector_effects(properties: &ShapeProperties) -> Result<(), String> { + if let Some(value) = properties.get("clip_path") { + let geometry: PathGeometry = serde_json::from_value(value.clone()) + .map_err(|error| format!("clip_path could not be decoded: {error}"))?; + validate_path_geometry(&geometry).map_err(|error| format!("clip_path is invalid: {error}"))?; + } + if let Some(value) = properties.get("mask_effect") { + let mask: MaskEffect = serde_json::from_value(value.clone()) + .map_err(|error| format!("mask_effect could not be decoded: {error}"))?; + validate_path_geometry(&mask.geometry).map_err(|error| format!("mask geometry is invalid: {error}"))?; + if !mask.opacity.is_finite() || !(0.0..=1.0).contains(&mask.opacity) { + return Err("mask opacity must be finite and between 0 and 1".into()); + } + } + if let Some(value) = properties.get("filter") { + let filter: FilterEffect = + serde_json::from_value(value.clone()).map_err(|error| format!("filter could not be decoded: {error}"))?; + if filter.primitives.is_empty() { + return Err("filter must contain at least one primitive".into()); + } + for primitive in filter.primitives { + let valid = match primitive { + FilterPrimitive::Blur { radius } => radius.is_finite() && radius >= 0.0, + FilterPrimitive::Brightness { amount } + | FilterPrimitive::Contrast { amount } + | FilterPrimitive::Saturate { amount } => amount.is_finite() && amount >= 0.0, + FilterPrimitive::Grayscale { amount } + | FilterPrimitive::Invert { amount } + | FilterPrimitive::Sepia { amount } + | FilterPrimitive::Opacity { amount } => amount.is_finite() && (0.0..=1.0).contains(&amount), + FilterPrimitive::HueRotate { degrees } => degrees.is_finite(), + FilterPrimitive::DropShadow { dx, dy, radius, opacity, color } => { + dx.is_finite() + && dy.is_finite() + && radius.is_finite() + && radius >= 0.0 + && opacity.is_finite() + && (0.0..=1.0).contains(&opacity) + && !color.trim().is_empty() + } + }; + if !valid { + return Err("filter primitive contains an invalid value".into()); + } + } + } + Ok(()) +} + fn validate_image_properties(properties: &ShapeProperties) -> Result<(), String> { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/crates/inkfinite-core/src/render/mod.rs b/crates/inkfinite-core/src/render/mod.rs index 57e376b..390923a 100644 --- a/crates/inkfinite-core/src/render/mod.rs +++ b/crates/inkfinite-core/src/render/mod.rs @@ -16,8 +16,9 @@ use crate::engine::geometry::{ use crate::path_metrics::{DEFAULT_PATH_METRIC_TOLERANCE, path_length, trim_path}; use crate::proto::Bounds; use crate::{ - AssetId, AssetSource, BuiltinShapeKind, Document, DocumentSnapshot, GradientSpread, GradientUnits, LayerId, PageId, - Paint, PaintValue, PathFillRule, PathGeometry, PathSegment, PathSubpath, ShapeId, ShapeRecord, Vec2, + AssetId, AssetSource, BuiltinShapeKind, Document, DocumentSnapshot, FilterEffect, FilterPrimitive, GradientSpread, + GradientUnits, LayerId, MaskEffect, MaskMode, PageId, Paint, PaintValue, PathFillRule, PathGeometry, PathSegment, + PathSubpath, ShapeId, ShapeRecord, Vec2, }; const DEFAULT_PADDING: f64 = 20.0; @@ -173,6 +174,19 @@ impl Renderer<'_> { for child_id in &shape.child_ids { self.render_shape(child_id, matrix, selected, &mut inner)?; } + if !inner.is_empty() { + let mut effect_defs = String::new(); + apply_shape_effects(shape, matrix, &mut inner, &mut effect_defs).map_err(|message| { + SvgRenderError::InvalidShapeProperties { + shape_id: shape.id.clone(), + kind: shape.kind.to_string(), + message, + } + })?; + if !effect_defs.is_empty() { + inner = format!(" {effect_defs}\n{inner}"); + } + } if inner.is_empty() { return Ok(()); } @@ -972,6 +986,83 @@ fn shape_local_bounds(document: &Document, shape: &ShapeRecord) -> Result Result<(), String> { + let clip = shape + .properties + .get("clip_path") + .map(|value| serde_json::from_value::(value.clone())) + .transpose() + .map_err(|error| format!("clip_path could not be decoded: {error}"))?; + let mask = shape + .properties + .get("mask_effect") + .map(|value| serde_json::from_value::(value.clone())) + .transpose() + .map_err(|error| format!("mask_effect could not be decoded: {error}"))?; + let filter = shape + .properties + .get("filter") + .map(|value| serde_json::from_value::(value.clone())) + .transpose() + .map_err(|error| format!("filter could not be decoded: {error}"))?; + let safe_id = shape + .id + .as_str() + .replace(|character: char| !character.is_ascii_alphanumeric(), "-"); + let mut attributes = Vec::new(); + if let Some(geometry) = clip { + let id = format!("inkfinite-clip-{safe_id}"); + writeln!(defs, "", path_data(&geometry), affine_svg(matrix), path_fill_rule(geometry.fill_rule)).map_err(|error| error.to_string())?; + attributes.push(format!("clip-path=\"url(#{id})\"")); + } + if let Some(mask) = mask { + crate::validate_path_geometry(&mask.geometry).map_err(|error| error.to_string())?; + let id = format!("inkfinite-mask-{safe_id}"); + let mode = match mask.mode { + MaskMode::Alpha => "alpha", + MaskMode::Luminance => "luminance", + }; + writeln!(defs, "", path_data(&mask.geometry), affine_svg(matrix), number(mask.opacity), path_fill_rule(mask.geometry.fill_rule)).map_err(|error| error.to_string())?; + attributes.push(format!("mask=\"url(#{id})\"")); + } + if let Some(filter) = filter { + let id = format!("inkfinite-filter-{safe_id}"); + write_filter(defs, &id, &filter)?; + attributes.push(format!("filter=\"url(#{id})\"")); + } + if !attributes.is_empty() { + *output = format!("{output}", attributes.join(" ")); + } + Ok(()) +} + +fn write_filter(defs: &mut String, id: &str, filter: &FilterEffect) -> Result<(), String> { + write!( + defs, + "" + ) + .map_err(|error| error.to_string())?; + for (index, primitive) in filter.primitives.iter().enumerate() { + let input = if index == 0 { "SourceGraphic".to_owned() } else { format!("inkfinite-filter-{id}-{index}") }; + let result = format!("inkfinite-filter-{id}-{}", index + 1); + match primitive { + FilterPrimitive::Blur { radius } => write!(defs, "", number(*radius)), + FilterPrimitive::DropShadow { dx, dy, radius, color, opacity } => write!(defs, "", number(*dx), number(*dy), number(*radius), escape_xml(color), number(*opacity)), + FilterPrimitive::Saturate { amount } => write!(defs, "", number(*amount)), + FilterPrimitive::HueRotate { degrees } => write!(defs, "", number(*degrees)), + FilterPrimitive::Grayscale { amount } => write!(defs, "", number(1.0 - amount)), + FilterPrimitive::Invert { amount } => write!(defs, "", number(*amount), number(1.0 - amount), number(*amount), number(1.0 - amount), number(*amount), number(1.0 - amount)), + FilterPrimitive::Brightness { amount } => write!(defs, "", number(*amount), number(*amount), number(*amount)), + FilterPrimitive::Contrast { amount } => { let intercept = 0.5 - 0.5 * amount; write!(defs, "", number(*amount), number(intercept), number(*amount), number(intercept), number(*amount), number(intercept)) }, + FilterPrimitive::Sepia { amount } => write!(defs, "", number(0.393 + 0.607 * (1.0 - amount)), number(0.769 - 0.769 * (1.0 - amount)), number(0.189 - 0.189 * (1.0 - amount)), number(0.349 - 0.349 * (1.0 - amount)), number(0.686 + 0.314 * (1.0 - amount)), number(0.168 - 0.168 * (1.0 - amount)), number(0.272 - 0.272 * (1.0 - amount)), number(0.534 - 0.534 * (1.0 - amount)), number(0.131 + 0.869 * (1.0 - amount))), + FilterPrimitive::Opacity { amount } => write!(defs, "", number(*amount)), + }.map_err(|error| error.to_string())?; + } + writeln!(defs, "").map_err(|error| error.to_string()) +} + fn image_mask_path(mask: &ImageMask, width: f64, height: f64) -> String { match mask.kind.as_str() { "ellipse" => format!( diff --git a/crates/inkfinite-core/src/render/tests.rs b/crates/inkfinite-core/src/render/tests.rs index 470d932..8c8e83b 100644 --- a/crates/inkfinite-core/src/render/tests.rs +++ b/crates/inkfinite-core/src/render/tests.rs @@ -951,6 +951,43 @@ fn renders_gradient_paints_with_stop_opacity_transform_and_spread() { ); } +#[test] +fn renders_clip_masks_and_filters_deterministically() { + let mut snapshot = fixture_snapshot(); + let shape = snapshot + .document + .shapes + .get_mut(&ShapeId::from("shape:stencil-process")) + .expect("shape"); + shape.properties.insert( + "clip_path".into(), + serde_json::json!({ + "subpaths": [{ "segments": [{ "type": "move", "to": { "x": 0, "y": 0 } }, { "type": "line", "to": { "x": 80, "y": 0 } }, { "type": "line", "to": { "x": 40, "y": 80 } }], "closed": true }], + "fill_rule": "nonzero" + }), + ); + shape.properties.insert( + "mask_effect".into(), + serde_json::json!({ + "mode": "alpha", + "geometry": { "subpaths": [{ "segments": [{ "type": "move", "to": { "x": 0, "y": 0 } }, { "type": "line", "to": { "x": 120, "y": 0 } }, { "type": "line", "to": { "x": 120, "y": 80 } }, { "type": "line", "to": { "x": 0, "y": 80 } }], "closed": true }], "fill_rule": "nonzero" }, + "opacity": 0.75 + }), + ); + shape.properties.insert( + "filter".into(), + serde_json::json!({ "primitives": [{ "type": "blur", "radius": 2 }, { "type": "brightness", "amount": 1.1 }] }), + ); + let options = SvgRenderOptions { page_id: Some(PageId::from("page:fixtures")), ..SvgRenderOptions::default() }; + let first = render_svg(&snapshot, &options).expect("effects render"); + let second = render_svg(&snapshot, &options).expect("effects render twice"); + assert_eq!(first.svg, second.svg); + assert!(first.svg.contains("inkfinite-clip-shape-stencil-process")); + assert!(first.svg.contains("inkfinite-mask-shape-stencil-process")); + assert!(first.svg.contains("inkfinite-filter-shape-stencil-process")); + assert!(first.svg.contains("feGaussianBlur")); +} + #[test] fn invalid_page_and_region_are_typed_errors() { let snapshot = fixture_snapshot(); diff --git a/crates/inkfinite-core/src/svg_import.rs b/crates/inkfinite-core/src/svg_import.rs index 1de2b91..95964a9 100644 --- a/crates/inkfinite-core/src/svg_import.rs +++ b/crates/inkfinite-core/src/svg_import.rs @@ -7,7 +7,7 @@ //! as image nodes backed by embedded assets. The exact source is retained as a //! content-addressed SVG asset. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::str::FromStr; @@ -23,9 +23,9 @@ use ts_rs::TS; use crate::engine::geometry::{Affine, path_bounds, union}; use crate::proto::Bounds; use crate::{ - AssetId, AssetRecord, AssetSource, GradientSpread, GradientStop, GradientTransform, GradientUnits, Opacity, Paint, - PathFillRule, PathGeometry, PathSegment as NativePathSegment, PathSubpath, Provenance, ShapeKind, ShapeProperties, - ShapeStyle, Timestamp, Transform, Vec2, + AssetId, AssetRecord, AssetSource, FilterEffect, FilterPrimitive, GradientSpread, GradientStop, GradientTransform, + GradientUnits, MaskEffect, MaskMode, Opacity, Paint, PathFillRule, PathGeometry, PathSegment as NativePathSegment, + PathSubpath, Provenance, ShapeKind, ShapeProperties, ShapeStyle, Timestamp, Transform, Vec2, }; /// Maximum UTF-8 input accepted by the SVG parser. @@ -362,6 +362,9 @@ struct SvgStyle { font_size: f64, font_family: String, visible: bool, + clip_path: Option, + mask_effect: Option, + filter: Option, } impl Default for SvgStyle { @@ -378,6 +381,9 @@ impl Default for SvgStyle { font_size: 16.0, font_family: "sans-serif".into(), visible: true, + clip_path: None, + mask_effect: None, + filter: None, } } } @@ -397,11 +403,22 @@ impl SvgStyle { } } +#[derive(Default)] +struct SvgEffects { + clip_paths: BTreeMap, + masks: BTreeMap, + filters: BTreeMap, + unsupported_clip_paths: BTreeSet, + unsupported_masks: BTreeSet, + unsupported_filters: BTreeSet, +} + struct ImportParser { assets: Vec, warnings: Vec, view_box: Option, gradients: BTreeMap, + effects: SvgEffects, } impl ImportParser { @@ -419,7 +436,7 @@ impl ImportParser { ) -> Result, SvgImportError> { let element = local_name(node).to_owned(); self.warn_event_handlers(node); - let style = resolve_style(parent_style, node, &mut self.warnings, &self.gradients)?; + let style = resolve_style(parent_style, node, &mut self.warnings, &self.gradients, &self.effects)?; if !style.visible { return Ok(None); } @@ -431,7 +448,7 @@ impl ImportParser { source_id, transform: self.transform(node)?, style: style.native_style()?, - properties: group_properties(&children), + properties: styled_group_properties(&style, &children), children, }; Ok(Some(SvgImportNode::Group(Box::new(group)))) @@ -510,13 +527,16 @@ impl ImportParser { source_id: source_id(node), kind: ShapeKind::from(crate::RECTANGLE_KIND), transform, - properties: properties([ - ("width", json!(width)), - ("height", json!(height)), - ("radius", json!(radius.min(width / 2.0).min(height / 2.0).max(0.0))), - ("fill", paint_value(style.fill.clone())), - ("stroke", paint_value(style.stroke.clone())), - ]), + properties: styled_properties( + style, + [ + ("width", json!(width)), + ("height", json!(height)), + ("radius", json!(radius.min(width / 2.0).min(height / 2.0).max(0.0))), + ("fill", paint_value(style.fill.clone())), + ("stroke", paint_value(style.stroke.clone())), + ], + ), style: style.native_style()?, }) } @@ -531,12 +551,15 @@ impl ImportParser { source_id: source_id(node), kind: ShapeKind::from(crate::ELLIPSE_KIND), transform, - properties: properties([ - ("width", json!(radius * 2.0)), - ("height", json!(radius * 2.0)), - ("fill", paint_value(style.fill.clone())), - ("stroke", paint_value(style.stroke.clone())), - ]), + properties: styled_properties( + style, + [ + ("width", json!(radius * 2.0)), + ("height", json!(radius * 2.0)), + ("fill", paint_value(style.fill.clone())), + ("stroke", paint_value(style.stroke.clone())), + ], + ), style: style.native_style()?, }) } @@ -553,12 +576,15 @@ impl ImportParser { source_id: source_id(node), kind: ShapeKind::from(crate::ELLIPSE_KIND), transform, - properties: properties([ - ("width", json!(rx * 2.0)), - ("height", json!(ry * 2.0)), - ("fill", paint_value(style.fill.clone())), - ("stroke", paint_value(style.stroke.clone())), - ]), + properties: styled_properties( + style, + [ + ("width", json!(rx * 2.0)), + ("height", json!(ry * 2.0)), + ("fill", paint_value(style.fill.clone())), + ("stroke", paint_value(style.stroke.clone())), + ], + ), style: style.native_style()?, }) } @@ -573,20 +599,23 @@ impl ImportParser { source_id: source_id(node), kind: ShapeKind::from(crate::LINE_KIND), transform, - properties: properties([ - ("a", json!(Vec2 { x: 0.0, y: 0.0 })), - ("b", json!(Vec2 { x: x2 - x1, y: y2 - y1 })), - ( - "stroke", - paint_value( - style - .stroke - .clone() - .or_else(|| Some(Paint::Solid { color: "none".into() })), + properties: styled_properties( + style, + [ + ("a", json!(Vec2 { x: 0.0, y: 0.0 })), + ("b", json!(Vec2 { x: x2 - x1, y: y2 - y1 })), + ( + "stroke", + paint_value( + style + .stroke + .clone() + .or_else(|| Some(Paint::Solid { color: "none".into() })), + ), ), - ), - ("width", json!(style.stroke_width)), - ]), + ("width", json!(style.stroke_width)), + ], + ), style: style.native_style()?, }) } @@ -643,13 +672,16 @@ impl ImportParser { source_id: source_id(node), kind: ShapeKind::from(crate::PATH_KIND), transform, - properties: properties([ - ("subpaths", subpaths), - ("fill_rule", fill_rule), - ("fill", paint_value(style.fill.clone())), - ("stroke", paint_value(style.stroke.clone())), - ("stroke_width", json!(style.stroke_width)), - ]), + properties: styled_properties( + style, + [ + ("subpaths", subpaths), + ("fill_rule", fill_rule), + ("fill", paint_value(style.fill.clone())), + ("stroke", paint_value(style.stroke.clone())), + ("stroke_width", json!(style.stroke_width)), + ], + ), style: style.native_style()?, }) } @@ -674,12 +706,15 @@ impl ImportParser { source_id: source_id(node), kind: ShapeKind::from(crate::TEXT_KIND), transform, - properties: properties([ - ("text", json!(text)), - ("font_size", json!(style.font_size)), - ("font_family", json!(style.font_family.clone())), - ("color", paint_value(Some(color))), - ]), + properties: styled_properties( + style, + [ + ("text", json!(text)), + ("font_size", json!(style.font_size)), + ("font_family", json!(style.font_family.clone())), + ("color", paint_value(Some(color))), + ], + ), style: style.native_style()?, }) } @@ -720,7 +755,7 @@ impl ImportParser { source_id: source_id(node), asset_id, transform: self.transformed_geometry(node, x, y)?, - properties: properties([("width", json!(width)), ("height", json!(height))]), + properties: styled_properties(style, [("width", json!(width)), ("height", json!(height))]), style: style.native_style()?, })) } @@ -807,9 +842,27 @@ impl ImportParser { let feature = match local_name(descendant) { "meshgradient" => Some(SvgUnsupportedFeature::Gradient), "pattern" => Some(SvgUnsupportedFeature::Pattern), - "clipPath" => Some(SvgUnsupportedFeature::ClipPath), - "mask" => Some(SvgUnsupportedFeature::Mask), - "filter" => Some(SvgUnsupportedFeature::Filter), + "clipPath" + if descendant + .attribute("id") + .is_some_and(|id| self.effects.unsupported_clip_paths.contains(id)) => + { + Some(SvgUnsupportedFeature::ClipPath) + } + "mask" + if descendant + .attribute("id") + .is_some_and(|id| self.effects.unsupported_masks.contains(id)) => + { + Some(SvgUnsupportedFeature::Mask) + } + "filter" + if descendant + .attribute("id") + .is_some_and(|id| self.effects.unsupported_filters.contains(id)) => + { + Some(SvgUnsupportedFeature::Filter) + } "script" => Some(SvgUnsupportedFeature::Script), "animate" | "animateMotion" | "animateTransform" | "set" | "discard" => { Some(SvgUnsupportedFeature::Animation) @@ -870,9 +923,16 @@ pub fn parse_svg(source: &str) -> Result { .transpose()?; let source_asset = make_source_asset(source.as_bytes()); let gradients = collect_gradients(root, view_box)?; - let mut parser = ImportParser { assets: Vec::new(), warnings: Vec::new(), view_box, gradients }; + let effects = collect_svg_effects(root, view_box)?; + let mut parser = ImportParser { assets: Vec::new(), warnings: Vec::new(), view_box, gradients, effects }; parser.warn_event_handlers(root); - let root_style = resolve_style(&SvgStyle::default(), root, &mut parser.warnings, &parser.gradients)?; + let root_style = resolve_style( + &SvgStyle::default(), + root, + &mut parser.warnings, + &parser.gradients, + &parser.effects, + )?; let mut children = parser.children(root, &root_style)?; let root_transform = parser.transform(root)?; if requires_static_fallback(&parser.warnings) { @@ -924,6 +984,354 @@ pub fn import_svg(source: impl AsRef<[u8]>) -> Result parse_svg(source) } +fn collect_svg_effects(root: Node<'_, '_>, view_box: Option) -> Result { + let mut effects = SvgEffects::default(); + for node in root.descendants().filter(|node| node.is_element()) { + let Some(id) = node.attribute("id").filter(|id| !id.trim().is_empty()) else { continue }; + match local_name(node) { + "clipPath" => { + if node + .attribute("clipPathUnits") + .is_some_and(|value| value != "userSpaceOnUse") + { + effects.unsupported_clip_paths.insert(id.into()); + continue; + } + let (mut geometry, supported) = effect_geometry(node, view_box)?; + if let Some(geometry) = &mut geometry { + apply_effect_definition_transform(node, geometry)?; + } + if supported { + if let Some(geometry) = geometry { + effects.clip_paths.insert(id.into(), geometry); + } else { + effects.unsupported_clip_paths.insert(id.into()); + } + } else { + effects.unsupported_clip_paths.insert(id.into()); + } + } + "mask" => { + if node.attribute("maskUnits") != Some("userSpaceOnUse") { + effects.unsupported_masks.insert(id.into()); + continue; + } + let (mut geometry, supported) = effect_geometry(node, view_box)?; + if let Some(geometry) = &mut geometry { + apply_effect_definition_transform(node, geometry)?; + } + if !supported { + effects.unsupported_masks.insert(id.into()); + continue; + } + let Some(geometry) = geometry else { + effects.unsupported_masks.insert(id.into()); + continue; + }; + let mode = match node.attribute("mask-type").unwrap_or("luminance") { + "alpha" => MaskMode::Alpha, + "luminance" => MaskMode::Luminance, + _ => { + effects.unsupported_masks.insert(id.into()); + continue; + } + }; + effects + .masks + .insert(id.into(), MaskEffect { mode, geometry, opacity: 1.0 }); + } + "filter" => { + let (filter, supported) = filter_effect(node)?; + if supported && !filter.primitives.is_empty() { + effects.filters.insert(id.into(), filter); + } else { + effects.unsupported_filters.insert(id.into()); + } + } + _ => {} + } + } + Ok(effects) +} + +fn effect_geometry( + node: Node<'_, '_>, view_box: Option, +) -> Result<(Option, bool), SvgImportError> { + let mut geometry = PathGeometry { subpaths: Vec::new(), fill_rule: PathFillRule::NonZero }; + let mut supported = true; + for child in node.children().filter(|child| child.is_element()) { + let Some(child_geometry) = effect_geometry_node(child, view_box)? else { + supported = false; + continue; + }; + geometry.subpaths.extend(child_geometry.subpaths); + } + Ok(((!geometry.subpaths.is_empty()).then_some(geometry), supported)) +} + +fn effect_geometry_node( + node: Node<'_, '_>, view_box: Option, +) -> Result, SvgImportError> { + let Some(mut geometry) = (match local_name(node) { + "g" => { + let (nested, supported) = effect_geometry(node, view_box)?; + if !supported { + return Ok(None); + } + nested + } + "rect" => { + let x = effect_length(node, "x", 0.0, Axis::Horizontal, view_box)?; + let y = effect_length(node, "y", 0.0, Axis::Vertical, view_box)?; + let width = effect_length(node, "width", 0.0, Axis::Horizontal, view_box)?; + let height = effect_length(node, "height", 0.0, Axis::Vertical, view_box)?; + if width < 0.0 || height < 0.0 { + return Err(invalid_attribute(node, "width", width.to_string())); + } + let rx = effect_length(node, "rx", 0.0, Axis::Horizontal, view_box)? + .max(0.0) + .min(width / 2.0); + let ry = effect_length(node, "ry", 0.0, Axis::Vertical, view_box)? + .max(0.0) + .min(height / 2.0); + Some(rect_geometry(x, y, width, height, rx, ry)) + } + "circle" => { + let cx = effect_length(node, "cx", 0.0, Axis::Horizontal, view_box)?; + let cy = effect_length(node, "cy", 0.0, Axis::Vertical, view_box)?; + let radius = effect_length(node, "r", 0.0, Axis::Horizontal, view_box)?; + (radius >= 0.0).then(|| ellipse_geometry(cx, cy, radius, radius)) + } + "ellipse" => { + let cx = effect_length(node, "cx", 0.0, Axis::Horizontal, view_box)?; + let cy = effect_length(node, "cy", 0.0, Axis::Vertical, view_box)?; + let rx = effect_length(node, "rx", 0.0, Axis::Horizontal, view_box)?; + let ry = effect_length(node, "ry", 0.0, Axis::Vertical, view_box)?; + (rx >= 0.0 && ry >= 0.0).then(|| ellipse_geometry(cx, cy, rx, ry)) + } + "path" => { + let Some(value) = node.attribute("d") else { + return Ok(None); + }; + Some( + normalize_path(value, PathFillRule::NonZero) + .map_err(|message| SvgImportError::InvalidPath { element: "path".into(), message })?, + ) + } + "polygon" | "polyline" => { + let Some(value) = node.attribute("points") else { + return Ok(None); + }; + let points = parse_number_pairs(value).map_err(|message| invalid_attribute(node, "points", message))?; + if points.len() < if local_name(node) == "polygon" { 3 } else { 2 } { + return Ok(None); + } + let mut segments = vec![NativePathSegment::Move { to: points[0] }]; + segments.extend(points.iter().skip(1).copied().map(|to| NativePathSegment::Line { to })); + Some(PathGeometry { + subpaths: vec![PathSubpath { segments, closed: local_name(node) == "polygon", handle_modes: None }], + fill_rule: PathFillRule::NonZero, + }) + } + _ => return Ok(None), + }) else { + return Ok(None); + }; + let transform = node + .attribute("transform") + .map(parse_transform) + .transpose() + .map_err(|message| SvgImportError::UnsupportedTransform { element: local_name(node).into(), message })? + .unwrap_or(Affine::IDENTITY); + transform_geometry(&mut geometry, transform); + Ok(Some(geometry)) +} + +fn apply_effect_definition_transform(node: Node<'_, '_>, geometry: &mut PathGeometry) -> Result<(), SvgImportError> { + let transform = node + .attribute("transform") + .map(parse_transform) + .transpose() + .map_err(|message| SvgImportError::UnsupportedTransform { element: local_name(node).into(), message })? + .unwrap_or(Affine::IDENTITY); + transform_geometry(geometry, transform); + Ok(()) +} + +fn effect_length( + node: Node<'_, '_>, attribute: &str, default: f64, axis: Axis, view_box: Option, +) -> Result { + let Some(value) = node.attribute(attribute) else { + return Ok(default); + }; + let reference = view_box.map(|value| match axis { + Axis::Horizontal => value.width, + Axis::Vertical => value.height, + }); + parse_length(value, reference).map_err(|_| invalid_attribute(node, attribute, value)) +} + +fn rect_geometry(x: f64, y: f64, width: f64, height: f64, rx: f64, ry: f64) -> PathGeometry { + if rx <= 0.0 || ry <= 0.0 { + return PathGeometry { + subpaths: vec![PathSubpath { + segments: vec![ + NativePathSegment::Move { to: Vec2 { x, y } }, + NativePathSegment::Line { to: Vec2 { x: x + width, y } }, + NativePathSegment::Line { to: Vec2 { x: x + width, y: y + height } }, + NativePathSegment::Line { to: Vec2 { x, y: y + height } }, + ], + closed: true, + handle_modes: None, + }], + fill_rule: PathFillRule::NonZero, + }; + } + let k = 0.5522847498307936; + let mut segments = vec![NativePathSegment::Move { to: Vec2 { x: x + rx, y } }]; + segments.push(NativePathSegment::Line { to: Vec2 { x: x + width - rx, y } }); + segments.push(NativePathSegment::Cubic { + control_1: Vec2 { x: x + width - rx + k * rx, y }, + control_2: Vec2 { x: x + width, y: y + ry - k * ry }, + to: Vec2 { x: x + width, y: y + ry }, + }); + segments.push(NativePathSegment::Line { to: Vec2 { x: x + width, y: y + height - ry } }); + segments.push(NativePathSegment::Cubic { + control_1: Vec2 { x: x + width, y: y + height - ry + k * ry }, + control_2: Vec2 { x: x + width - rx + k * rx, y: y + height }, + to: Vec2 { x: x + width - rx, y: y + height }, + }); + segments.push(NativePathSegment::Line { to: Vec2 { x: x + rx, y: y + height } }); + segments.push(NativePathSegment::Cubic { + control_1: Vec2 { x: x + rx - k * rx, y: y + height }, + control_2: Vec2 { x, y: y + height - ry + k * ry }, + to: Vec2 { x, y: y + height - ry }, + }); + segments.push(NativePathSegment::Line { to: Vec2 { x, y: y + ry } }); + segments.push(NativePathSegment::Cubic { + control_1: Vec2 { x, y: y + ry - k * ry }, + control_2: Vec2 { x: x + rx - k * rx, y }, + to: Vec2 { x: x + rx, y }, + }); + PathGeometry { + subpaths: vec![PathSubpath { segments, closed: true, handle_modes: None }], + fill_rule: PathFillRule::NonZero, + } +} + +fn ellipse_geometry(cx: f64, cy: f64, rx: f64, ry: f64) -> PathGeometry { + let k = 0.5522847498307936; + PathGeometry { + subpaths: vec![PathSubpath { + segments: vec![ + NativePathSegment::Move { to: Vec2 { x: cx + rx, y: cy } }, + NativePathSegment::Cubic { + control_1: Vec2 { x: cx + rx, y: cy + k * ry }, + control_2: Vec2 { x: cx + k * rx, y: cy + ry }, + to: Vec2 { x: cx, y: cy + ry }, + }, + NativePathSegment::Cubic { + control_1: Vec2 { x: cx - k * rx, y: cy + ry }, + control_2: Vec2 { x: cx - rx, y: cy + k * ry }, + to: Vec2 { x: cx - rx, y: cy }, + }, + NativePathSegment::Cubic { + control_1: Vec2 { x: cx - rx, y: cy - k * ry }, + control_2: Vec2 { x: cx - k * rx, y: cy - ry }, + to: Vec2 { x: cx, y: cy - ry }, + }, + NativePathSegment::Cubic { + control_1: Vec2 { x: cx + k * rx, y: cy - ry }, + control_2: Vec2 { x: cx + rx, y: cy - k * ry }, + to: Vec2 { x: cx + rx, y: cy }, + }, + ], + closed: true, + handle_modes: None, + }], + fill_rule: PathFillRule::NonZero, + } +} + +fn transform_geometry(geometry: &mut PathGeometry, transform: Affine) { + for subpath in &mut geometry.subpaths { + for segment in &mut subpath.segments { + match segment { + NativePathSegment::Move { to } | NativePathSegment::Line { to } => *to = transform.point(*to), + NativePathSegment::Quadratic { control, to } => { + *control = transform.point(*control); + *to = transform.point(*to); + } + NativePathSegment::Cubic { to, control_1, control_2 } => { + *to = transform.point(*to); + *control_1 = transform.point(*control_1); + *control_2 = transform.point(*control_2); + } + } + } + } +} + +fn filter_effect(node: Node<'_, '_>) -> Result<(FilterEffect, bool), SvgImportError> { + let mut primitives = Vec::new(); + let mut supported = true; + for child in node.children().filter(|child| child.is_element()) { + let primitive = match local_name(child) { + "feGaussianBlur" => { + let value = child.attribute("stdDeviation").unwrap_or("0"); + let radius = value + .split_ascii_whitespace() + .next() + .unwrap_or("0") + .parse::() + .map_err(|_| invalid_attribute(child, "stdDeviation", value))?; + Some(FilterPrimitive::Blur { radius }) + } + "feDropShadow" => { + let dx = child + .attribute("dx") + .unwrap_or("0") + .parse::() + .map_err(|_| invalid_attribute(child, "dx", "invalid"))?; + let dy = child + .attribute("dy") + .unwrap_or("0") + .parse::() + .map_err(|_| invalid_attribute(child, "dy", "invalid"))?; + let radius = child + .attribute("stdDeviation") + .unwrap_or("0") + .split_ascii_whitespace() + .next() + .unwrap_or("0") + .parse::() + .map_err(|_| invalid_attribute(child, "stdDeviation", "invalid"))?; + let color = child.attribute("flood-color").unwrap_or("#000000").to_owned(); + let opacity = child + .attribute("flood-opacity") + .map_or(Ok(1.0), |value| parse_opacity(value, "flood-opacity", child))?; + Some(FilterPrimitive::DropShadow { dx, dy, radius, color, opacity: f64::from(opacity) }) + } + "feColorMatrix" if child.attribute("type") == Some("saturate") => { + let amount = child + .attribute("values") + .unwrap_or("1") + .parse::() + .map_err(|_| invalid_attribute(child, "values", "invalid"))?; + Some(FilterPrimitive::Saturate { amount }) + } + _ => { + supported = false; + None + } + }; + if let Some(primitive) = primitive { + primitives.push(primitive); + } + } + Ok((FilterEffect { primitives }, supported)) +} + #[derive(Clone)] struct RawGradient { kind: &'static str, @@ -1311,10 +1719,13 @@ fn escape_svg_attribute(value: &str, output: &mut String) { fn resolve_style<'a, 'input>( parent: &SvgStyle, node: Node<'a, 'input>, warnings: &mut Vec, - gradients: &BTreeMap, + gradients: &BTreeMap, effects: &SvgEffects, ) -> Result { let mut style = parent.clone(); style.opacity = 1.0; + style.clip_path = None; + style.mask_effect = None; + style.filter = None; let mut declarations = BTreeMap::new(); for attribute in node.attributes() { declarations.insert(attribute.name(), attribute.value()); @@ -1360,28 +1771,37 @@ fn resolve_style<'a, 'input>( } "font-family" => style.font_family = first_font_family(value), "clip-path" if value.trim_start().starts_with("url(") => { - warnings.push(SvgImportWarning::UnsupportedFeature { - feature: SvgUnsupportedFeature::ClipPath, - element: local_name(node).into(), - source_id: source_id(node), - action: SvgUnsupportedAction::Omitted, - }); + let id = value + .trim() + .strip_prefix("url(#") + .and_then(|value| value.strip_suffix(')')); + if let Some(id) = id.and_then(|id| effects.clip_paths.get(id)) { + style.clip_path = Some(id.clone()); + } else { + warn_effect_reference(node, SvgUnsupportedFeature::ClipPath, warnings); + } } "mask" if value.trim_start().starts_with("url(") => { - warnings.push(SvgImportWarning::UnsupportedFeature { - feature: SvgUnsupportedFeature::Mask, - element: local_name(node).into(), - source_id: source_id(node), - action: SvgUnsupportedAction::Omitted, - }); + let id = value + .trim() + .strip_prefix("url(#") + .and_then(|value| value.strip_suffix(')')); + if let Some(id) = id.and_then(|id| effects.masks.get(id)) { + style.mask_effect = Some(id.clone()); + } else { + warn_effect_reference(node, SvgUnsupportedFeature::Mask, warnings); + } } "filter" if value.trim_start().starts_with("url(") => { - warnings.push(SvgImportWarning::UnsupportedFeature { - feature: SvgUnsupportedFeature::Filter, - element: local_name(node).into(), - source_id: source_id(node), - action: SvgUnsupportedAction::Omitted, - }); + let id = value + .trim() + .strip_prefix("url(#") + .and_then(|value| value.strip_suffix(')')); + if let Some(id) = id.and_then(|id| effects.filters.get(id)) { + style.filter = Some(id.clone()); + } else { + warn_effect_reference(node, SvgUnsupportedFeature::Filter, warnings); + } } "display" if value.trim() == "none" => style.visible = false, "visibility" if matches!(value.trim(), "hidden" | "collapse") => style.visible = false, @@ -1391,6 +1811,17 @@ fn resolve_style<'a, 'input>( Ok(style) } +fn warn_effect_reference<'a, 'input>( + node: Node<'a, 'input>, feature: SvgUnsupportedFeature, warnings: &mut Vec, +) { + warnings.push(SvgImportWarning::UnsupportedFeature { + feature, + element: local_name(node).into(), + source_id: source_id(node), + action: SvgUnsupportedAction::Omitted, + }); +} + fn parse_paint<'a, 'input>( value: &str, property: &str, node: Node<'a, 'input>, warnings: &mut Vec, current_color: &str, gradients: &BTreeMap, @@ -1557,6 +1988,39 @@ fn properties(entries: impl IntoIterator) -> Shape entries.into_iter().map(|(key, value)| (key.into(), value)).collect() } +fn styled_properties(style: &SvgStyle, entries: impl IntoIterator) -> ShapeProperties { + let mut result = properties(entries); + insert_style_effects(&mut result, style); + result +} + +fn styled_group_properties(style: &SvgStyle, children: &[SvgImportNode]) -> ShapeProperties { + let mut result = group_properties(children); + insert_style_effects(&mut result, style); + result +} + +fn insert_style_effects(properties: &mut ShapeProperties, style: &SvgStyle) { + if let Some(clip_path) = &style.clip_path { + properties.insert( + "clip_path".into(), + serde_json::to_value(clip_path).expect("path geometry is serializable"), + ); + } + if let Some(mask) = &style.mask_effect { + properties.insert( + "mask_effect".into(), + serde_json::to_value(mask).expect("mask effect is serializable"), + ); + } + if let Some(filter) = &style.filter { + properties.insert( + "filter".into(), + serde_json::to_value(filter).expect("filter effect is serializable"), + ); + } +} + fn paint_value(value: Option) -> Value { match value { None => Value::Null, @@ -2135,6 +2599,49 @@ mod tests { ); } + #[test] + fn imports_native_clip_masks_and_supported_filters_without_fallback() { + let import = parse_svg( + r##" + + + + + + + "##, + ) + .expect("supported effects should import"); + let native = shape(&import, 0); + assert!(native.properties.contains_key("clip_path")); + assert_eq!(native.properties["mask_effect"]["mode"], json!("alpha")); + assert_eq!(native.properties["filter"]["primitives"].as_array().unwrap().len(), 2); + assert!(!import.warnings.iter().any(|warning| matches!( + warning, + SvgImportWarning::UnsupportedFeature { + feature: SvgUnsupportedFeature::ClipPath | SvgUnsupportedFeature::Mask | SvgUnsupportedFeature::Filter, + .. + } + ))); + } + + #[test] + fn preserves_unsupported_filter_visuals_as_a_sanitized_fallback() { + let import = parse_svg( + r##""##, + ) + .expect("unsupported effects should fall back"); + assert!(matches!(import.root.children.first(), Some(SvgImportNode::Image(_)))); + assert!(import.warnings.iter().any(|warning| matches!( + warning, + SvgImportWarning::UnsupportedFeature { + feature: SvgUnsupportedFeature::Filter, + action: SvgUnsupportedAction::PreservedStaticFallback, + .. + } + ))); + } + #[test] fn text_is_flattened_to_a_native_text_shape() { let import = parse_svg(r#"Hello world"#) diff --git a/fixtures/svg-import/unsupported/feature-matrix.svg b/fixtures/svg-import/unsupported/feature-matrix.svg index 85605f6..2696ceb 100644 --- a/fixtures/svg-import/unsupported/feature-matrix.svg +++ b/fixtures/svg-import/unsupported/feature-matrix.svg @@ -1 +1 @@ -Static import warning fixture
unsupported
+Static import warning fixture
unsupported
diff --git a/packages/bindings/src/model.ts b/packages/bindings/src/model.ts index 578b1c8..955d728 100644 --- a/packages/bindings/src/model.ts +++ b/packages/bindings/src/model.ts @@ -247,6 +247,130 @@ export type Paint = */ export type PaintValue = string | Paint; +/** + * Whether a native mask reads source alpha or source luminance. + */ +export type MaskMode = 'alpha' | 'luminance'; + +/** + * A native non-destructive mask based on editable path geometry. + */ +export type MaskEffect = { + /** + * How the mask contributes opacity. + */ + mode: MaskMode; + /** + * Mask geometry in the target shape's local coordinates. + */ + geometry: PathGeometry; + /** + * Overall mask opacity. + */ + opacity: number; +}; + +/** + * One supported, editable SVG filter primitive. + */ +export type FilterPrimitive = + | { + type: 'blur'; + /** + * Standard deviation used by the blur. + */ + radius: number; + } + | { + type: 'brightness'; + /** + * Multiplier where `1` leaves the source unchanged. + */ + amount: number; + } + | { + type: 'contrast'; + /** + * Multiplier where `1` leaves the source unchanged. + */ + amount: number; + } + | { + type: 'grayscale'; + /** + * Amount from zero to one. + */ + amount: number; + } + | { + type: 'hue_rotate'; + /** + * Hue rotation in degrees. + */ + degrees: number; + } + | { + type: 'invert'; + /** + * Amount from zero to one. + */ + amount: number; + } + | { + type: 'saturate'; + /** + * Multiplier where `1` leaves the source unchanged. + */ + amount: number; + } + | { + type: 'sepia'; + /** + * Amount from zero to one. + */ + amount: number; + } + | { + type: 'opacity'; + /** + * Multiplier from zero to one. + */ + amount: number; + } + | { + type: 'drop_shadow'; + /** + * Horizontal offset. + */ + dx: number; + /** + * Vertical offset. + */ + dy: number; + /** + * Blur radius. + */ + radius: number; + /** + * Shadow colour. + */ + color: string; + /** + * Shadow opacity. + */ + opacity: number; + }; + +/** + * An ordered list of supported filter primitives. + */ +export type FilterEffect = { + /** + * Filter primitives in application order. + */ + primitives: Array; +}; + /** * Fill rule used to determine the interior of a compound path. */ diff --git a/packages/core/src/export.ts b/packages/core/src/export.ts index d5e3776..802f7f8 100644 --- a/packages/core/src/export.ts +++ b/packages/core/src/export.ts @@ -163,25 +163,25 @@ function shapeToSVG(shape: ShapeRecord, state: EditorState, definitions: string[ switch (shape.type) { case "rect": { - return rectToSVG(shape, transform, definitions); + return withSvgEffects(shape, rectToSVG(shape, transform, definitions), transform, definitions); } case "ellipse": { - return ellipseToSVG(shape, transform, definitions); + return withSvgEffects(shape, ellipseToSVG(shape, transform, definitions), transform, definitions); } case "line": { - return lineToSVG(shape, transform, definitions); + return withSvgEffects(shape, lineToSVG(shape, transform, definitions), transform, definitions); } case "arrow": { - return arrowToSVG(shape, transform, state, definitions); + return withSvgEffects(shape, arrowToSVG(shape, transform, state, definitions), transform, definitions); } case "container": { - return containerToSVG(shape, transform, definitions); + return withSvgEffects(shape, containerToSVG(shape, transform, definitions), transform, definitions); } case "text": { - return textToSVG(shape, transform, definitions); + return withSvgEffects(shape, textToSVG(shape, transform, definitions), transform, definitions); } case "path": { - return pathToSVG(shape, transform, definitions); + return withSvgEffects(shape, pathToSVG(shape, transform, definitions), transform, definitions); } case "image": { const asset = state.doc.assets?.[shape.props.assetId]; @@ -199,15 +199,15 @@ function shapeToSVG(shape: ShapeRecord, state: EditorState, definitions: string[ const captionMarkup = caption?.trim() ? `${escapeXML(caption)}` : ''; - return `${maskMarkup}${image}${captionMarkup}`; + return withSvgEffects(shape, `${maskMarkup}${image}${captionMarkup}`, transform, definitions); } case "reference": { const { w, h, referenceType, value, label } = shape.props; const accent = referenceType === 'url' ? '#2563eb' : referenceType === 'file' ? '#16a34a' : '#7c3aed'; - return `${referenceType.toUpperCase()}${escapeXML(label || value)}`; + return withSvgEffects(shape, `${referenceType.toUpperCase()}${escapeXML(label || value)}`, transform, definitions); } case "markdown": { - return markdownToSVG(shape, transform, definitions); + return withSvgEffects(shape, markdownToSVG(shape, transform, definitions), transform, definitions); } default: { return null; @@ -215,6 +215,56 @@ function shapeToSVG(shape: ShapeRecord, state: EditorState, definitions: string[ } } +function withSvgEffects(shape: ShapeRecord, content: string, transform: string, definitions: string[]): string { + const props = shape.props; + const safeId = shape.id.replace(/[^a-zA-Z0-9_-]/g, '-'); + const attributes: string[] = []; + if (props.clipPath) { + const id = `inkfinite-clip-${safeId}`; + definitions.push(``); + attributes.push(`clip-path="url(#${id})"`); + } + if (props.maskEffect) { + const id = `inkfinite-mask-${safeId}`; + definitions.push(``); + attributes.push(`mask="url(#${id})"`); + } + if (props.filter) { + const id = `inkfinite-filter-${safeId}`; + const primitives = props.filter.primitives.map((primitive, index) => filterPrimitiveToSvg(primitive, index, id)).join(''); + definitions.push(`${primitives}`); + attributes.push(`filter="url(#${id})"`); + } + return attributes.length > 0 ? `${content}` : content; +} + +function filterPrimitiveToSvg( + primitive: NonNullable['primitives'][number], + index: number, + filterId: string +): string { + const input = index === 0 ? 'SourceGraphic' : `${filterId}-${index}`; + const result = `${filterId}-${index + 1}`; + switch (primitive.type) { + case 'blur': return ``; + case 'drop_shadow': return ``; + case 'saturate': return ``; + case 'hue_rotate': return ``; + case 'grayscale': return ``; + case 'brightness': return ``; + case 'contrast': { + const intercept = 0.5 - 0.5 * primitive.amount; + return ``; + } + case 'invert': return ``; + case 'sepia': { + const amount = primitive.amount; + return ``; + } + case 'opacity': return ``; + } +} + function rectToSVG(shape: RectShape, transform: string, definitions: string[]): string { const { w, h, fill, stroke, radius } = shape.props; const fillAttribute = `fill="${paintToSvg(fill, `${shape.id}-fill`, definitions)}"`; diff --git a/packages/core/src/geom.ts b/packages/core/src/geom.ts index 8b7e7f8..6a43f72 100644 --- a/packages/core/src/geom.ts +++ b/packages/core/src/geom.ts @@ -625,6 +625,9 @@ export function hitTestPoint(state: EditorState, worldPoint: Vec2, tolerance = 5 } function hitTestShape(state: EditorState, shape: ShapeRecord, worldPoint: Vec2, tolerance: number): boolean { + const localPoint = worldToLocal(worldPoint, shape); + if (shape.props.clipPath && !pointInPath(localPoint, shape.props.clipPath)) return false; + if (shape.props.maskEffect && !pointInPath(localPoint, shape.props.maskEffect.geometry)) return false; switch (shape.type) { case 'rect': return pointInRect(worldPoint, shape); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 04b445f..f02576c 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -33,5 +33,6 @@ export * from './snapping'; export * from './style-policy'; export * as stencils from './stencils'; export * from './tools'; +export * from './vector-effects'; export * from './ui/filebrowser'; export * from './ui/statusbar'; diff --git a/packages/core/src/model.ts b/packages/core/src/model.ts index 97b26c0..d89aede 100644 --- a/packages/core/src/model.ts +++ b/packages/core/src/model.ts @@ -4,6 +4,10 @@ import type { PathTopologyOperation } from '@inkfinite/bindings/editor'; import type { + FilterEffect as NativeFilterEffect, + FilterPrimitive as NativeFilterPrimitive, + MaskEffect as NativeMaskEffect, + MaskMode as NativeMaskMode, PaintValue as NativePaintValue, PathFillRule as NativePathFillRule, PathGeometry as NativePathGeometry, @@ -79,9 +83,9 @@ export const LayerRecord = { }; export type PaintValue = NativePaintValue; -export type RectProps = { w: number; h: number; fill: PaintValue; stroke: PaintValue; radius: number }; -export type EllipseProps = { w: number; h: number; fill: PaintValue; stroke: PaintValue }; -export type LineProps = { a: Vec2; b: Vec2; stroke: PaintValue; width: number }; +export type RectProps = { w: number; h: number; fill: PaintValue; stroke: PaintValue; radius: number } & ShapeEffects; +export type EllipseProps = { w: number; h: number; fill: PaintValue; stroke: PaintValue } & ShapeEffects; +export type LineProps = { a: Vec2; b: Vec2; stroke: PaintValue; width: number } & ShapeEffects; /** Fill rule for compound native paths. */ export type PathFillRule = NativePathFillRule; @@ -116,8 +120,38 @@ export type PathControlRef = PathAnchorRef & { control: 'quadratic' | 'control_1 /** Topology operations staged for one path commit. */ export type PathTopologyEdit = { shapeId: string; operations: PathTopologyOperation[] }; +/** Native filter definition shared by canvas and SVG rendering. */ +export type FilterEffect = NativeFilterEffect; + +/** One primitive in the editable filter subset. */ +export type FilterPrimitive = NativeFilterPrimitive; + +/** Mask source mode. */ +export type MaskMode = NativeMaskMode; + +/** Native path-based mask shared by canvas and SVG rendering. */ +export type MaskEffect = NativeMaskEffect; + +/** Non-destructive effects accepted by drawable shape properties. */ +export type ShapeEffects = { + /** Editable path used as a local clip region. */ + clipPath?: PathGeometry; + /** Editable alpha or luminance mask. */ + maskEffect?: MaskEffect; + /** Ordered subset of SVG filter primitives. */ + filter?: FilterEffect; +}; + +function cloneShapeEffects(props: ShapeEffects): ShapeEffects { + return { + clipPath: props.clipPath ? (JSON.parse(JSON.stringify(props.clipPath)) as PathGeometry) : undefined, + maskEffect: props.maskEffect ? (JSON.parse(JSON.stringify(props.maskEffect)) as MaskEffect) : undefined, + filter: props.filter ? (JSON.parse(JSON.stringify(props.filter)) as FilterEffect) : undefined + }; +} + /** Native path painting properties stored alongside its geometry. */ -export type PathProps = PathGeometry & { fill?: PaintValue; stroke?: PaintValue; stroke_width?: number }; +export type PathProps = PathGeometry & ShapeEffects & { fill?: PaintValue; stroke?: PaintValue; stroke_width?: number }; /** Rust-resolved arrow shaft geometry projected for interactive consumers. */ export type ResolvedArrowGeometry = NativeResolvedArrowGeometry; @@ -166,7 +200,7 @@ export type ArrowLabel = { text: string; align: 'center' | 'start' | 'end'; offs * Arrow properties using modern format * Modern format: { points, start, end, style, routing?, label? } */ -export type ArrowProps = { +export type ArrowProps = ShapeEffects & { points: Vec2[]; start: ArrowEndpoint; end: ArrowEndpoint; @@ -175,13 +209,19 @@ export type ArrowProps = { label?: ArrowLabel; }; -export type TextProps = { text: string; fontSize: number; fontFamily: string; color: PaintValue; w?: number }; +export type TextProps = ShapeEffects & { + text: string; + fontSize: number; + fontFamily: string; + color: PaintValue; + w?: number; +}; /** Shape used to clip an image while it is rendered. */ export type ImageMask = { kind: 'rectangle' | 'ellipse' | 'rounded'; radius?: number }; /** Embedded image geometry and the asset it displays. */ -export type ImageProps = { +export type ImageProps = ShapeEffects & { w: number; h: number; assetId: string; @@ -194,7 +234,7 @@ export type ImageProps = { }; /** A native link to a web URL, file, or page in the current document. */ -export type ReferenceProps = { +export type ReferenceProps = ShapeEffects & { w: number; h: number; referenceType: 'url' | 'file' | 'page'; @@ -203,7 +243,7 @@ export type ReferenceProps = { }; /** Native frame dimensions and title used for hierarchy selection and overlays. */ -export type ContainerProps = { +export type ContainerProps = ShapeEffects & { w?: number; h?: number; title?: string; @@ -219,7 +259,7 @@ export type ContainerProps = { * - h: auto-computed height from layout (optional override) * - style: font and color settings */ -export type MarkdownProps = { +export type MarkdownProps = ShapeEffects & { md: string; w: number; h?: number; @@ -258,7 +298,7 @@ export type StrokeStyle = { color: PaintValue; opacity: number }; * Points are in world coordinates * Outline and bounds are computed lazily and not persisted */ -export type StrokeProps = { points: StrokePoint[]; style: StrokeStyle; brush: BrushConfig }; +export type StrokeProps = ShapeEffects & { points: StrokePoint[]; style: StrokeStyle; brush: BrushConfig }; /** Semantic fields shared by native shapes and card containers. */ export type ShapeMetadata = { @@ -443,6 +483,7 @@ export const ShapeRecord = { ...(metadata ? { metadata } : {}), props: { ...shape.props, + ...cloneShapeEffects(shape.props), points: shape.props.points.map((p) => [...p] as StrokePoint), style: { ...shape.props.style }, brush: { ...shape.props.brush } @@ -451,12 +492,18 @@ export const ShapeRecord = { } if (shape.type === 'arrow') { if (!Array.isArray(shape.props.points)) { - return { ...shape, ...(metadata ? { metadata } : {}), props: { ...shape.props } } as ArrowShape; + return { + ...shape, + ...(metadata ? { metadata } : {}), + props: { ...shape.props, ...cloneShapeEffects(shape.props) } + } as ArrowShape; } return { ...shape, ...(metadata ? { metadata } : {}), props: { + ...shape.props, + ...cloneShapeEffects(shape.props), points: shape.props.points.map((p) => ({ ...p })), start: { ...shape.props.start }, end: { ...shape.props.end }, @@ -470,7 +517,11 @@ export const ShapeRecord = { }; } if (shape.type === 'markdown') { - return { ...shape, ...(metadata ? { metadata } : {}), props: { ...shape.props } }; + return { + ...shape, + ...(metadata ? { metadata } : {}), + props: { ...shape.props, ...cloneShapeEffects(shape.props) } + }; } if (shape.type === 'image') { return { @@ -478,13 +529,18 @@ export const ShapeRecord = { ...(metadata ? { metadata } : {}), props: { ...shape.props, + ...cloneShapeEffects(shape.props), crop: shape.props.crop ? { ...shape.props.crop } : undefined, mask: shape.props.mask ? { ...shape.props.mask } : undefined } }; } if (shape.type === 'reference') { - return { ...shape, ...(metadata ? { metadata } : {}), props: { ...shape.props } }; + return { + ...shape, + ...(metadata ? { metadata } : {}), + props: { ...shape.props, ...cloneShapeEffects(shape.props) } + }; } if (shape.type === 'path') { return { @@ -492,6 +548,7 @@ export const ShapeRecord = { ...(metadata ? { metadata } : {}), props: { ...shape.props, + ...cloneShapeEffects(shape.props), subpaths: shape.props.subpaths.map((subpath) => ({ ...subpath, handle_modes: subpath.handle_modes ? [...subpath.handle_modes] : subpath.handle_modes, @@ -507,7 +564,11 @@ export const ShapeRecord = { } } as PathShape; } - return { ...shape, ...(metadata ? { metadata } : {}), props: { ...shape.props } } as ShapeRecord; + return { + ...shape, + ...(metadata ? { metadata } : {}), + props: { ...shape.props, ...cloneShapeEffects(shape.props) } + } as ShapeRecord; } }; diff --git a/packages/core/src/persistence/canonical.ts b/packages/core/src/persistence/canonical.ts index 065c7d4..3fe7315 100644 --- a/packages/core/src/persistence/canonical.ts +++ b/packages/core/src/persistence/canonical.ts @@ -607,7 +607,9 @@ function editorProperties(properties: ShapeProperties): ShapeProperties { ['font_size', 'fontSize'], ['font_family', 'fontFamily'], ['asset_id', 'assetId'], - ['reference_type', 'referenceType'] + ['reference_type', 'referenceType'], + ['clip_path', 'clipPath'], + ['mask_effect', 'maskEffect'] ] as const) { if (native in result && !(editor in result)) result[editor] = result[native]; delete result[native]; @@ -794,6 +796,14 @@ function orderAnchor(shapeId: string, parent: ShapeParent, document: Document) { function nativePropertiesForShape(shape: ShapeRecord): ShapeProperties { const properties = JSON.parse(JSON.stringify(shape.props)) as ShapeProperties; + if ('clipPath' in properties) { + properties.clip_path = properties.clipPath; + delete properties.clipPath; + } + if ('maskEffect' in properties) { + properties.mask_effect = properties.maskEffect; + delete properties.maskEffect; + } if (shape.type !== 'container' && !shape.groupId) return properties; if ('w' in properties) { properties.width = properties.w; diff --git a/packages/core/src/vector-effects.ts b/packages/core/src/vector-effects.ts new file mode 100644 index 0000000..c708fcf --- /dev/null +++ b/packages/core/src/vector-effects.ts @@ -0,0 +1,99 @@ +import { localToWorld, worldToLocal } from './geom'; +import type { EditorState } from './reactivity'; +import type { PathGeometry, PathSegment, ShapeRecord, Vec2 } from './model'; + +/** Returns whether the current selection can make one path clip another shape. */ +export function canClipSelection(state: EditorState): boolean { + const selected = state.ui.selectionIds.map((id) => state.doc.shapes[id]).filter(Boolean); + return ( + selected.length === 2 && + selected.some((shape) => shape.type === 'path') && + selected.some((shape) => shape.type !== 'path') + ); +} + +/** + * Uses the selected path as a local, editable clip path on the other selected + * shape. The source path is removed because it becomes effect geometry rather + * than an independently painted object. + */ +export function clipSelection(state: EditorState): EditorState | null { + if (!canClipSelection(state)) return null; + const selected = state.ui.selectionIds.map((id) => state.doc.shapes[id]).filter(Boolean) as ShapeRecord[]; + const source = selected.find((shape) => shape.type === 'path'); + const target = selected.find((shape) => shape.type !== 'path'); + if (!source || source.type !== 'path' || !target) return null; + + const clipPath = transformGeometry(source.props, (point) => worldToLocal(localToWorld(source, point), target)); + const shapes = { ...state.doc.shapes }; + shapes[target.id] = { ...target, props: { ...target.props, clipPath } } as ShapeRecord; + delete shapes[source.id]; + const pages = Object.fromEntries( + Object.entries(state.doc.pages).map(([id, page]) => [ + id, + { ...page, shapeIds: page.shapeIds.filter((shapeId) => shapeId !== source.id) } + ]) + ); + const layers = Object.fromEntries( + Object.entries(state.doc.layers ?? {}).map(([id, layer]) => [ + id, + { ...layer, shapeIds: layer.shapeIds.filter((shapeId) => shapeId !== source.id) } + ]) + ); + const bindings = Object.fromEntries( + Object.entries(state.doc.bindings).filter( + ([, binding]) => binding.fromShapeId !== source.id && binding.toShapeId !== source.id + ) + ); + return { + ...state, + doc: { ...state.doc, shapes, pages, layers, bindings }, + ui: { ...state.ui, selectionIds: [target.id], pathSelection: undefined } + }; +} + +/** Removes the editable clip path from every selected shape. */ +export function removeClipFromSelection(state: EditorState): EditorState | null { + const targets = state.ui.selectionIds + .map((id) => state.doc.shapes[id]) + .filter((shape): shape is ShapeRecord => + Boolean(shape?.props && 'clipPath' in shape.props && shape.props.clipPath) + ); + if (targets.length === 0) return null; + const targetIds = new Set(targets.map((shape) => shape.id)); + const shapes = { ...state.doc.shapes }; + for (const target of targets) { + const props = { ...target.props } as Record; + delete props.clipPath; + shapes[target.id] = { ...target, props } as ShapeRecord; + } + return { ...state, doc: { ...state.doc, shapes }, ui: { ...state.ui, selectionIds: [...targetIds] } }; +} + +function transformGeometry(geometry: PathGeometry, transform: (point: Vec2) => Vec2): PathGeometry { + return { + fill_rule: geometry.fill_rule, + subpaths: geometry.subpaths.map((subpath) => ({ + ...subpath, + segments: subpath.segments.map((segment) => transformSegment(segment, transform)), + handle_modes: subpath.handle_modes ? [...subpath.handle_modes] : subpath.handle_modes + })) + }; +} + +function transformSegment(segment: PathSegment, transform: (point: Vec2) => Vec2): PathSegment { + switch (segment.type) { + case 'move': + case 'line': + return { ...segment, to: transform(segment.to) }; + case 'quadratic': + return { ...segment, control: transform(segment.control), to: transform(segment.to) }; + case 'cubic': + return { + ...segment, + control_1: transform(segment.control_1), + control_2: transform(segment.control_2), + to: transform(segment.to) + }; + } +} diff --git a/packages/core/tests/canonical.test.ts b/packages/core/tests/canonical.test.ts index 04e0b0c..f92e325 100644 --- a/packages/core/tests/canonical.test.ts +++ b/packages/core/tests/canonical.test.ts @@ -40,6 +40,45 @@ describe('toCanonicalDocumentSnapshot', () => { }); }); + it('round-trips native clip paths and filters through the canonical projection', () => { + const page = PageRecord.create('Page 1', 'page:effects'); + const rect = ShapeRecord.createRect( + page.id, + 0, + 0, + { w: 80, h: 60, fill: 'red', stroke: 'none', radius: 0 }, + 'shape:effects' + ); + rect.props.clipPath = { + subpaths: [ + { + segments: [ + { type: 'move', to: { x: 0, y: 0 } }, + { type: 'line', to: { x: 80, y: 0 } }, + { type: 'line', to: { x: 40, y: 60 } } + ], + closed: true + } + ], + fill_rule: 'nonzero' + }; + rect.props.filter = { primitives: [{ type: 'blur', radius: 2 }] }; + page.shapeIds.push(rect.id); + const snapshot = toCanonicalDocumentSnapshot( + { pages: { [page.id]: page }, shapes: { [rect.id]: rect }, bindings: {} }, + { documentId: 'document:effects' } + ); + expect(snapshot.document.shapes[rect.id]?.properties).toMatchObject({ + clip_path: rect.props.clipPath, + filter: rect.props.filter + }); + const roundTripped = fromCanonicalDocumentSnapshot(snapshot); + expect(roundTripped.shapes[rect.id]?.props).toMatchObject({ + clipPath: rect.props.clipPath, + filter: rect.props.filter + }); + }); + it('round-trips typed relationships through the native projection', () => { const page = PageRecord.create('Page 1', 'page:one'); const source = ShapeRecord.createRect( diff --git a/packages/core/tests/export.test.ts b/packages/core/tests/export.test.ts index 6fbd802..4a06791 100644 --- a/packages/core/tests/export.test.ts +++ b/packages/core/tests/export.test.ts @@ -71,6 +71,54 @@ describe("exportToSVG", () => { expect(svg).toContain("fill=\"url(#inkfinite-gradient-"); }); + it("should export native clipping, masks, and filters", () => { + const { state, pageId } = createTestState(); + const rect = ShapeRecord.createRect(pageId, 10, 20, { + w: 100, + h: 50, + fill: "red", + stroke: "none", + radius: 0, + clipPath: { + subpaths: [{ + segments: [ + { type: "move", to: { x: 0, y: 0 } }, + { type: "line", to: { x: 100, y: 0 } }, + { type: "line", to: { x: 50, y: 50 } } + ], + closed: true + }], + fill_rule: "nonzero" + }, + maskEffect: { + mode: "alpha", + geometry: { + subpaths: [{ + segments: [ + { type: "move", to: { x: 0, y: 0 } }, + { type: "line", to: { x: 100, y: 0 } }, + { type: "line", to: { x: 100, y: 50 } }, + { type: "line", to: { x: 0, y: 50 } } + ], + closed: true + }], + fill_rule: "nonzero" + }, + opacity: 0.8 + }, + filter: { primitives: [{ type: "blur", radius: 2 }, { type: "sepia", amount: 1 }] } + }); + state.doc.shapes[rect.id] = rect; + state.doc.pages[pageId].shapeIds.push(rect.id); + + const svg = exportToSVG(state); + expect(svg).toContain(" { const { state, pageId } = createTestState(); const rect = ShapeRecord.createRect(pageId, 10, 20, { w: 100, h: 50, fill: "red", stroke: "black", radius: 0 }); diff --git a/packages/core/tests/vector-effects.test.ts b/packages/core/tests/vector-effects.test.ts new file mode 100644 index 0000000..58f23fa --- /dev/null +++ b/packages/core/tests/vector-effects.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { Camera } from '../src/camera'; +import { clipSelection, removeClipFromSelection } from '../src/vector-effects'; +import { EditorState } from '../src/reactivity'; +import { PageRecord, ShapeRecord } from '../src/model'; + +describe('vector effects', () => { + it('turns a selected path into local clip geometry and removes the source', () => { + const page = PageRecord.create('Page 1', 'page:effects'); + const target = ShapeRecord.createRect( + page.id, + 100, + 50, + { w: 80, h: 60, fill: 'red', stroke: 'none', radius: 0 }, + 'shape:target' + ); + const source = ShapeRecord.createPath( + page.id, + 110, + 60, + { + subpaths: [ + { + segments: [ + { type: 'move', to: { x: 0, y: 0 } }, + { type: 'line', to: { x: 60, y: 0 } }, + { type: 'line', to: { x: 30, y: 40 } } + ], + closed: true + } + ], + fill_rule: 'nonzero' + }, + 'shape:clip' + ); + page.shapeIds = [target.id, source.id]; + const state = EditorState.create(); + state.doc = { pages: { [page.id]: page }, shapes: { [target.id]: target, [source.id]: source }, bindings: {} }; + state.ui = { ...state.ui, currentPageId: page.id, selectionIds: [target.id, source.id] }; + state.camera = Camera.create(); + + const clipped = clipSelection(state); + expect(clipped?.doc.shapes[source.id]).toBeUndefined(); + expect(clipped?.doc.shapes[target.id]?.props.clipPath?.subpaths[0]?.segments[0]).toEqual({ + type: 'move', + to: { x: 10, y: 10 } + }); + expect(clipped?.ui.selectionIds).toEqual([target.id]); + + const removed = clipped && removeClipFromSelection(clipped); + expect(removed?.doc.shapes[target.id]?.props.clipPath).toBeUndefined(); + }); +}); diff --git a/packages/editor/src/renderer.ts b/packages/editor/src/renderer.ts index 454681e..06ed771 100644 --- a/packages/editor/src/renderer.ts +++ b/packages/editor/src/renderer.ts @@ -7,6 +7,7 @@ import type { CursorState, EditorState, EllipseShape, + FilterEffect, LineShape, MarkdownShape, PathGeometry, @@ -542,6 +543,7 @@ function drawShape( context.globalAlpha *= shape.opacity ?? 1; applyShapeTransform(context, shape); + applyShapeEffects(context, shape); switch (shape.type) { case 'rect': { @@ -593,6 +595,67 @@ function drawShape( context.restore(); } +/** Apply the native clip, mask, and filter subset before drawing one shape. */ +function applyShapeEffects(context: CanvasRenderingContext2D, shape: ShapeRecord) { + const props = shape.props as ShapeRecord['props'] & { + clipPath?: PathGeometry; + maskEffect?: { geometry: PathGeometry; opacity?: number }; + filter?: { + primitives: Array< + | { type: 'blur'; radius: number } + | { type: 'brightness'; amount: number } + | { type: 'contrast'; amount: number } + | { type: 'grayscale'; amount: number } + | { type: 'hue_rotate'; degrees: number } + | { type: 'invert'; amount: number } + | { type: 'saturate'; amount: number } + | { type: 'sepia'; amount: number } + | { type: 'opacity'; amount: number } + | { type: 'drop_shadow'; dx: number; dy: number; radius: number; color: string; opacity: number } + >; + }; + }; + if (props.filter) context.filter = filterToCanvas(props.filter); + if (props.clipPath) { + drawNativePath(context, props.clipPath); + context.clip(props.clipPath.fill_rule === 'evenodd' ? 'evenodd' : 'nonzero'); + } + if (props.maskEffect) { + context.globalAlpha *= props.maskEffect.opacity ?? 1; + drawNativePath(context, props.maskEffect.geometry); + context.clip(props.maskEffect.geometry.fill_rule === 'evenodd' ? 'evenodd' : 'nonzero'); + } +} + +function filterToCanvas(filter: FilterEffect): string { + return filter.primitives + .map((primitive) => { + switch (primitive.type) { + case 'blur': + return `blur(${Math.max(0, primitive.radius)}px)`; + case 'brightness': + return `brightness(${Math.max(0, primitive.amount)})`; + case 'contrast': + return `contrast(${Math.max(0, primitive.amount)})`; + case 'grayscale': + return `grayscale(${Math.max(0, Math.min(1, primitive.amount))})`; + case 'hue_rotate': + return `hue-rotate(${primitive.degrees}deg)`; + case 'invert': + return `invert(${Math.max(0, Math.min(1, primitive.amount))})`; + case 'saturate': + return `saturate(${Math.max(0, primitive.amount)})`; + case 'sepia': + return `sepia(${Math.max(0, Math.min(1, primitive.amount))})`; + case 'opacity': + return `opacity(${Math.max(0, Math.min(1, primitive.amount))})`; + case 'drop_shadow': + return `drop-shadow(${primitive.dx}px ${primitive.dy}px ${Math.max(0, primitive.radius)}px ${primitive.color})`; + } + }) + .join(' '); +} + /** Draw an embedded image, preserving its crop window when one is set. */ function drawImage( context: CanvasRenderingContext2D, diff --git a/packages/ui/src/lib/editor/commands.ts b/packages/ui/src/lib/editor/commands.ts index 33731a3..9c84fa5 100644 --- a/packages/ui/src/lib/editor/commands.ts +++ b/packages/ui/src/lib/editor/commands.ts @@ -14,14 +14,12 @@ import { setShapesLocked, SnapshotCommand, ungroupShapes, - convertSelectedShapes -} from '@inkfinite/core'; -import type { - SelectionConversionTarget, - EditorState, - ShapeAlignment, - Store + convertSelectedShapes, + canClipSelection, + clipSelection, + removeClipFromSelection } from '@inkfinite/core'; +import type { SelectionConversionTarget, EditorState, ShapeAlignment, Store } from '@inkfinite/core'; /** Commands shared by the selection toolbar and canvas context menu. */ export type SelectionCommand = @@ -30,6 +28,8 @@ export type SelectionCommand = | 'boolean-intersection' | 'boolean-difference' | 'boolean-exclusion' + | 'clip-selection' + | 'remove-clip' | 'distribute-horizontal' | 'distribute-vertical' | 'stack-horizontal' @@ -59,6 +59,8 @@ export const SELECTION_COMMAND_LABELS: Record = { 'boolean-intersection': 'Intersect Paths', 'boolean-difference': 'Subtract Paths', 'boolean-exclusion': 'Exclude Paths', + 'clip-selection': 'Use Path as Clip', + 'remove-clip': 'Remove Clip Path', 'align-left': 'Align Left', 'align-center': 'Align Center', 'align-right': 'Align Right', @@ -102,6 +104,10 @@ export function applySelectionCommand(state: EditorState, command: SelectionComm return booleanPathSelection(state, 'difference') ?? state; case 'boolean-exclusion': return booleanPathSelection(state, 'exclusion') ?? state; + case 'clip-selection': + return clipSelection(state) ?? state; + case 'remove-clip': + return removeClipFromSelection(state) ?? state; case 'group': return groupShapes(state, ids); case 'ungroup': @@ -158,9 +164,7 @@ export function executeSelectionCommand(store: Store, command: SelectionCommand) const before = store.getState(); const after = applySelectionCommand(before, command); if (after === before) return false; - store.executeCommand( - new SnapshotCommand(SELECTION_COMMAND_LABELS[command], 'doc', before, after) - ); + store.executeCommand(new SnapshotCommand(SELECTION_COMMAND_LABELS[command], 'doc', before, after)); return true; } @@ -212,12 +216,7 @@ export function getCommandPaletteEntries( keywords: 'align distribute layout' })); const booleanEntries: CommandPaletteEntry[] = ( - [ - 'boolean-union', - 'boolean-intersection', - 'boolean-difference', - 'boolean-exclusion' - ] as SelectionCommand[] + ['boolean-union', 'boolean-intersection', 'boolean-difference', 'boolean-exclusion'] as SelectionCommand[] ).map((id) => ({ id, label: SELECTION_COMMAND_LABELS[id], @@ -225,14 +224,16 @@ export function getCommandPaletteEntries( disabled: !canBooleanPathSelection(state), keywords: 'boolean path vector' })); + const clipEntries: CommandPaletteEntry[] = ['clip-selection', 'remove-clip'].map((id) => ({ + id, + label: SELECTION_COMMAND_LABELS[id as SelectionCommand], + group: 'Selection' as const, + disabled: id === 'clip-selection' ? !canClipSelection(state) : selectedCount === 0, + keywords: 'clip path mask vector' + })); const entries: CommandPaletteEntry[] = [ { id: 'select-all', label: 'Select all shapes', group: 'Selection', shortcut: 'โŒ˜/Ctrl A' }, - { - id: 'clear-selection', - label: 'Clear selection', - group: 'Selection', - shortcut: 'Escape' - }, + { id: 'clear-selection', label: 'Clear selection', group: 'Selection', shortcut: 'Escape' }, { id: 'duplicate', label: 'Duplicate selection', @@ -251,6 +252,7 @@ export function getCommandPaletteEntries( }, ...alignmentEntries, ...booleanEntries, + ...clipEntries, { id: 'convert-to-rect', label: SELECTION_COMMAND_LABELS['convert-to-rect'], @@ -265,18 +267,8 @@ export function getCommandPaletteEntries( disabled: selectedCount === 0, keywords: 'shape convert ellipse oval' }, - { - id: 'group', - label: SELECTION_COMMAND_LABELS.group, - group: 'Selection', - disabled: selectedCount < 2 - }, - { - id: 'ungroup', - label: SELECTION_COMMAND_LABELS.ungroup, - group: 'Selection', - disabled: selectedCount === 0 - }, + { id: 'group', label: SELECTION_COMMAND_LABELS.group, group: 'Selection', disabled: selectedCount < 2 }, + { id: 'ungroup', label: SELECTION_COMMAND_LABELS.ungroup, group: 'Selection', disabled: selectedCount === 0 }, { id: 'forward', label: SELECTION_COMMAND_LABELS.forward, @@ -305,18 +297,8 @@ export function getCommandPaletteEntries( disabled: selectedCount === 0, shortcut: 'โ‡งโŒ˜/Ctrl [' }, - { - id: 'lock', - label: SELECTION_COMMAND_LABELS.lock, - group: 'Selection', - disabled: selectedCount === 0 - }, - { - id: 'unlock', - label: SELECTION_COMMAND_LABELS.unlock, - group: 'Selection', - disabled: selectedCount === 0 - }, + { id: 'lock', label: SELECTION_COMMAND_LABELS.lock, group: 'Selection', disabled: selectedCount === 0 }, + { id: 'unlock', label: SELECTION_COMMAND_LABELS.unlock, group: 'Selection', disabled: selectedCount === 0 }, ...(platform === 'desktop' ? [ { diff --git a/packages/ui/src/lib/editor/components/SelectionControls.svelte b/packages/ui/src/lib/editor/components/SelectionControls.svelte index 3ffd489..bdaefe7 100644 --- a/packages/ui/src/lib/editor/components/SelectionControls.svelte +++ b/packages/ui/src/lib/editor/components/SelectionControls.svelte @@ -2,6 +2,7 @@ import type { ArrowShape, EditorState as EditorStateType, + FilterEffect, MarkdownShape, PaintValue, ShapeMetadata, @@ -12,6 +13,7 @@ } from '@inkfinite/core'; import { canBooleanPathSelection, + canClipSelection, cardChildren, EditorState, getSelectedShapes, @@ -203,6 +205,11 @@ selectionCount > 0 && selectedShapes.every((shape) => shape.locked) ); let booleanPathSelection = $derived(canBooleanPathSelection(editorState)); + let clipSelectionAvailable = $derived(canClipSelection(editorState)); + let selectedClipCount = $derived( + selectedShapes.filter((shape) => Boolean('clipPath' in shape.props && shape.props.clipPath)).length + ); + let effectTarget = $derived(selectionCount === 1 ? selectedShapes[0] : undefined); let fillColorState = $derived.by(() => { const shared = getSharedPaintValue(fillTargets.map(getFillPaint)); @@ -276,6 +283,43 @@ return { value: shared ?? true, mixed: values.length > 1 && shared === null }; } + function filterPreset(shape: ShapeRecord | undefined): string { + const primitive = shape?.props.filter?.primitives[0]; + return primitive?.type ?? 'none'; + } + + function applyMaskMode(event: Event) { + const mode = (event.currentTarget as HTMLSelectElement).value as 'alpha' | 'luminance'; + updateSelectedShapes('Set mask mode', (shape) => + shape.props.maskEffect + ? ({ + ...shape, + props: { ...shape.props, maskEffect: { ...shape.props.maskEffect, mode } } + } as ShapeRecord) + : shape + ); + } + + function applyFilterPreset(event: Event) { + const preset = (event.currentTarget as HTMLSelectElement).value; + const filter: FilterEffect | undefined = + preset === 'blur' + ? { primitives: [{ type: 'blur', radius: 4 }] } + : preset === 'grayscale' + ? { primitives: [{ type: 'grayscale', amount: 1 }] } + : preset === 'drop_shadow' + ? { + primitives: [ + { type: 'drop_shadow', dx: 3, dy: 3, radius: 4, color: '#000000', opacity: 0.35 } + ] + } + : undefined; + updateSelectedShapes('Set filter', (shape) => ({ + ...shape, + props: { ...shape.props, filter } + }) as ShapeRecord); + } + function metadataForShape(shape: ShapeRecord): ShapeMetadata { return ( shape.metadata ?? { @@ -1343,6 +1387,56 @@ {/if} + {#if effectTarget} +
+

Effects

+
+ {#if effectTarget.props.maskEffect} + + {/if} + +
+
+ {/if} + + {#if clipSelectionAvailable || selectedClipCount > 0} +
+

Clipping

+
+ {#if clipSelectionAvailable} + + {/if} + {#if selectedClipCount > 0} + + {/if} +
+
+ {/if} + {#if booleanPathSelection}