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