Skip to main content

bnto_vector/
rasterize.rs

1// SVG rasterization — parse SVG bytes and render to a pixel buffer.
2
3use thiserror::Error;
4
5/// Errors from SVG parsing and rasterization.
6#[derive(Debug, Error)]
7pub enum VectorError {
8    #[error("failed to parse SVG: {0}")]
9    ParseError(String),
10
11    #[error("failed to render SVG to pixels")]
12    RenderError,
13
14    #[error("invalid DPI {0}: must be between {MIN_DPI} and {MAX_DPI}")]
15    InvalidDpi(u32),
16}
17
18/// Minimum allowed DPI for rasterization.
19pub const MIN_DPI: u32 = 72;
20/// Maximum allowed DPI for rasterization.
21pub const MAX_DPI: u32 = 300;
22/// Default DPI — matches SVG spec's 1:1 mapping (96 CSS px per inch).
23pub const DEFAULT_DPI: u32 = 96;
24
25/// Options for SVG rasterization.
26#[derive(Debug, Clone)]
27pub struct RasterizeOptions {
28    /// Output DPI. Default 96 (1:1 with SVG units). Higher = larger output.
29    pub dpi: u32,
30}
31
32impl Default for RasterizeOptions {
33    fn default() -> Self {
34        Self { dpi: DEFAULT_DPI }
35    }
36}
37
38/// Rasterize SVG bytes to a pixel buffer.
39///
40/// Parses the SVG via usvg, scales to the requested DPI, and renders
41/// via resvg into a `tiny_skia::Pixmap` (RGBA premultiplied).
42pub fn rasterize_svg(
43    svg_bytes: &[u8],
44    options: RasterizeOptions,
45) -> Result<tiny_skia::Pixmap, VectorError> {
46    if options.dpi < MIN_DPI || options.dpi > MAX_DPI {
47        return Err(VectorError::InvalidDpi(options.dpi));
48    }
49
50    let svg_str =
51        std::str::from_utf8(svg_bytes).map_err(|e| VectorError::ParseError(e.to_string()))?;
52
53    let tree = usvg::Tree::from_str(svg_str, &usvg::Options::default())
54        .map_err(|e| VectorError::ParseError(e.to_string()))?;
55
56    let scale = options.dpi as f32 / DEFAULT_DPI as f32;
57    let size = tree.size();
58    let width = (size.width() * scale).ceil() as u32;
59    let height = (size.height() * scale).ceil() as u32;
60
61    let mut pixmap = tiny_skia::Pixmap::new(width, height).ok_or(VectorError::RenderError)?;
62
63    let transform = tiny_skia::Transform::from_scale(scale, scale);
64    resvg::render(&tree, transform, &mut pixmap.as_mut());
65
66    Ok(pixmap)
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    fn fixture_svg() -> Vec<u8> {
74        std::fs::read(concat!(
75            env!("CARGO_MANIFEST_DIR"),
76            "/../../../test-fixtures/images/small.svg"
77        ))
78        .expect("small.svg fixture must exist")
79    }
80
81    #[test]
82    fn default_dpi_produces_100x100() {
83        let pixmap = rasterize_svg(&fixture_svg(), RasterizeOptions::default()).unwrap();
84        assert_eq!(pixmap.width(), 100);
85        assert_eq!(pixmap.height(), 100);
86    }
87
88    #[test]
89    fn custom_dpi_192_doubles_dimensions() {
90        let opts = RasterizeOptions { dpi: 192 };
91        let pixmap = rasterize_svg(&fixture_svg(), opts).unwrap();
92        assert_eq!(pixmap.width(), 200);
93        assert_eq!(pixmap.height(), 200);
94    }
95
96    #[test]
97    fn min_dpi_72_accepted() {
98        let opts = RasterizeOptions { dpi: 72 };
99        let pixmap = rasterize_svg(&fixture_svg(), opts).unwrap();
100        assert_eq!(pixmap.width(), 75); // 100 * 72/96 = 75
101        assert_eq!(pixmap.height(), 75);
102    }
103
104    #[test]
105    fn max_dpi_300_accepted() {
106        let opts = RasterizeOptions { dpi: 300 };
107        let pixmap = rasterize_svg(&fixture_svg(), opts).unwrap();
108        // 100 * 300/96 = 312.5 -> ceil = 313
109        assert_eq!(pixmap.width(), 313);
110        assert_eq!(pixmap.height(), 313);
111    }
112
113    #[test]
114    fn dpi_below_min_rejected() {
115        let opts = RasterizeOptions { dpi: 71 };
116        let err = rasterize_svg(&fixture_svg(), opts).unwrap_err();
117        assert!(matches!(err, VectorError::InvalidDpi(71)));
118    }
119
120    #[test]
121    fn dpi_above_max_rejected() {
122        let opts = RasterizeOptions { dpi: 301 };
123        let err = rasterize_svg(&fixture_svg(), opts).unwrap_err();
124        assert!(matches!(err, VectorError::InvalidDpi(301)));
125    }
126
127    #[test]
128    fn invalid_svg_returns_parse_error() {
129        let err = rasterize_svg(b"not valid svg", RasterizeOptions::default()).unwrap_err();
130        assert!(matches!(err, VectorError::ParseError(_)));
131    }
132
133    #[test]
134    fn non_utf8_returns_parse_error() {
135        let err = rasterize_svg(&[0xFF, 0xFE, 0x00], RasterizeOptions::default()).unwrap_err();
136        assert!(matches!(err, VectorError::ParseError(_)));
137    }
138
139    #[test]
140    fn empty_data_returns_error() {
141        let err = rasterize_svg(b"", RasterizeOptions::default()).unwrap_err();
142        assert!(matches!(err, VectorError::ParseError(_)));
143    }
144
145    #[test]
146    fn pixmap_has_non_zero_data() {
147        let pixmap = rasterize_svg(&fixture_svg(), RasterizeOptions::default()).unwrap();
148        // The fixture has a blue rect + orange circle — pixels shouldn't all be transparent
149        let has_visible_pixels = pixmap.data().iter().any(|&b| b != 0);
150        assert!(
151            has_visible_pixels,
152            "rendered pixmap should contain visible pixels"
153        );
154    }
155
156    // --- Complex real-world SVG (mascot illustration from Figma) ---
157
158    fn fixture_mascot() -> Vec<u8> {
159        std::fs::read(concat!(
160            env!("CARGO_MANIFEST_DIR"),
161            "/../../../test-fixtures/images/mascot-sushi-friends.svg"
162        ))
163        .expect("mascot-sushi-friends.svg fixture must exist")
164    }
165
166    #[test]
167    fn complex_svg_rasterizes_at_default_dpi() {
168        // 2144x1569 mascot SVG with paths, opacity groups, and fills
169        let pixmap = rasterize_svg(&fixture_mascot(), RasterizeOptions::default()).unwrap();
170        assert_eq!(pixmap.width(), 2144);
171        assert_eq!(pixmap.height(), 1569);
172        let has_visible_pixels = pixmap.data().iter().any(|&b| b != 0);
173        assert!(has_visible_pixels, "mascot should render visible pixels");
174    }
175
176    #[test]
177    fn complex_svg_scales_with_dpi() {
178        let opts = RasterizeOptions { dpi: 192 };
179        let pixmap = rasterize_svg(&fixture_mascot(), opts).unwrap();
180        // 2x scale: 2144 * 2 = 4288, 1569 * 2 = 3138
181        assert_eq!(pixmap.width(), 4288);
182        assert_eq!(pixmap.height(), 3138);
183    }
184}