1use thiserror::Error;
4
5#[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
18pub const MIN_DPI: u32 = 72;
20pub const MAX_DPI: u32 = 300;
22pub const DEFAULT_DPI: u32 = 96;
24
25#[derive(Debug, Clone)]
27pub struct RasterizeOptions {
28 pub dpi: u32,
30}
31
32impl Default for RasterizeOptions {
33 fn default() -> Self {
34 Self { dpi: DEFAULT_DPI }
35 }
36}
37
38pub 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); 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 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 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 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 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 assert_eq!(pixmap.width(), 4288);
182 assert_eq!(pixmap.height(), 3138);
183 }
184}