Skip to main content

bevy_react/svg/
asset.rs

1//! The [`SvgDocument`] asset and its [`AssetLoader`].
2//!
3//! Parsing is a pure function ([`parse_svg_bytes`]) so it is unit-testable
4//! without an `AssetServer`; [`SvgAssetLoader`] is just the IO glue that reads
5//! the file bytes and delegates. The asset wraps the parsed [`usvg::Tree`]
6//! (immutable, `Send + Sync`) plus the document's intrinsic size in logical px
7//! — stored now because it will feed layout when `<image src="x.svg">` lands.
8
9use bevy::asset::{Asset, AssetLoader, LoadContext, io::Reader};
10use bevy::math::Vec2;
11use bevy::prelude::BevyError;
12use bevy::reflect::TypePath;
13
14/// A parsed SVG document: the resolution-independent scene graph, rasterized
15/// per node at laid-out size by the consumers of this asset.
16#[derive(Asset, TypePath, Debug)]
17pub struct SvgDocument {
18    /// The parsed, simplified scene graph.
19    pub tree: usvg::Tree,
20    /// Intrinsic document size in logical px (from `tree.size()`).
21    pub size: Vec2,
22}
23
24/// The document failed to parse as SVG. Wraps [`usvg::Error`], which already
25/// covers non-UTF-8 input, malformed gzip, and XML parse failures —
26/// [`usvg::Tree::from_data`] handles all of those itself.
27#[derive(Debug)]
28pub struct SvgParseError(pub usvg::Error);
29
30impl std::fmt::Display for SvgParseError {
31    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
32        write!(f, "invalid SVG: {}", self.0)
33    }
34}
35
36impl std::error::Error for SvgParseError {
37    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
38        Some(&self.0)
39    }
40}
41
42/// Parse raw SVG bytes (plain or gzip-compressed) into an [`SvgDocument`].
43/// All parsing logic lives here; the asset loader only feeds it file bytes.
44pub fn parse_svg_bytes(bytes: &[u8]) -> Result<SvgDocument, SvgParseError> {
45    let mut opts = usvg::Options::default();
46    super::text::configure_text_options(&mut opts);
47    let tree = usvg::Tree::from_data(bytes, &opts).map_err(SvgParseError)?;
48    let size = tree.size();
49    Ok(SvgDocument {
50        size: Vec2::new(size.width(), size.height()),
51        tree,
52    })
53}
54
55/// Loads `.svg` files into [`SvgDocument`] assets via [`parse_svg_bytes`].
56#[derive(TypePath)]
57pub struct SvgAssetLoader;
58
59impl AssetLoader for SvgAssetLoader {
60    type Asset = SvgDocument;
61    type Settings = ();
62    type Error = BevyError;
63
64    async fn load(
65        &self,
66        reader: &mut dyn Reader,
67        _settings: &Self::Settings,
68        _load_context: &mut LoadContext<'_>,
69    ) -> Result<Self::Asset, Self::Error> {
70        let mut bytes = Vec::new();
71        reader.read_to_end(&mut bytes).await?;
72        Ok(parse_svg_bytes(&bytes)?)
73    }
74
75    fn extensions(&self) -> &[&str] {
76        &["svg"]
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use crate::svg::CIRCLE_SVG;
84
85    #[test]
86    fn parses_valid_svg_and_rejects_garbage() {
87        let doc = parse_svg_bytes(CIRCLE_SVG.as_bytes()).expect("valid SVG parses");
88        assert_eq!(
89            doc.size,
90            Vec2::new(100.0, 100.0),
91            "intrinsic size must come from the viewBox"
92        );
93
94        parse_svg_bytes(b"not svg").expect_err("garbage bytes must be rejected");
95        parse_svg_bytes(b"").expect_err("empty input must be rejected");
96    }
97
98    /// The loader must claim the `.svg` extension, or `asset_server.load()`
99    /// cannot infer it and every load would need an explicit loader.
100    #[test]
101    fn loader_claims_svg_extension() {
102        assert_eq!(SvgAssetLoader.extensions(), ["svg"]);
103    }
104}