Skip to main content

native_theme/
rasterize.rs

1// SVG-to-RGBA rasterization using resvg.
2//
3// Converts SVG byte data to IconData::Rgba pixel data at a specified size.
4// Enabled behind the `svg-rasterize` feature gate.
5
6use crate::IconData;
7use resvg::tiny_skia;
8use resvg::usvg;
9
10/// Rasterize SVG bytes to RGBA pixel data at the given size.
11///
12/// Parses the SVG, scales it uniformly to fit within `size x size` pixels
13/// (preserving aspect ratio), and renders to a pixel buffer with straight
14/// (non-premultiplied) alpha.
15///
16/// # Errors
17///
18/// Returns [`crate::Error::ReaderFailed`] if the SVG cannot be parsed or
19/// the size is zero / pixmap allocation fails.
20///
21/// # Examples
22///
23/// ```
24/// # #[cfg(feature = "svg-rasterize")]
25/// # {
26/// use native_theme::rasterize::rasterize_svg;
27/// use native_theme::theme::IconData;
28///
29/// let svg = b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='10'/></svg>";
30/// let result = rasterize_svg(svg, 24);
31/// assert!(result.is_ok());
32/// if let Ok(IconData::Rgba { width, height, data }) = result {
33///     assert_eq!(width, 24);
34///     assert_eq!(height, 24);
35///     assert_eq!(data.len(), 24 * 24 * 4);
36/// }
37/// # }
38/// ```
39pub fn rasterize_svg(svg_bytes: &[u8], size: u32) -> crate::Result<IconData> {
40    let options = usvg::Options::default();
41    let tree =
42        usvg::Tree::from_data(svg_bytes, &options).map_err(|e| crate::Error::ReaderFailed {
43            reader: "svg_rasterizer",
44            source: Box::new(e),
45        })?;
46
47    let original_size = tree.size();
48    let scale_x = size as f32 / original_size.width();
49    let scale_y = size as f32 / original_size.height();
50    let scale = scale_x.min(scale_y);
51
52    // Center the icon if aspect ratio doesn't match
53    let scaled_w = original_size.width() * scale;
54    let scaled_h = original_size.height() * scale;
55    let offset_x = (size as f32 - scaled_w) / 2.0;
56    let offset_y = (size as f32 - scaled_h) / 2.0;
57
58    let mut pixmap =
59        tiny_skia::Pixmap::new(size, size).ok_or_else(|| crate::Error::ReaderFailed {
60            reader: "svg_rasterizer",
61            source: format!("invalid rasterization size {size}x{size}").into(),
62        })?;
63    let transform =
64        tiny_skia::Transform::from_translate(offset_x, offset_y).post_scale(scale, scale);
65    resvg::render(&tree, transform, &mut pixmap.as_mut());
66
67    // resvg outputs premultiplied RGBA; convert to straight alpha
68    let mut data = pixmap.take();
69    crate::color::unpremultiply_alpha(&mut data);
70
71    Ok(IconData::Rgba {
72        width: size,
73        height: size,
74        data,
75    })
76}
77
78#[cfg(test)]
79#[allow(clippy::unwrap_used, clippy::expect_used)]
80mod tests {
81    use super::*;
82
83    const VALID_SVG: &[u8] = b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><circle cx='12' cy='12' r='10' fill='red'/></svg>";
84
85    #[test]
86    fn rasterize_valid_svg_returns_rgba() {
87        let result = rasterize_svg(VALID_SVG, 24);
88        assert!(result.is_ok(), "valid SVG should produce Ok");
89        match result.unwrap() {
90            IconData::Rgba {
91                width,
92                height,
93                data,
94            } => {
95                assert_eq!(width, 24);
96                assert_eq!(height, 24);
97                assert_eq!(data.len(), 24 * 24 * 4);
98            }
99            _ => panic!("expected IconData::Rgba"),
100        }
101    }
102
103    #[test]
104    fn rasterize_invalid_svg_returns_err() {
105        let result = rasterize_svg(b"not svg", 24);
106        assert!(result.is_err(), "invalid SVG should return Err");
107    }
108
109    #[test]
110    fn rasterize_output_length_matches_size() {
111        for size in [16, 24, 32, 48, 64] {
112            let result = rasterize_svg(VALID_SVG, size);
113            let icon = result.unwrap_or_else(|_| panic!("should produce output for size {size}"));
114            if let IconData::Rgba { data, .. } = icon {
115                assert_eq!(
116                    data.len(),
117                    (size * size * 4) as usize,
118                    "output length mismatch for size {size}"
119                );
120            }
121        }
122    }
123
124    #[test]
125    fn rasterize_produces_non_empty_pixels() {
126        let result = rasterize_svg(VALID_SVG, 24).unwrap();
127        if let IconData::Rgba { data, .. } = result {
128            // At least some pixels should be non-zero (red circle on transparent bg)
129            let non_zero = data.iter().any(|&b| b != 0);
130            assert!(non_zero, "rasterized output should contain non-zero pixels");
131        }
132    }
133
134    #[test]
135    fn rasterize_straight_alpha() {
136        // A semi-transparent SVG element
137        let svg = b"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 2 2'><rect width='2' height='2' fill='white' fill-opacity='0.5'/></svg>";
138        let result = rasterize_svg(svg, 2).unwrap();
139        if let IconData::Rgba { data, .. } = result {
140            // All pixels should be the same semi-transparent white
141            for pixel in data.as_chunks::<4>().0 {
142                let &[r, _g, _b, a] = pixel;
143                if a > 0 {
144                    // Straight alpha: R should be close to 255, not ~128
145                    // (premultiplied would have R ~= 128 for alpha 128)
146                    assert!(
147                        r > 200,
148                        "expected straight alpha (R near 255), got R={r} A={a}"
149                    );
150                }
151            }
152        }
153    }
154
155    #[test]
156    fn rasterize_zero_size_returns_err() {
157        let result = rasterize_svg(VALID_SVG, 0);
158        assert!(result.is_err(), "zero size should return Err");
159    }
160}