1use 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
16pub 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(¶ms);
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}