Skip to main content

bevy_react/svg/
mod.rs

1//! The SVG subsystem: parse SVG documents and rasterize them to pixels.
2//!
3//! Documents parse into a [`usvg::Tree`] — an immutable, `Send + Sync`
4//! simplified scene graph — and rasterize **CPU-side** via `resvg` onto the
5//! same `tiny-skia` [`Pixmap`](tiny_skia::Pixmap) type the [`crate::canvas`]
6//! module paints, so SVG output plugs into the existing "an image we paint
7//! into" [`ImageNode`](bevy::ui::widget::ImageNode) plumbing without touching
8//! Bevy's render internals. Like `canvas`, this is a leaf module: the bridge
9//! machinery reaches into `crate::svg`, never the reverse.
10//!
11//! Licensing note: `resvg`/`usvg` were historically MPL-2.0, but as of the
12//! linebender-maintained releases (0.47 included) they are dual-licensed
13//! `MIT OR Apache-2.0` — no license caveat applies.
14
15mod asset;
16mod hit;
17mod image;
18pub(crate) mod interact;
19mod paint;
20pub(crate) mod pick;
21mod protocol;
22mod raster;
23mod text;
24mod walk;
25
26use bevy::asset::Handle;
27use bevy::ecs::component::Component;
28use bevy::math::{UVec2, Vec2};
29use bevy::ui::widget::ImageMeasure;
30use bevy::ui::{ComputedNode, ContentSize, NodeMeasure, VisualBox};
31
32pub use asset::{SvgAssetLoader, SvgDocument, SvgParseError, parse_svg_bytes};
33pub(crate) use image::{ensure_svg_image, is_svg_src, warn_ignored_attrs};
34pub use interact::SvgUserPos;
35#[cfg(test)]
36pub(crate) use protocol::st;
37pub use protocol::{
38    FillRuleKind, LinecapKind, LinejoinKind, PathData, PathSeg, ShapeAttrs, ShapePaint,
39    ShapeTransform, ShapeTransitionSpec, ViewBox,
40};
41pub(crate) use protocol::{
42    NUMERIC_ATTR_COUNT, NUMERIC_ATTRS, de_view_box, numeric_attr, numeric_attr_mut,
43};
44pub use raster::{rasterize_document, stamp_svg_measures, update_svg_surfaces};
45
46/// The 100×100-viewBox red-circle fixture shared by the svg test suites
47/// (parse, raster, and the rasterizer spike below).
48#[cfg(test)]
49pub(crate) const CIRCLE_SVG: &str = r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><circle cx="50" cy="50" r="40" fill="#f00"/></svg>"##;
50
51/// The kind of a JSX SVG shape child: which wire intrinsic (`<circle>`,
52/// `<rect>`, …, `<g>`) spawned it, and therefore which [`ShapeAttrs`] fields
53/// the rasterizer reads.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum ShapeKind {
56    Path,
57    Rect,
58    Circle,
59    Ellipse,
60    Line,
61    Polyline,
62    Polygon,
63    Group,
64}
65
66impl ShapeKind {
67    /// Map a create-op `kind` (the bare JSX intrinsic name) to its shape
68    /// kind; `None` for non-shape kinds. This is the create dispatch's arm
69    /// guard: any kind it recognizes mounts as a Node-less [`SvgShape`].
70    pub fn from_kind(kind: &str) -> Option<ShapeKind> {
71        Some(match kind {
72            "path" => Self::Path,
73            "rect" => Self::Rect,
74            "circle" => Self::Circle,
75            "ellipse" => Self::Ellipse,
76            "line" => Self::Line,
77            "polyline" => Self::Polyline,
78            "polygon" => Self::Polygon,
79            "g" => Self::Group,
80            _ => return None,
81        })
82    }
83}
84
85/// One shape child of a JSX `<svg>` element: a **Node-less** entity (the
86/// `textSpan` precedent — no layout box, no style, no `stamp_common`)
87/// carrying only its kind and folded attrs. The rasterizer walks the `<svg>`
88/// root's `Children` to paint these, and the hit-tester reads the same data.
89/// Updates rewrite `attrs` compare-before-write, so `Changed<SvgShape>` is a
90/// sound dirt signal for the raster.
91#[derive(Component, Debug, Clone, PartialEq)]
92pub struct SvgShape {
93    pub kind: ShapeKind,
94    pub attrs: ShapeAttrs,
95}
96
97/// The element-owned raster surface of a node that displays an SVG.
98///
99/// Present on an `<image>` whose `src` names an `.svg` asset (**svg mode**)
100/// and on the JSX `<svg>` element: the node's `ImageNode` texture is an
101/// element-owned pixel buffer — never a path loaded as a Bevy `Image` — and
102/// the svg raster system repaints it at the laid-out size whenever the
103/// layout, the document, or (JSX mode) the shape children change.
104#[derive(Component)]
105pub struct SvgSurface {
106    /// The parsed document to rasterize. `Some` = an svg-mode `<image>`
107    /// (**file mode**); `None` = a JSX `<svg>` element, whose document is
108    /// built from its [`SvgShape`] children instead of an asset.
109    pub doc: Option<Handle<SvgDocument>>,
110    /// The JSX `<svg>` element's coordinate system: the user-unit rect mapped
111    /// onto the laid-out box. `None` = logical-pixel space — and always
112    /// `None` in file mode (the document carries its own viewBox).
113    pub view_box: Option<ViewBox>,
114    /// Physical-px size of the last raster; `UVec2::ZERO` before the first.
115    pub last_size: UVec2,
116    /// Repaint requested: set on mount and whenever the document handle (or
117    /// the JSX `viewBox`) changes; the raster system clears it after
118    /// painting. JSX shape and child-list changes intentionally do **not**
119    /// set this; the rasterizer derives that dirt itself
120    /// (`Changed<SvgShape>`/`Changed<Children>`, plus
121    /// `RemovedComponents<Children>` for an emptied container).
122    pub dirty: bool,
123}
124
125impl SvgSurface {
126    /// A fresh file-mode surface awaiting its first raster of `doc`.
127    pub fn new(doc: Handle<SvgDocument>) -> Self {
128        Self {
129            doc: Some(doc),
130            view_box: None,
131            last_size: UVec2::ZERO,
132            dirty: true,
133        }
134    }
135
136    /// A fresh JSX-mode surface (no document asset — the picture is the
137    /// element's [`SvgShape`] children) awaiting its first raster.
138    pub fn jsx(view_box: Option<ViewBox>) -> Self {
139        Self {
140            doc: None,
141            view_box,
142            last_size: UVec2::ZERO,
143            dirty: true,
144        }
145    }
146}
147
148/// The node's physical-per-logical scale factor, guarded against the zero
149/// `inverse_scale_factor` of a never-laid-out `ComputedNode` (fall back to
150/// `1.0` rather than an inf/NaN recip). Shared by the pick refinement and the
151/// interaction synthesis — both feed it into `paint::view_box_transform`.
152pub(crate) fn node_scale_factor(node: &ComputedNode) -> f32 {
153    if node.inverse_scale_factor > 0.0 {
154        node.inverse_scale_factor.recip()
155    } else {
156        1.0
157    }
158}
159
160/// Stamp the node's intrinsic-size measure from the document: an
161/// [`ImageMeasure`] over the document's intrinsic size (converted to physical
162/// px, like `bevy_ui`'s own image measure), so an unstyled svg `<image>` lays
163/// out exactly like a raster image of that size — aspect ratio preserved when
164/// only one axis is constrained — while never reading the texture. (The
165/// texture is re-rastered *at* laid-out size; measuring it would loop
166/// layout → raster → layout.)
167///
168/// Ordering caveat for callers: `bevy_ui`'s `update_image_content_size_system`
169/// (`PostUpdate`, `UiSystems::Content`) **clears** the measure of any
170/// non-`Auto`-mode `ImageNode` whose component changed that frame — so a stamp
171/// from the op-apply path (`Update`) is wiped whenever it rides an `ImageNode`
172/// re-insert. The raster system must re-stamp from a system ordered after it
173/// (and before `UiSystems::Layout`).
174pub(crate) fn stamp_intrinsic_measure(
175    content_size: &mut ContentSize,
176    doc_size: Vec2,
177    scale_factor: f32,
178    visual_box: VisualBox,
179) {
180    content_size.set(NodeMeasure::Image(ImageMeasure {
181        size: doc_size * scale_factor,
182        visual_box,
183    }));
184}
185
186#[cfg(test)]
187mod tests {
188    use super::CIRCLE_SVG;
189
190    /// Compile-time proof that a type is `Send + Sync`.
191    fn assert_send_sync<T: Send + Sync>() {}
192
193    /// `usvg::Tree` must be `Send + Sync` — Bevy's `Asset` trait requires it,
194    /// and the planned SVG asset wraps the parsed tree directly.
195    #[test]
196    fn usvg_tree_is_send_sync() {
197        assert_send_sync::<usvg::Tree>();
198    }
199
200    /// Parse a minimal document and rasterize it into a 64×64 pixmap, scaling
201    /// the 100×100 viewBox down to fill it. Asserts actual pixel values: the
202    /// center of the circle is opaque red, the corner outside it transparent.
203    /// (tiny-skia pixels are premultiplied; at full alpha that is a no-op.)
204    #[test]
205    fn smoke_rasters_a_circle() {
206        let tree =
207            usvg::Tree::from_str(CIRCLE_SVG, &usvg::Options::default()).expect("valid SVG parses");
208        let mut pixmap = tiny_skia::Pixmap::new(64, 64).expect("nonzero pixmap");
209        let transform = tiny_skia::Transform::from_scale(64.0 / 100.0, 64.0 / 100.0);
210        resvg::render(&tree, transform, &mut pixmap.as_mut());
211
212        let center = pixmap.pixel(32, 32).expect("in bounds");
213        assert_eq!(
214            (center.red(), center.green(), center.blue(), center.alpha()),
215            (255, 0, 0, 255),
216            "circle center must be opaque red"
217        );
218        let corner = pixmap.pixel(1, 1).expect("in bounds");
219        assert_eq!(
220            corner.alpha(),
221            0,
222            "corner outside the circle must be transparent"
223        );
224    }
225
226    /// Informational perf datapoint: wall time to rasterize the same tree at
227    /// 512×512. No assertion — run with `--nocapture` to see it.
228    #[test]
229    fn perf_datapoint_512() {
230        let tree =
231            usvg::Tree::from_str(CIRCLE_SVG, &usvg::Options::default()).expect("valid SVG parses");
232        let mut pixmap = tiny_skia::Pixmap::new(512, 512).expect("nonzero pixmap");
233        let transform = tiny_skia::Transform::from_scale(512.0 / 100.0, 512.0 / 100.0);
234        let start = std::time::Instant::now();
235        resvg::render(&tree, transform, &mut pixmap.as_mut());
236        eprintln!(
237            "svg perf datapoint: 512x512 circle raster took {:?}",
238            start.elapsed()
239        );
240    }
241}