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}