1use bnto_core::DEFAULT_QUALITY;
7use bnto_core::context::ProcessContext;
8use bnto_core::errors::BntoError;
9use bnto_core::processor::{FileData, NodeInput, NodeOutput, NodeProcessor, OutputFile};
10use bnto_core::progress::ProgressReporter;
11use bnto_encode::ImageFormat;
12
13use crate::common::{format_param_def, quality_param_def, svg_accepts};
14use crate::rasterize::{RasterizeOptions, rasterize_svg};
15
16const MIN_QUALITY: u8 = 1;
17const MAX_QUALITY: u8 = 100;
18
19pub struct VectorRasterize;
21
22impl Default for VectorRasterize {
23 fn default() -> Self {
24 Self::new()
25 }
26}
27
28impl VectorRasterize {
29 pub fn new() -> Self {
30 Self
31 }
32}
33
34impl NodeProcessor for VectorRasterize {
35 fn name(&self) -> &str {
36 "vector-rasterize"
37 }
38
39 fn metadata(&self) -> bnto_core::NodeMetadata {
40 use bnto_core::metadata::*;
41 NodeMetadata {
42 node_type: "vector-rasterize".to_string(),
43 name: "Rasterize SVG".to_string(),
44 description: "Rasterize SVG files into raster images (PNG, JPEG, WebP)".to_string(),
45 category: NodeCategory::Vector,
46 accepts: svg_accepts(),
47 platforms: vec!["browser".to_string()],
48 parameters: vec![format_param_def(), quality_param_def()],
49 input_cardinality: InputCardinality::PerFile,
50 requires: vec![],
51 }
52 }
53
54 #[expect(clippy::too_many_lines)]
55 fn process(
56 &self,
57 input: NodeInput,
58 progress: &ProgressReporter,
59 _ctx: &dyn ProcessContext,
60 ) -> Result<NodeOutput, BntoError> {
61 let target_format = extract_target_format(&input)?;
62 let quality = extract_quality(&input.params, target_format);
63 let data = input
64 .data
65 .into_bytes()
66 .map_err(|e| BntoError::ProcessingFailed(format!("Failed to read input: {e}")))?;
67
68 progress.report(10, "Rasterizing SVG...");
69 let pixmap = rasterize_svg(&data, RasterizeOptions::default())
70 .map_err(|e| BntoError::ProcessingFailed(format!("SVG rasterization failed: {e}")))?;
71
72 progress.report(50, "Converting to raster image...");
73 let img = pixmap_to_dynamic_image(&pixmap, target_format)?;
74
75 progress.report(
76 70,
77 &format!("Encoding as {target_format:?} (quality: {quality})..."),
78 );
79 let encoded = bnto_encode::encode::encode_image(&img, target_format, quality)?;
80
81 let output_name = output_filename(&input.filename, target_format);
82 let metadata = build_metadata(&data, &encoded, target_format);
83
84 progress.report(100, "Rasterization complete");
85 Ok(NodeOutput {
86 files: vec![OutputFile {
87 data: FileData::Bytes(encoded),
88 filename: output_name,
89 mime_type: target_format.mime_type().to_string(),
90 metadata: serde_json::Map::new(),
91 }],
92 metadata,
93 })
94 }
95
96 fn validate(&self, params: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
97 let mut errors = Vec::new();
98 validate_format_param(params, &mut errors);
99 validate_quality_param(params, &mut errors);
100 errors
101 }
102}
103
104fn extract_target_format(input: &NodeInput) -> Result<ImageFormat, BntoError> {
107 let format_str = input
108 .params
109 .get("format")
110 .and_then(|v| v.as_str())
111 .unwrap_or("png");
112
113 parse_target_format(format_str)
114}
115
116fn parse_target_format(format_str: &str) -> Result<ImageFormat, BntoError> {
117 match format_str.to_lowercase().as_str() {
118 "jpeg" | "jpg" => Ok(ImageFormat::Jpeg),
119 "png" => Ok(ImageFormat::Png),
120 "webp" => Ok(ImageFormat::WebP),
121 _ => Err(BntoError::InvalidInput(format!(
122 "Unsupported target format: '{format_str}'. Supported: png, jpeg, webp"
123 ))),
124 }
125}
126
127fn extract_quality(
128 params: &serde_json::Map<String, serde_json::Value>,
129 _target_format: ImageFormat,
130) -> u8 {
131 params
132 .get("quality")
133 .and_then(|v| v.as_u64())
134 .map(|q| q as u8)
135 .unwrap_or(DEFAULT_QUALITY)
136 .clamp(MIN_QUALITY, MAX_QUALITY)
137}
138
139fn pixmap_to_dynamic_image(
144 pixmap: &tiny_skia::Pixmap,
145 target_format: ImageFormat,
146) -> Result<image::DynamicImage, BntoError> {
147 let mut pixels = pixmap.data().to_vec();
148 unpremultiply_alpha(&mut pixels);
149
150 let needs_opaque_bg = matches!(target_format, ImageFormat::Jpeg);
151 if needs_opaque_bg {
152 composite_over_white(&mut pixels);
153 }
154
155 let rgba =
156 image::RgbaImage::from_raw(pixmap.width(), pixmap.height(), pixels).ok_or_else(|| {
157 BntoError::ProcessingFailed("Failed to create RgbaImage from pixmap data".to_string())
158 })?;
159
160 Ok(image::DynamicImage::ImageRgba8(rgba))
161}
162
163fn composite_over_white(data: &mut [u8]) {
166 for chunk in data.as_chunks_mut::<4>().0 {
167 let a = chunk[3] as f32 / 255.0;
168 chunk[0] = (chunk[0] as f32 * a + 255.0 * (1.0 - a)) as u8;
169 chunk[1] = (chunk[1] as f32 * a + 255.0 * (1.0 - a)) as u8;
170 chunk[2] = (chunk[2] as f32 * a + 255.0 * (1.0 - a)) as u8;
171 chunk[3] = 255;
172 }
173}
174
175fn unpremultiply_alpha(data: &mut [u8]) {
178 for chunk in data.as_chunks_mut::<4>().0 {
179 let alpha = chunk[3];
180 if alpha == 0 || alpha == 255 {
181 continue;
182 }
183 let a = alpha as f32 / 255.0;
184 chunk[0] = (chunk[0] as f32 / a).min(255.0) as u8;
185 chunk[1] = (chunk[1] as f32 / a).min(255.0) as u8;
186 chunk[2] = (chunk[2] as f32 / a).min(255.0) as u8;
187 }
188}
189
190fn output_filename(input_filename: &str, target_format: ImageFormat) -> String {
192 if let Some(dot_pos) = input_filename.rfind('.') {
193 let stem = &input_filename[..dot_pos];
194 format!("{stem}.{}", target_format.extension())
195 } else {
196 format!("{input_filename}.{}", target_format.extension())
197 }
198}
199
200fn build_metadata(
201 input_data: &[u8],
202 output_data: &[u8],
203 target_format: ImageFormat,
204) -> serde_json::Map<String, serde_json::Value> {
205 let mut meta = serde_json::Map::new();
206 meta.insert("originalFormat".to_string(), serde_json::json!("svg"));
207 meta.insert(
208 "outputFormat".to_string(),
209 serde_json::json!(target_format.extension()),
210 );
211 meta.insert(
212 "originalSize".to_string(),
213 serde_json::json!(input_data.len()),
214 );
215 meta.insert(
216 "compressedSize".to_string(),
217 serde_json::json!(output_data.len()),
218 );
219 meta
220}
221
222fn validate_format_param(
223 params: &serde_json::Map<String, serde_json::Value>,
224 errors: &mut Vec<String>,
225) {
226 if let Some(format_val) = params.get("format")
227 && let Some(s) = format_val.as_str()
228 && parse_target_format(s).is_err()
229 {
230 errors.push(format!("Invalid format '{s}'. Supported: png, jpeg, webp"));
231 }
232}
233
234fn validate_quality_param(
235 params: &serde_json::Map<String, serde_json::Value>,
236 errors: &mut Vec<String>,
237) {
238 if let Some(q) = params.get("quality") {
239 if let Some(n) = q.as_u64()
240 && (n < MIN_QUALITY as u64 || n > MAX_QUALITY as u64)
241 {
242 errors.push(format!(
243 "Quality must be between {MIN_QUALITY} and {MAX_QUALITY}, got {n}"
244 ));
245 } else if let Some(n) = q.as_f64()
246 && (n < MIN_QUALITY as f64 || n > MAX_QUALITY as f64)
247 {
248 errors.push(format!(
249 "Quality must be between {MIN_QUALITY} and {MAX_QUALITY}, got {n}"
250 ));
251 }
252 }
253}
254
255#[cfg(test)]
256mod tests {
257 use super::*;
258 use bnto_core::NoopContext;
259
260 fn fixture_svg() -> Vec<u8> {
261 std::fs::read(concat!(
262 env!("CARGO_MANIFEST_DIR"),
263 "/../../../test-fixtures/images/small.svg"
264 ))
265 .expect("small.svg fixture must exist")
266 }
267
268 fn make_input(params: serde_json::Map<String, serde_json::Value>) -> NodeInput {
269 NodeInput {
270 data: FileData::Bytes(fixture_svg()),
271 filename: "icon.svg".to_string(),
272 mime_type: Some("image/svg+xml".to_string()),
273 params,
274 }
275 }
276
277 fn make_params(format: &str, quality: u64) -> serde_json::Map<String, serde_json::Value> {
278 let mut params = serde_json::Map::new();
279 params.insert("format".to_string(), serde_json::json!(format));
280 params.insert("quality".to_string(), serde_json::json!(quality));
281 params
282 }
283
284 #[test]
287 fn test_name_returns_vector_rasterize() {
288 assert_eq!(VectorRasterize::new().name(), "vector-rasterize");
289 }
290
291 #[test]
292 fn test_metadata_has_svg_accepts() {
293 let meta = VectorRasterize::new().metadata();
294 assert!(meta.accepts.contains(&"image/svg+xml".to_string()));
295 }
296
297 #[test]
298 fn test_metadata_category_is_vector() {
299 let meta = VectorRasterize::new().metadata();
300 assert!(matches!(
301 meta.category,
302 bnto_core::metadata::NodeCategory::Vector
303 ));
304 }
305
306 #[test]
307 fn test_validate_passes_with_defaults() {
308 let errors = VectorRasterize::new().validate(&serde_json::Map::new());
309 assert!(errors.is_empty(), "Default params should pass validation");
310 }
311
312 #[test]
313 fn test_validate_rejects_invalid_quality() {
314 let mut params = serde_json::Map::new();
315 params.insert("quality".to_string(), serde_json::json!(200));
316 let errors = VectorRasterize::new().validate(¶ms);
317 assert!(!errors.is_empty(), "quality=200 should fail validation");
318 }
319
320 #[test]
321 fn test_validate_rejects_invalid_format() {
322 let mut params = serde_json::Map::new();
323 params.insert("format".to_string(), serde_json::json!("bmp"));
324 let errors = VectorRasterize::new().validate(¶ms);
325 assert!(!errors.is_empty(), "format=bmp should fail validation");
326 }
327
328 #[test]
331 fn test_svg_to_png_produces_valid_png() {
332 let input = make_input(make_params("png", 80));
333 let reporter = ProgressReporter::new_noop();
334 let output = VectorRasterize::new()
335 .process(input, &reporter, &NoopContext)
336 .expect("SVG to PNG should succeed");
337
338 assert_eq!(output.files.len(), 1);
339 let FileData::Bytes(ref data) = output.files[0].data else {
340 panic!("expected FileData::Bytes");
341 };
342 assert!(data.len() > 8);
344 assert_eq!(&data[..4], &[0x89, b'P', b'N', b'G']);
345 }
346
347 #[test]
350 fn test_svg_to_jpeg_produces_valid_jpeg() {
351 let input = make_input(make_params("jpeg", 80));
352 let reporter = ProgressReporter::new_noop();
353 let output = VectorRasterize::new()
354 .process(input, &reporter, &NoopContext)
355 .expect("SVG to JPEG should succeed");
356
357 assert_eq!(output.files.len(), 1);
358 let FileData::Bytes(ref data) = output.files[0].data else {
359 panic!("expected FileData::Bytes");
360 };
361 assert!(data.len() > 2);
363 assert_eq!(&data[..2], &[0xFF, 0xD8]);
364 }
365
366 #[test]
369 fn test_svg_to_webp_produces_valid_webp() {
370 let input = make_input(make_params("webp", 80));
371 let reporter = ProgressReporter::new_noop();
372 let output = VectorRasterize::new()
373 .process(input, &reporter, &NoopContext)
374 .expect("SVG to WebP should succeed");
375
376 assert_eq!(output.files.len(), 1);
377 let FileData::Bytes(ref data) = output.files[0].data else {
378 panic!("expected FileData::Bytes");
379 };
380 assert!(data.len() > 12);
382 assert_eq!(&data[..4], b"RIFF");
383 assert_eq!(&data[8..12], b"WEBP");
384 }
385
386 #[test]
389 fn test_quality_affects_jpeg_size() {
390 let reporter = ProgressReporter::new_noop();
391
392 let input_low = make_input(make_params("jpeg", 20));
393 let output_low = VectorRasterize::new()
394 .process(input_low, &reporter, &NoopContext)
395 .unwrap();
396
397 let input_high = make_input(make_params("jpeg", 90));
398 let output_high = VectorRasterize::new()
399 .process(input_high, &reporter, &NoopContext)
400 .unwrap();
401
402 let FileData::Bytes(ref low_data) = output_low.files[0].data else {
403 panic!("expected FileData::Bytes");
404 };
405 let FileData::Bytes(ref high_data) = output_high.files[0].data else {
406 panic!("expected FileData::Bytes");
407 };
408 assert_ne!(
409 low_data.len(),
410 high_data.len(),
411 "Different quality levels should produce different file sizes"
412 );
413 }
414
415 #[test]
418 fn test_output_filename_has_target_extension() {
419 let input = make_input(make_params("png", 80));
420 let reporter = ProgressReporter::new_noop();
421 let output = VectorRasterize::new()
422 .process(input, &reporter, &NoopContext)
423 .unwrap();
424 assert_eq!(output.files[0].filename, "icon.png");
425 }
426
427 #[test]
428 fn test_output_filename_jpeg_extension() {
429 let input = make_input(make_params("jpeg", 80));
430 let reporter = ProgressReporter::new_noop();
431 let output = VectorRasterize::new()
432 .process(input, &reporter, &NoopContext)
433 .unwrap();
434 assert_eq!(output.files[0].filename, "icon.jpg");
435 }
436
437 #[test]
440 fn test_metadata_reports_original_format() {
441 let input = make_input(make_params("png", 80));
442 let reporter = ProgressReporter::new_noop();
443 let output = VectorRasterize::new()
444 .process(input, &reporter, &NoopContext)
445 .unwrap();
446 assert_eq!(output.metadata["originalFormat"], "svg");
447 }
448
449 #[test]
452 fn test_invalid_svg_returns_error() {
453 let input = NodeInput {
454 data: FileData::Bytes(b"not valid svg data".to_vec()),
455 filename: "bad.svg".to_string(),
456 mime_type: Some("image/svg+xml".to_string()),
457 params: make_params("png", 80),
458 };
459 let reporter = ProgressReporter::new_noop();
460 let result = VectorRasterize::new().process(input, &reporter, &NoopContext);
461 assert!(result.is_err(), "Invalid SVG should return an error");
462 }
463
464 #[test]
465 fn test_empty_data_returns_error() {
466 let input = NodeInput {
467 data: FileData::Bytes(vec![]),
468 filename: "empty.svg".to_string(),
469 mime_type: Some("image/svg+xml".to_string()),
470 params: make_params("png", 80),
471 };
472 let reporter = ProgressReporter::new_noop();
473 let result = VectorRasterize::new().process(input, &reporter, &NoopContext);
474 assert!(result.is_err(), "Empty data should return an error");
475 }
476
477 #[test]
480 fn test_output_mime_type_matches_format() {
481 let cases = [
482 ("png", "image/png"),
483 ("jpeg", "image/jpeg"),
484 ("webp", "image/webp"),
485 ];
486 let reporter = ProgressReporter::new_noop();
487
488 for (format, expected_mime) in &cases {
489 let input = make_input(make_params(format, 80));
490 let output = VectorRasterize::new()
491 .process(input, &reporter, &NoopContext)
492 .unwrap();
493 assert_eq!(
494 output.files[0].mime_type, *expected_mime,
495 "Format {format} should produce MIME {expected_mime}"
496 );
497 }
498 }
499}