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