From 435f093b585007309d9d26aa1d18e23a6bccb536 Mon Sep 17 00:00:00 2001 From: "Dylan M. Taylor" Date: Fri, 13 Nov 2026 05:40:01 -0500 Subject: [PATCH] Preserve SVG gradients and fallback metadata --- crates/svg/src/tree/document.rs | 163 +++++++++++++++++++++++++++++--- 1 file changed, 150 insertions(+), 13 deletions(-) diff --git a/crates/svg/src/tree/document.rs b/crates/svg/src/tree/document.rs index 5a4b19117..4474ba08a 100644 --- a/crates/svg/src/tree/document.rs +++ b/crates/svg/src/tree/document.rs @@ -21,8 +21,39 @@ pub struct Document { /// A group or path in document order. #[derive(Clone, Debug, PartialEq)] pub enum Node { - Group { children: Vec, opacity: f32 }, - Path(Path), + Group { children: Vec, opacity: f32, transform: [f64; 6], bounds: [f64; 4], features: GroupFeatures }, + Path(Box), + Image(Image), +} + +/// Features that need a renderer capable of SVG compositing or external resources. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct GroupFeatures { + pub filters: bool, + pub clip_path: bool, + pub mask: bool, + pub non_normal_blend: bool, + pub isolation: bool, +} + +/// An embedded image node and its bounded source data/placement. +#[derive(Clone, Debug, PartialEq)] +pub struct Image { + pub kind: ImageKind, + pub visible: bool, + pub transform: [f64; 6], + pub bounds: [f64; 4], + pub encoded: Option>>, + pub svg: Option>, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ImageKind { + Jpeg, + Png, + Gif, + WebP, + Svg, } /// A transformed path with its resolved fill and stroke. @@ -47,10 +78,30 @@ pub enum Segment { #[derive(Clone, Debug, PartialEq)] pub enum Paint { Color { rgb: [u8; 3], opacity: f32 }, - Gradient { first_rgb: Option<[u8; 3]>, opacity: f32 }, + Gradient { kind: GradientKind, stops: Vec, transform: [f64; 6], spread: Spread, opacity: f32 }, Pattern, } +#[derive(Clone, Debug, PartialEq)] +pub enum GradientKind { + Linear { start: [f64; 2], end: [f64; 2] }, + Radial { center: [f64; 2], radius: f64, focus: [f64; 2], focus_radius: f64 }, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct GradientStop { + pub offset: f64, + pub rgb: [u8; 3], + pub opacity: f32, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Spread { + Pad, + Reflect, + Repeat, +} + /// A resolved outline. #[derive(Clone, Debug, PartialEq)] pub struct Stroke { @@ -86,15 +137,60 @@ impl Document { } } +fn transform(t: usvg::Transform) -> [f64; 6] { + [f64::from(t.sx), f64::from(t.ky), f64::from(t.kx), f64::from(t.sy), f64::from(t.tx), f64::from(t.ty)] +} + +fn rect(r: usvg::Rect) -> [f64; 4] { + [f64::from(r.x()), f64::from(r.y()), f64::from(r.width()), f64::from(r.height())] +} + +fn spread(s: usvg::SpreadMethod) -> Spread { + match s { + usvg::SpreadMethod::Pad => Spread::Pad, + usvg::SpreadMethod::Reflect => Spread::Reflect, + usvg::SpreadMethod::Repeat => Spread::Repeat, + } +} + fn color(paint: &usvg::Paint, opacity: f32) -> Paint { match paint { usvg::Paint::Color(c) => Paint::Color { rgb: [c.red, c.green, c.blue], opacity }, - usvg::Paint::LinearGradient(g) => { - Paint::Gradient { first_rgb: g.stops().first().map(|s| [s.color().red, s.color().green, s.color().blue]), opacity } - } - usvg::Paint::RadialGradient(g) => { - Paint::Gradient { first_rgb: g.stops().first().map(|s| [s.color().red, s.color().green, s.color().blue]), opacity } - } + usvg::Paint::LinearGradient(g) => Paint::Gradient { + kind: GradientKind::Linear { start: [f64::from(g.x1()), f64::from(g.y1())], end: [f64::from(g.x2()), f64::from(g.y2())] }, + stops: g + .stops() + .iter() + .map(|s| GradientStop { + offset: f64::from(s.offset().get()), + rgb: [s.color().red, s.color().green, s.color().blue], + opacity: s.opacity().get(), + }) + .collect(), + transform: transform(g.transform()), + spread: spread(g.spread_method()), + opacity, + }, + usvg::Paint::RadialGradient(g) => Paint::Gradient { + kind: GradientKind::Radial { + center: [f64::from(g.cx()), f64::from(g.cy())], + radius: f64::from(g.r().get()), + focus: [f64::from(g.fx()), f64::from(g.fy())], + focus_radius: f64::from(g.fr().get()), + }, + stops: g + .stops() + .iter() + .map(|s| GradientStop { + offset: f64::from(s.offset().get()), + rgb: [s.color().red, s.color().green, s.color().blue], + opacity: s.opacity().get(), + }) + .collect(), + transform: transform(g.transform()), + spread: spread(g.spread_method()), + opacity, + }, usvg::Paint::Pattern(_) => Paint::Pattern, } } @@ -148,7 +244,7 @@ fn convert_group(group: &usvg::Group, depth: usize, max_depth: usize, doc: &mut dash_array: s.dasharray().map(|d| d.iter().map(|v| f64::from(*v) * scale).collect()), } }); - result.push(Node::Path(Path { segments, fill, stroke })); + result.push(Node::Path(Box::new(Path { segments, fill, stroke }))); } usvg::Node::Group(child) => { if depth >= max_depth { @@ -157,7 +253,19 @@ fn convert_group(group: &usvg::Group, depth: usize, max_depth: usize, doc: &mut } let children = convert_group(child, depth + 1, max_depth, doc); if !children.is_empty() { - result.push(Node::Group { children, opacity: child.opacity().get() }); + result.push(Node::Group { + children, + opacity: child.opacity().get(), + transform: transform(child.abs_transform()), + bounds: rect(child.abs_layer_bounding_box().to_rect()), + features: GroupFeatures { + filters: !child.filters().is_empty(), + clip_path: child.clip_path().is_some(), + mask: child.mask().is_some(), + non_normal_blend: child.blend_mode() != usvg::BlendMode::Normal, + isolation: child.should_isolate(), + }, + }); } } usvg::Node::Text(text) => { @@ -167,10 +275,39 @@ fn convert_group(group: &usvg::Group, depth: usize, max_depth: usize, doc: &mut } let children = convert_group(text.flattened(), depth + 1, max_depth, doc); if !children.is_empty() { - result.push(Node::Group { children, opacity: 1.0 }); + result.push(Node::Group { + children, + opacity: 1.0, + transform: transform(text.abs_transform()), + bounds: rect(text.abs_bounding_box()), + features: GroupFeatures::default(), + }); } } - usvg::Node::Image(_) => doc.images += 1, + usvg::Node::Image(image) => { + doc.images += 1; + let kind = match image.kind() { + usvg::ImageKind::JPEG(_) => ImageKind::Jpeg, + usvg::ImageKind::PNG(_) => ImageKind::Png, + usvg::ImageKind::GIF(_) => ImageKind::Gif, + usvg::ImageKind::WEBP(_) => ImageKind::WebP, + usvg::ImageKind::SVG(_) => ImageKind::Svg, + }; + let (encoded, svg) = match image.kind() { + usvg::ImageKind::JPEG(data) | usvg::ImageKind::PNG(data) | usvg::ImageKind::GIF(data) | usvg::ImageKind::WEBP(data) => { + (Some(data.clone()), None) + } + usvg::ImageKind::SVG(tree) => (None, Some(Box::new(Document::from_tree(tree, max_depth.saturating_sub(depth + 1))))), + }; + result.push(Node::Image(Image { + kind, + visible: image.is_visible(), + transform: transform(image.abs_transform()), + bounds: rect(image.abs_bounding_box()), + encoded, + svg, + })); + } } } result