Skip to main content

bnto_vector/optimize/
processor.rs

1// OptimizeSvg — NodeProcessor that strips editor cruft from SVG files.
2//
3// Runs 9 XML-level cleanup passes using roxmltree/xmlwriter.
4// Zero new Cargo dependencies — both are already in the WASM binary
5// via the resvg/usvg transitive chain.
6
7use bnto_core::context::ProcessContext;
8use bnto_core::errors::BntoError;
9use bnto_core::metadata::*;
10use bnto_core::processor::{FileData, NodeInput, NodeOutput, NodeProcessor, OutputFile};
11use bnto_core::progress::ProgressReporter;
12
13use super::{OptimizeConfig, optimize_svg};
14use crate::common::svg_accepts;
15
16/// SVG optimization processor — removes editor metadata, comments,
17/// unused namespaces, empty containers, and collapses redundant groups.
18pub struct OptimizeSvg;
19
20impl NodeProcessor for OptimizeSvg {
21    fn name(&self) -> &str {
22        "vector-optimize"
23    }
24
25    fn metadata(&self) -> NodeMetadata {
26        NodeMetadata {
27            node_type: "vector-optimize".to_string(),
28            name: "Optimize SVG".to_string(),
29            description:
30                "Optimize SVG files by removing editor metadata, comments, and unnecessary elements"
31                    .to_string(),
32            category: NodeCategory::Vector,
33            accepts: svg_accepts(),
34            platforms: vec!["browser".to_string(), "native".to_string()],
35            input_cardinality: InputCardinality::PerFile,
36            parameters: vec![
37                ParameterDef {
38                    name: "removeComments".to_string(),
39                    label: "Remove Comments".to_string(),
40                    description: "Strip XML comments from the SVG".to_string(),
41                    param_type: ParameterType::Boolean,
42                    default: Some(serde_json::Value::Bool(true)),
43                    constraints: None,
44                    ..Default::default()
45                },
46                ParameterDef {
47                    name: "removeMetadata".to_string(),
48                    label: "Remove Metadata".to_string(),
49                    description: "Strip <metadata> elements (RDF, Dublin Core, etc.)".to_string(),
50                    param_type: ParameterType::Boolean,
51                    default: Some(serde_json::Value::Bool(true)),
52                    constraints: None,
53                    ..Default::default()
54                },
55                ParameterDef {
56                    name: "collapseGroups".to_string(),
57                    label: "Collapse Groups".to_string(),
58                    description: "Remove empty groups and collapse single-child wrapper groups"
59                        .to_string(),
60                    param_type: ParameterType::Boolean,
61                    default: Some(serde_json::Value::Bool(true)),
62                    constraints: None,
63                    ..Default::default()
64                },
65                ParameterDef {
66                    name: "minify".to_string(),
67                    label: "Minify".to_string(),
68                    description: "Remove unnecessary whitespace and newlines".to_string(),
69                    param_type: ParameterType::Boolean,
70                    default: Some(serde_json::Value::Bool(true)),
71                    constraints: None,
72                    ..Default::default()
73                },
74                ParameterDef {
75                    name: "precision".to_string(),
76                    label: "Numeric Precision".to_string(),
77                    description: "Decimal places for numeric values (reserved for Tier 2)"
78                        .to_string(),
79                    param_type: ParameterType::Number,
80                    default: Some(serde_json::json!(3)),
81                    constraints: Some(Constraints {
82                        min: Some(0.0),
83                        max: Some(8.0),
84                        required: false,
85                    }),
86                    ..Default::default()
87                },
88            ],
89            requires: vec![],
90        }
91    }
92
93    fn process(
94        &self,
95        input: NodeInput,
96        progress: &ProgressReporter,
97        _ctx: &dyn ProcessContext,
98    ) -> Result<NodeOutput, BntoError> {
99        let data = input
100            .data
101            .into_bytes()
102            .map_err(|e| BntoError::ProcessingFailed(format!("Failed to read input: {e}")))?;
103        let config = extract_config(&input.params);
104
105        progress.report(10, "Parsing SVG...");
106        let input_str = std::str::from_utf8(&data)
107            .map_err(|e| BntoError::InvalidInput(format!("SVG is not valid UTF-8: {e}")))?;
108
109        progress.report(30, "Optimizing SVG...");
110        let optimized = optimize_svg(input_str, &config)
111            .map_err(|e| BntoError::ProcessingFailed(format!("SVG optimization failed: {e}")))?;
112
113        let input_size = data.len();
114        let output_size = optimized.len();
115
116        let metadata = build_metadata(input_size, output_size);
117
118        progress.report(100, "SVG optimization complete");
119        Ok(NodeOutput {
120            files: vec![OutputFile {
121                data: FileData::Bytes(optimized.into_bytes()),
122                filename: input.filename,
123                mime_type: "image/svg+xml".to_string(),
124                metadata: serde_json::Map::new(),
125            }],
126            metadata,
127        })
128    }
129
130    fn validate(&self, params: &serde_json::Map<String, serde_json::Value>) -> Vec<String> {
131        let mut errors = Vec::new();
132        if let Some(precision) = params.get("precision").and_then(|v| v.as_u64())
133            && precision > 8
134        {
135            errors.push(format!(
136                "precision must be between 0 and 8, got {precision}"
137            ));
138        }
139        errors
140    }
141}
142
143fn extract_config(params: &serde_json::Map<String, serde_json::Value>) -> OptimizeConfig {
144    OptimizeConfig {
145        remove_comments: params
146            .get("removeComments")
147            .and_then(|v| v.as_bool())
148            .unwrap_or(true),
149        remove_metadata: params
150            .get("removeMetadata")
151            .and_then(|v| v.as_bool())
152            .unwrap_or(true),
153        collapse_groups: params
154            .get("collapseGroups")
155            .and_then(|v| v.as_bool())
156            .unwrap_or(true),
157        minify: params
158            .get("minify")
159            .and_then(|v| v.as_bool())
160            .unwrap_or(true),
161        precision: params
162            .get("precision")
163            .and_then(|v| v.as_u64())
164            .unwrap_or(3) as u8,
165    }
166}
167
168fn build_metadata(
169    original_size: usize,
170    compressed_size: usize,
171) -> serde_json::Map<String, serde_json::Value> {
172    let ratio = if original_size > 0 {
173        (1.0 - (compressed_size as f64 / original_size as f64)) * 100.0
174    } else {
175        0.0
176    };
177
178    let mut meta = serde_json::Map::new();
179    meta.insert("originalSize".to_string(), serde_json::json!(original_size));
180    meta.insert(
181        "compressedSize".to_string(),
182        serde_json::json!(compressed_size),
183    );
184    if let Some(ratio_num) = serde_json::Number::from_f64(ratio) {
185        meta.insert(
186            "compressionRatio".to_string(),
187            serde_json::Value::Number(ratio_num),
188        );
189    }
190    meta
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use bnto_core::NoopContext;
197
198    fn make_input(params: serde_json::Map<String, serde_json::Value>) -> NodeInput {
199        NodeInput {
200            data: FileData::Bytes(verbose_svg_bytes()),
201            filename: "verbose.svg".to_string(),
202            mime_type: Some("image/svg+xml".to_string()),
203            params,
204        }
205    }
206
207    fn verbose_svg_bytes() -> Vec<u8> {
208        std::fs::read(concat!(
209            env!("CARGO_MANIFEST_DIR"),
210            "/../../../test-fixtures/vector/verbose.svg"
211        ))
212        .expect("verbose.svg fixture must exist")
213    }
214
215    fn empty_params() -> serde_json::Map<String, serde_json::Value> {
216        serde_json::Map::new()
217    }
218
219    #[test]
220    fn test_name_matches_registry_key() {
221        assert_eq!(OptimizeSvg.name(), "vector-optimize");
222    }
223
224    #[test]
225    fn test_metadata_accepts_svg() {
226        let meta = OptimizeSvg.metadata();
227        assert!(meta.accepts.contains(&"image/svg+xml".to_string()));
228    }
229
230    #[test]
231    fn test_metadata_has_five_params() {
232        let meta = OptimizeSvg.metadata();
233        assert_eq!(meta.parameters.len(), 5);
234    }
235
236    #[test]
237    fn test_metadata_supports_browser_platform() {
238        let meta = OptimizeSvg.metadata();
239        assert!(meta.platforms.contains(&"browser".to_string()));
240    }
241
242    #[test]
243    fn test_validate_passes_with_no_params() {
244        let errors = OptimizeSvg.validate(&empty_params());
245        assert!(errors.is_empty(), "Default params should pass validation");
246    }
247
248    #[test]
249    fn test_validate_rejects_precision_too_high() {
250        let mut params = empty_params();
251        params.insert("precision".to_string(), serde_json::json!(9));
252        let errors = OptimizeSvg.validate(&params);
253        assert!(!errors.is_empty(), "precision=9 should fail validation");
254    }
255
256    #[test]
257    fn test_process_produces_smaller_output() {
258        let input = make_input(empty_params());
259        let input_size = input.data.len().unwrap() as usize;
260        let reporter = ProgressReporter::new_noop();
261        let output = OptimizeSvg.process(input, &reporter, &NoopContext).unwrap();
262
263        assert_eq!(output.files.len(), 1);
264        let FileData::Bytes(ref data) = output.files[0].data else {
265            panic!("expected FileData::Bytes");
266        };
267        assert!(
268            data.len() < input_size,
269            "Output ({}) should be smaller than input ({input_size})",
270            data.len(),
271        );
272    }
273
274    #[test]
275    fn test_process_preserves_filename() {
276        let mut input = make_input(empty_params());
277        input.filename = "icon.svg".to_string();
278        let reporter = ProgressReporter::new_noop();
279        let output = OptimizeSvg.process(input, &reporter, &NoopContext).unwrap();
280
281        assert_eq!(output.files[0].filename, "icon.svg");
282    }
283
284    #[test]
285    fn test_process_returns_svg_mime_type() {
286        let input = make_input(empty_params());
287        let reporter = ProgressReporter::new_noop();
288        let output = OptimizeSvg.process(input, &reporter, &NoopContext).unwrap();
289
290        assert_eq!(output.files[0].mime_type, "image/svg+xml");
291    }
292
293    #[test]
294    fn test_process_metadata_has_savings() {
295        let input = make_input(empty_params());
296        let reporter = ProgressReporter::new_noop();
297        let output = OptimizeSvg.process(input, &reporter, &NoopContext).unwrap();
298
299        assert!(output.metadata.contains_key("originalSize"));
300        assert!(output.metadata.contains_key("compressedSize"));
301        assert!(output.metadata.contains_key("compressionRatio"));
302    }
303
304    #[test]
305    fn test_process_with_different_params_produces_different_output() {
306        let reporter = ProgressReporter::new_noop();
307
308        let out_default = OptimizeSvg
309            .process(make_input(empty_params()), &reporter, &NoopContext)
310            .unwrap();
311
312        let mut params = empty_params();
313        params.insert("removeComments".to_string(), serde_json::json!(false));
314        let out_comments = OptimizeSvg
315            .process(make_input(params), &reporter, &NoopContext)
316            .unwrap();
317
318        let FileData::Bytes(ref default_data) = out_default.files[0].data else {
319            panic!("expected FileData::Bytes");
320        };
321        let FileData::Bytes(ref comments_data) = out_comments.files[0].data else {
322            panic!("expected FileData::Bytes");
323        };
324        assert_ne!(
325            default_data, comments_data,
326            "Different params should produce different output"
327        );
328    }
329
330    #[test]
331    fn test_process_reports_minimum_50pct_savings() {
332        let input = make_input(empty_params());
333        let reporter = ProgressReporter::new_noop();
334        let output = OptimizeSvg.process(input, &reporter, &NoopContext).unwrap();
335
336        let ratio = output
337            .metadata
338            .get("compressionRatio")
339            .and_then(|v| v.as_f64())
340            .expect("compressionRatio should be in metadata");
341        assert!(
342            ratio >= 50.0,
343            "Expected >=50% savings on verbose SVG fixture, got {ratio}%"
344        );
345    }
346
347    #[test]
348    fn test_process_invalid_utf8_returns_error() {
349        let input = NodeInput {
350            data: FileData::Bytes(vec![0xFF, 0xFE, 0x00]),
351            filename: "bad.svg".to_string(),
352            mime_type: Some("image/svg+xml".to_string()),
353            params: empty_params(),
354        };
355        let reporter = ProgressReporter::new_noop();
356        let result = OptimizeSvg.process(input, &reporter, &NoopContext);
357        assert!(result.is_err());
358    }
359}