Skip to main content

bnto_vector/
processor.rs

1// Vector Rasterize Node — convert SVG files to raster images (PNG, JPEG, WebP).
2//
3// Delegates SVG parsing and rendering to rasterize_svg(), then converts the
4// pixel buffer to a DynamicImage and encodes via bnto-encode's shared encoder.
5
6use 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
19/// The vector-rasterize node processor.
20pub 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
104// --- Private helpers ---
105
106fn 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
139/// Convert a tiny_skia::Pixmap (premultiplied RGBA) to a DynamicImage.
140///
141/// For formats that don't support transparency (JPEG), composites over a white
142/// background so transparent areas appear white instead of black.
143fn 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
163/// Blend RGBA pixels over a white background, setting alpha to 255.
164/// result_rgb = src_rgb * src_alpha + 255 * (1 - src_alpha)
165fn 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
175/// Unpremultiply RGBA pixels — tiny-skia uses premultiplied alpha,
176/// but the image crate expects straight (non-premultiplied) alpha.
177fn 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
190/// Replace extension: "icon.svg" + PNG -> "icon.png"
191fn 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    // --- Trait basics ---
285
286    #[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(&params);
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(&params);
325        assert!(!errors.is_empty(), "format=bmp should fail validation");
326    }
327
328    // --- SVG to PNG ---
329
330    #[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        // PNG magic bytes: 0x89 P N G
343        assert!(data.len() > 8);
344        assert_eq!(&data[..4], &[0x89, b'P', b'N', b'G']);
345    }
346
347    // --- SVG to JPEG ---
348
349    #[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        // JPEG magic: 0xFF 0xD8
362        assert!(data.len() > 2);
363        assert_eq!(&data[..2], &[0xFF, 0xD8]);
364    }
365
366    // --- SVG to WebP ---
367
368    #[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        // RIFF....WEBP header
381        assert!(data.len() > 12);
382        assert_eq!(&data[..4], b"RIFF");
383        assert_eq!(&data[8..12], b"WEBP");
384    }
385
386    // --- Quality sensitivity ---
387
388    #[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    // --- Output filename ---
416
417    #[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    // --- Metadata ---
438
439    #[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    // --- Error handling ---
450
451    #[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    // --- Output MIME type ---
478
479    #[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}