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: "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
103// --- Private helpers ---
104
105fn 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
138/// Convert a tiny_skia::Pixmap (premultiplied RGBA) to a DynamicImage.
139///
140/// For formats that don't support transparency (JPEG), composites over a white
141/// background so transparent areas appear white instead of black.
142fn 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
162/// Blend RGBA pixels over a white background, setting alpha to 255.
163/// result_rgb = src_rgb * src_alpha + 255 * (1 - src_alpha)
164fn 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
174/// Unpremultiply RGBA pixels — tiny-skia uses premultiplied alpha,
175/// but the image crate expects straight (non-premultiplied) alpha.
176fn 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
189/// Replace extension: "icon.svg" + PNG -> "icon.png"
190fn 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    // --- Trait basics ---
284
285    #[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(&params);
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(&params);
324        assert!(!errors.is_empty(), "format=bmp should fail validation");
325    }
326
327    // --- SVG to PNG ---
328
329    #[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        // PNG magic bytes: 0x89 P N G
342        assert!(data.len() > 8);
343        assert_eq!(&data[..4], &[0x89, b'P', b'N', b'G']);
344    }
345
346    // --- SVG to JPEG ---
347
348    #[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        // JPEG magic: 0xFF 0xD8
361        assert!(data.len() > 2);
362        assert_eq!(&data[..2], &[0xFF, 0xD8]);
363    }
364
365    // --- SVG to WebP ---
366
367    #[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        // RIFF....WEBP header
380        assert!(data.len() > 12);
381        assert_eq!(&data[..4], b"RIFF");
382        assert_eq!(&data[8..12], b"WEBP");
383    }
384
385    // --- Quality sensitivity ---
386
387    #[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    // --- Output filename ---
415
416    #[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    // --- Metadata ---
437
438    #[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    // --- Error handling ---
449
450    #[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    // --- Output MIME type ---
477
478    #[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}