From e4ab060963d2cb156bc485f08913bd147d384a3c Mon Sep 17 00:00:00 2001 From: Kulratan Date: Sun, 5 Jul 2026 16:08:14 +0000 Subject: [PATCH] Add support for SVG gradient units import/export (rebased on master) --- .../graph_operation_message_handler.rs | 47 ++++++++++++++++++- node-graph/libraries/core-types/src/lib.rs | 4 +- node-graph/libraries/core-types/src/list.rs | 6 +++ .../libraries/rendering/src/render_ext.rs | 29 +++++++++--- .../libraries/rendering/src/renderer.rs | 26 +++++++--- .../libraries/vector-types/src/gradient.rs | 18 +++++++ node-graph/libraries/vector-types/src/lib.rs | 2 +- 7 files changed, 114 insertions(+), 18 deletions(-) diff --git a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs index 4e64599e79..52f773dc25 100644 --- a/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs +++ b/editor/src/messages/portfolio/document/graph_operation/graph_operation_message_handler.rs @@ -13,7 +13,7 @@ use graph_craft::document::{NodeId, NodeInput}; use graphene_std::list::List; use graphene_std::renderer::convert_usvg_path::convert_usvg_path; use graphene_std::text::{Font, TypesettingConfig}; -use graphene_std::vector::style::{GradientSpreadMethod, GradientStop, GradientStops, GradientType, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; +use graphene_std::vector::style::{GradientSpreadMethod, GradientStop, GradientStops, GradientType, GradientUnits, PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use graphene_std::{Artboard, Color}; #[derive(ExtractField)] @@ -562,6 +562,51 @@ fn extract_graphite_gradient_stops(svg: &str) -> HashMap result } +#[allow(dead_code)] +fn extract_svg_gradient_units(svg: &str) -> HashMap { + let mut result = HashMap::new(); + let mut gradient_nodes = HashMap::new(); + + let doc = match usvg::roxmltree::Document::parse(svg) { + Ok(doc) => doc, + Err(_) => return result, + }; + + for node in doc.descendants() { + if matches!(node.tag_name().name(), "linearGradient" | "radialGradient") + && let Some(gradient_id) = node.attribute("id") + { + gradient_nodes.insert(gradient_id, node); + } + } + + fn resolve_units<'a>(node: usvg::roxmltree::Node<'a, 'a>, gradient_nodes: &HashMap<&'a str, usvg::roxmltree::Node<'a, 'a>>, seen: &mut Vec<&'a str>) -> Option { + match node.attribute("gradientUnits") { + Some("userSpaceOnUse") => return Some(GradientUnits::UserSpaceOnUse), + Some("objectBoundingBox") => return Some(GradientUnits::ObjectBoundingBox), + Some(_) => return None, + None => {} + } + + let href = node.attribute("href").or_else(|| node.attribute(("http://www.w3.org/1999/xlink", "href")))?; + let referenced_id = href.strip_prefix('#')?; + if seen.contains(&referenced_id) { + return None; + } + let referenced_node = *gradient_nodes.get(referenced_id)?; + seen.push(referenced_id); + resolve_units(referenced_node, gradient_nodes, seen) + } + + for (&gradient_id, &node) in &gradient_nodes { + // Per SVG spec, the default gradientUnits is objectBoundingBox. + let units = resolve_units(node, &gradient_nodes, &mut vec![gradient_id]).unwrap_or(GradientUnits::ObjectBoundingBox); + result.insert(gradient_id.to_string(), units); + } + + result +} + fn parse_hex_stop_color(hex: &str, opacity: f32) -> Option { let hex = hex.strip_prefix('#')?; if hex.len() != 6 { diff --git a/node-graph/libraries/core-types/src/lib.rs b/node-graph/libraries/core-types/src/lib.rs index 878e7f5360..d9e0216369 100644 --- a/node-graph/libraries/core-types/src/lib.rs +++ b/node-graph/libraries/core-types/src/lib.rs @@ -25,8 +25,8 @@ pub use graphene_hash; pub use graphene_hash::CacheHash; pub use list::{ ATTR_BACKGROUND, ATTR_BLEND_MODE, ATTR_CLIP, ATTR_CLIPPING_MASK, ATTR_DIMENSIONS, ATTR_EDITOR_CLICK_TARGET, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_EDITOR_TEXT_FRAME, ATTR_END, - ATTR_FONT, ATTR_FONT_SIZE, ATTR_GRADIENT_TYPE, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_LOCATION, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_NAME, ATTR_OPACITY, ATTR_OPACITY_FILL, - ATTR_SPREAD_METHOD, ATTR_START, ATTR_TEXT_ALIGN, ATTR_TRANSFORM, ATTR_TYPE, + ATTR_FONT, ATTR_FONT_SIZE, ATTR_GRADIENT_LEGACY, ATTR_GRADIENT_TYPE, ATTR_GRADIENT_UNITS, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_LOCATION, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, + ATTR_NAME, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, ATTR_START, ATTR_TEXT_ALIGN, ATTR_TRANSFORM, ATTR_TYPE, }; pub use memo::MemoHash; pub use no_std_types::AsU32; diff --git a/node-graph/libraries/core-types/src/list.rs b/node-graph/libraries/core-types/src/list.rs index b0d5888115..4bd42f18f7 100644 --- a/node-graph/libraries/core-types/src/list.rs +++ b/node-graph/libraries/core-types/src/list.rs @@ -58,6 +58,12 @@ pub const ATTR_CLIP: &str = "clip"; pub const ATTR_SPREAD_METHOD: &str = "spread_method"; /// Gradient's `GradientType` (`Linear` or `Radial`). pub const ATTR_GRADIENT_TYPE: &str = "gradient_type"; +/// Gradient's SVG coordinate system (`userSpaceOnUse` or `objectBoundingBox`). +pub const ATTR_GRADIENT_UNITS: &str = "gradient_units"; +// TODO: Eventually remove this document upgrade code +/// `bool` runtime marker (never serialized) flagging a gradient that came from the legacy bounding-box-relative `Fill::Gradient`, +/// so the renderer reproduces the pre-#4241 positioning instead of the new absolute path. +pub const ATTR_GRADIENT_LEGACY: &str = "gradient_legacy"; /// Vector graphics object's filled area paint, of type List where T is any graphic type. pub const ATTR_FILL: &str = "fill"; /// Vector graphics object's stroke paint, of type List where T is any graphic type. diff --git a/node-graph/libraries/rendering/src/render_ext.rs b/node-graph/libraries/rendering/src/render_ext.rs index f25bb069ff..4272cd07c3 100644 --- a/node-graph/libraries/rendering/src/render_ext.rs +++ b/node-graph/libraries/rendering/src/render_ext.rs @@ -3,10 +3,10 @@ use crate::{Render, RenderSvgSegmentList, SvgRender}; use core_types::color::SRGBA8; use core_types::list::List; use core_types::uuid::generate_uuid; -use core_types::{ATTR_GRADIENT_TYPE, ATTR_SPREAD_METHOD, ATTR_TRANSFORM, Color}; +use core_types::{ATTR_GRADIENT_TYPE, ATTR_GRADIENT_UNITS, ATTR_SPREAD_METHOD, ATTR_TRANSFORM, Color}; use glam::{DAffine2, DVec2}; use graphic_types::Graphic; -use graphic_types::vector_types::gradient::GradientType; +use graphic_types::vector_types::gradient::{GradientType, GradientUnits}; use graphic_types::vector_types::vector::style::{PaintOrder, Stroke, StrokeAlign, StrokeCap, StrokeJoin}; use std::fmt::Write; use vector_types::GradientStops; @@ -18,6 +18,16 @@ pub enum PaintTarget { Stroke, } +fn svg_gradient_transform(transform: DAffine2, bounds: DAffine2, units: GradientUnits) -> (GradientUnits, String) { + let (units, transform) = match units { + GradientUnits::UserSpaceOnUse => (GradientUnits::UserSpaceOnUse, transform), + GradientUnits::ObjectBoundingBox if transform_is_invertible(bounds) => (GradientUnits::ObjectBoundingBox, bounds.inverse() * transform), + GradientUnits::ObjectBoundingBox => (GradientUnits::UserSpaceOnUse, transform), + }; + + (units, format_transform_matrix(transform)) +} + impl PaintTarget { fn paint_attr(self) -> &'static str { match self { @@ -94,6 +104,7 @@ impl RenderExt for List { let Some(stops) = self.element(0) else { return 0 }; let gradient_type: GradientType = self.attribute_cloned_or_default(ATTR_GRADIENT_TYPE, 0); + let gradient_units: GradientUnits = self.attribute_cloned_or_default(ATTR_GRADIENT_UNITS, 0); let local_gradient_transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, 0); let spread_method: GradientSpreadMethod = self.attribute_cloned_or_default(ATTR_SPREAD_METHOD, 0); @@ -122,7 +133,7 @@ impl RenderExt for List { let document_transform = item_transform * local_gradient_transform; let placement = gradient_placement(document_transform, gradient_type); - let gradient_transform = format_transform_matrix(element_transform_inverse * placement); + let (gradient_units, gradient_transform) = svg_gradient_transform(element_transform_inverse * placement, _bounds, gradient_units); let gradient_transform = if gradient_transform.is_empty() { String::new() } else { @@ -141,15 +152,19 @@ impl RenderExt for List { GradientType::Linear => { let _ = write!( svg_defs, - r#"{}"#, - gradient_id, stop + r#"{}"#, + gradient_id, + gradient_units.svg_name(), + stop ); } GradientType::Radial => { let _ = write!( svg_defs, - r#"{}"#, - gradient_id, stop + r#"{}"#, + gradient_id, + gradient_units.svg_name(), + stop ); } } diff --git a/node-graph/libraries/rendering/src/renderer.rs b/node-graph/libraries/rendering/src/renderer.rs index 74c2405ae0..c6907d005a 100644 --- a/node-graph/libraries/rendering/src/renderer.rs +++ b/node-graph/libraries/rendering/src/renderer.rs @@ -14,8 +14,8 @@ use core_types::transform::Footprint; use core_types::uuid::{NodeId, generate_uuid}; use core_types::{ ATTR_BACKGROUND, ATTR_BLEND_MODE, ATTR_CLIP, ATTR_CLIPPING_MASK, ATTR_DIMENSIONS, ATTR_EDITOR_CLICK_TARGET, ATTR_EDITOR_LAYER_PATH, ATTR_EDITOR_MERGED_LAYERS, ATTR_EDITOR_TEXT_FRAME, ATTR_FONT, - ATTR_FONT_SIZE, ATTR_GRADIENT_TYPE, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_LOCATION, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, - ATTR_TEXT_ALIGN, ATTR_TRANSFORM, + ATTR_FONT_SIZE, ATTR_GRADIENT_LEGACY, ATTR_GRADIENT_TYPE, ATTR_GRADIENT_UNITS, ATTR_LETTER_SPACING, ATTR_LETTER_TILT, ATTR_LINE_HEIGHT, ATTR_LOCATION, ATTR_MAX_HEIGHT, ATTR_MAX_WIDTH, + ATTR_OPACITY, ATTR_OPACITY_FILL, ATTR_SPREAD_METHOD, ATTR_TEXT_ALIGN, ATTR_TRANSFORM, }; use dyn_any::DynAny; use glam::{DAffine2, DMat2, DVec2}; @@ -39,7 +39,7 @@ use std::fmt::Write; use std::hash::Hash; use std::ops::Deref; use std::sync::{Arc, LazyLock}; -use vector_types::gradient::GradientSpreadMethod; +use vector_types::gradient::{GradientSpreadMethod, GradientUnits}; use vello::*; #[derive(Clone, Copy, Debug, PartialEq)] @@ -2064,12 +2064,14 @@ impl Render for List { for index in 0..self.len() { let Some(gradient) = self.element(index) else { continue }; let transform: DAffine2 = self.attribute_cloned_or_default(ATTR_TRANSFORM, index); + let mut gradient_units: GradientUnits = self.attribute_cloned_or_default(ATTR_GRADIENT_UNITS, index); let blend_mode: BlendMode = self.attribute_cloned_or_default(ATTR_BLEND_MODE, index); let opacity_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY, index, 1.); let opacity_fill_attr: f64 = self.attribute_cloned_or(ATTR_OPACITY_FILL, index, 1.); let spread_method: GradientSpreadMethod = self.attribute_cloned_or_default(ATTR_SPREAD_METHOD, index); let gradient_type: GradientType = self.attribute_cloned_or_default(ATTR_GRADIENT_TYPE, index); let tag = if thumbnail_rect.is_some() { "rect" } else { "polyline" }; + const MAX: f64 = 1e7; render.leaf_tag(tag, |attributes| { if let Some((min, size)) = thumbnail_rect { attributes.push("x", min.x.to_string()); @@ -2080,7 +2082,6 @@ impl Render for List { // Stand-in for an infinite background. Chrome's SVG renderer keeps internal coordinates in f32 and loses // precision past ~2^24 (~16.7 million), causing tile-boundary artifacts that pop in and out during panning. // 1e7 stays under that limit while still being far larger than any practical document extent. - const MAX: f64 = 1e7; attributes.push("points", format!("{MAX},{MAX} -{MAX},{MAX} -{MAX},-{MAX} {MAX},-{MAX}")); } @@ -2097,7 +2098,16 @@ impl Render for List { } // render_thumbnail already added the footprint transform - let gradient_transform = if render_params.thumbnail { transform } else { render_params.footprint.transform * transform }; + let mut gradient_transform = if render_params.thumbnail { transform } else { render_params.footprint.transform * transform }; + if gradient_units == GradientUnits::ObjectBoundingBox { + let (min, size) = thumbnail_rect.unwrap_or((DVec2::splat(-MAX), DVec2::splat(MAX * 2.))); + let bounds = DAffine2::from_scale_angle_translation(size, 0., min); + if transform_is_invertible(bounds) { + gradient_transform = bounds.inverse() * gradient_transform; + } else { + gradient_units = GradientUnits::UserSpaceOnUse; + } + } let gradient_transform_matrix = format_transform_matrix(gradient_transform); let gradient_transform_attribute = if gradient_transform_matrix.is_empty() { String::new() @@ -2117,13 +2127,15 @@ impl Render for List { GradientType::Linear => { let _ = write!( &mut attributes.0.svg_defs, - r#"{stop_string}"# + r#"{stop_string}"#, + gradient_units.svg_name() ); } GradientType::Radial => { let _ = write!( &mut attributes.0.svg_defs, - r#"{stop_string}"# + r#"{stop_string}"#, + gradient_units.svg_name() ); } } diff --git a/node-graph/libraries/vector-types/src/gradient.rs b/node-graph/libraries/vector-types/src/gradient.rs index 00dc7ea9d6..0b54106ea4 100644 --- a/node-graph/libraries/vector-types/src/gradient.rs +++ b/node-graph/libraries/vector-types/src/gradient.rs @@ -14,6 +14,24 @@ pub enum GradientType { Radial, } +#[cfg_attr(feature = "wasm", derive(tsify::Tsify))] +#[derive(Default, PartialEq, Eq, Clone, Copy, Debug, Hash, graphene_hash::CacheHash, DynAny)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub enum GradientUnits { + #[default] + UserSpaceOnUse, + ObjectBoundingBox, +} + +impl GradientUnits { + pub fn svg_name(&self) -> &'static str { + match self { + GradientUnits::UserSpaceOnUse => "userSpaceOnUse", + GradientUnits::ObjectBoundingBox => "objectBoundingBox", + } + } +} + // TODO: Someday we could switch this to a Box[T] to avoid over-allocation /// A list of colors (linear, unassociated alpha) associated with positions (in the range 0 to 1) along a gradient. /// diff --git a/node-graph/libraries/vector-types/src/lib.rs b/node-graph/libraries/vector-types/src/lib.rs index 6ff415e567..8ad13f836e 100644 --- a/node-graph/libraries/vector-types/src/lib.rs +++ b/node-graph/libraries/vector-types/src/lib.rs @@ -8,7 +8,7 @@ pub mod vector; // Re-export commonly used types at the crate root pub use core_types as gcore; -pub use gradient::{GradientSpreadMethod, GradientStop, GradientStops, GradientType}; +pub use gradient::{GradientSpreadMethod, GradientStop, GradientStops, GradientType, GradientUnits}; pub use math::{QuadExt, RectExt}; pub use subpath::Subpath; pub use vector::Vector;