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 #[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(¶ms);
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}