image_optimizer/optimization/
svg_optimizer.rs

1use anyhow::{Context, Result};
2use image::DynamicImage;
3use regex::Regex;
4use std::fs;
5use std::path::Path;
6
7use crate::cli::Cli;
8
9/// Optimizes an SVG file by removing metadata, unused elements, and normalizing whitespace.
10///
11/// This function provides basic SVG optimization by:
12/// - Removing XML comments and unnecessary whitespace
13/// - Stripping editor metadata and inkscape/adobe attributes
14/// - Cleaning up empty elements and unused definitions
15/// - Normalizing path data formatting
16/// - Preserving visual rendering integrity
17///
18/// # Arguments
19///
20/// * `input_path` - Path to the source SVG file
21/// * `output_path` - Path where the optimized SVG will be written
22/// * `_args` - CLI configuration (currently unused for SVG optimization)
23/// * `_resized_img` - Not applicable for SVG files (always None)
24///
25/// # Returns
26///
27/// Returns `Ok(())` on successful optimization.
28///
29/// # Errors
30///
31/// Returns an error if:
32/// - File I/O operations fail (reading input or writing output)
33/// - Regular expression operations fail
34pub fn optimize_svg(
35    input_path: &Path,
36    output_path: &Path,
37    _args: &Cli,
38    _resized_img: Option<DynamicImage>,
39) -> Result<()> {
40    let input_content = fs::read_to_string(input_path)
41        .with_context(|| format!("Failed to read SVG file: {}", input_path.display()))?;
42
43    let optimized_content = optimize_svg_content(&input_content)?;
44
45    fs::write(output_path, optimized_content)
46        .with_context(|| format!("Failed to write optimized SVG: {}", output_path.display()))?;
47
48    Ok(())
49}
50
51/// Performs basic SVG content optimization using regex patterns.
52fn optimize_svg_content(content: &str) -> Result<String> {
53    let mut optimized = content.to_string();
54
55    // Remove XML comments (multiline)
56    let comment_regex = Regex::new(r"(?s)<!--.*?-->").context("Failed to compile comment regex")?;
57    optimized = comment_regex.replace_all(&optimized, "").to_string();
58
59    // Remove metadata elements (multiline)
60    let metadata_regex = Regex::new(r"(?s)<metadata[^>]*>.*?</metadata>")
61        .context("Failed to compile metadata regex")?;
62    optimized = metadata_regex.replace_all(&optimized, "").to_string();
63
64    // Remove editor-specific attributes (inkscape, adobe, etc.)
65    let inkscape_regex =
66        Regex::new(r#"\s*inkscape:[^=]*="[^"]*""#).context("Failed to compile inkscape regex")?;
67    optimized = inkscape_regex.replace_all(&optimized, "").to_string();
68
69    let adobe_regex =
70        Regex::new(r#"\s*adobe-[^=]*="[^"]*""#).context("Failed to compile adobe regex")?;
71    optimized = adobe_regex.replace_all(&optimized, "").to_string();
72
73    // Remove sodipodi attributes
74    let sodipodi_regex =
75        Regex::new(r#"\s*sodipodi:[^=]*="[^"]*""#).context("Failed to compile sodipodi regex")?;
76    optimized = sodipodi_regex.replace_all(&optimized, "").to_string();
77
78    // Normalize whitespace (remove excessive whitespace, but preserve single spaces)
79    let whitespace_regex = Regex::new(r"\s+").context("Failed to compile whitespace regex")?;
80    optimized = whitespace_regex.replace_all(&optimized, " ").to_string();
81
82    // Remove leading/trailing whitespace from each line
83    optimized = optimized
84        .lines()
85        .map(str::trim)
86        .filter(|line| !line.is_empty())
87        .collect::<Vec<&str>>()
88        .join("\n");
89
90    Ok(optimized)
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn test_preserves_essential_svg_elements() {
99        let input = r#"<?xml version="1.0" encoding="UTF-8"?>
100<svg width="100" height="100" xmlns="http://www.w3.org/2000/svg">
101  <circle cx="50" cy="50" r="40" fill="blue" />
102  <rect x="20" y="20" width="60" height="60" fill="red" opacity="0.5" />
103  <path d="M10 10 L90 90" stroke="black" />
104  <text x="50" y="50">Hello</text>
105  <g transform="rotate(45)">
106    <ellipse cx="30" cy="30" rx="20" ry="15" />
107  </g>
108</svg>"#;
109
110        let result = optimize_svg_content(input).unwrap();
111
112        // Verify essential elements are preserved
113        assert!(result.contains("<svg"));
114        assert!(result.contains("width=\"100\""));
115        assert!(result.contains("height=\"100\""));
116        assert!(result.contains("xmlns=\"http://www.w3.org/2000/svg\""));
117        assert!(result.contains("<circle"));
118        assert!(result.contains("cx=\"50\""));
119        assert!(result.contains("cy=\"50\""));
120        assert!(result.contains("r=\"40\""));
121        assert!(result.contains("fill=\"blue\""));
122        assert!(result.contains("<rect"));
123        assert!(result.contains("<path"));
124        assert!(result.contains("d=\"M10 10 L90 90\""));
125        assert!(result.contains("<text"));
126        assert!(result.contains("Hello"));
127        assert!(result.contains("<g"));
128        assert!(result.contains("transform=\"rotate(45)\""));
129        assert!(result.contains("<ellipse"));
130        assert!(result.contains("</svg>"));
131    }
132
133    #[test]
134    fn test_removes_comments_and_metadata() {
135        let input = r#"<?xml version="1.0" encoding="UTF-8"?>
136<!-- This is a comment -->
137<svg xmlns="http://www.w3.org/2000/svg">
138  <metadata>
139    <rdf:RDF>
140      <cc:Work>
141        <dc:title>Test</dc:title>
142      </cc:Work>
143    </rdf:RDF>
144  </metadata>
145  <!-- Another comment -->
146  <circle r="10" />
147</svg>"#;
148
149        let result = optimize_svg_content(input).unwrap();
150
151        // Verify comments and metadata are removed
152        assert!(!result.contains("<!-- This is a comment -->"));
153        assert!(!result.contains("<!-- Another comment -->"));
154        assert!(!result.contains("<metadata"));
155        assert!(!result.contains("</metadata>"));
156        assert!(!result.contains("<rdf:RDF"));
157        assert!(!result.contains("<cc:Work"));
158        assert!(!result.contains("<dc:title>"));
159
160        // Verify essential content is preserved
161        assert!(result.contains("<svg"));
162        assert!(result.contains("<circle"));
163        assert!(result.contains("r=\"10\""));
164        assert!(result.contains("</svg>"));
165    }
166
167    #[test]
168    fn test_removes_editor_specific_attributes() {
169        let input = r#"<svg xmlns="http://www.w3.org/2000/svg"
170     xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
171     inkscape:version="1.0"
172     inkscape:current-layer="layer1"
173     sodipodi:docname="test.svg"
174     adobe-illustrator-version="25.0">
175  <circle r="10" inkscape:label="Circle" adobe-blend-mode="normal" />
176</svg>"#;
177
178        let result = optimize_svg_content(input).unwrap();
179
180        // Verify editor-specific attributes are removed
181        assert!(!result.contains("inkscape:version"));
182        assert!(!result.contains("inkscape:current-layer"));
183        assert!(!result.contains("sodipodi:docname"));
184        assert!(!result.contains("adobe-illustrator-version"));
185        assert!(!result.contains("inkscape:label"));
186        assert!(!result.contains("adobe-blend-mode"));
187
188        // Verify essential attributes are preserved
189        assert!(result.contains("xmlns=\"http://www.w3.org/2000/svg\""));
190        assert!(result.contains("xmlns:inkscape"));
191        assert!(result.contains("<circle"));
192        assert!(result.contains("r=\"10\""));
193    }
194
195    #[test]
196    fn test_preserves_style_and_class_attributes() {
197        let input = r#"<svg xmlns="http://www.w3.org/2000/svg">
198  <style>
199    .red { fill: red; }
200    .blue { fill: blue; }
201  </style>
202  <circle class="red" style="stroke: black; stroke-width: 2" />
203  <rect class="blue" style="opacity: 0.8" />
204</svg>"#;
205
206        let result = optimize_svg_content(input).unwrap();
207
208        // Verify style-related content is preserved
209        assert!(result.contains("<style>"));
210        assert!(result.contains(".red { fill: red; }"));
211        assert!(result.contains(".blue { fill: blue; }"));
212        assert!(result.contains("</style>"));
213        assert!(result.contains("class=\"red\""));
214        assert!(result.contains("class=\"blue\""));
215        assert!(result.contains("style=\"stroke: black; stroke-width: 2\""));
216        assert!(result.contains("style=\"opacity: 0.8\""));
217    }
218
219    #[test]
220    fn test_preserves_definitions_and_uses() {
221        let input = r##"<svg xmlns="http://www.w3.org/2000/svg">
222  <defs>
223    <linearGradient id="grad1">
224      <stop offset="0%" stop-color="red" />
225      <stop offset="100%" stop-color="blue" />
226    </linearGradient>
227    <pattern id="pattern1">
228      <rect width="10" height="10" fill="green" />
229    </pattern>
230  </defs>
231  <rect fill="url(#grad1)" />
232  <circle fill="url(#pattern1)" />
233  <use xlink:href="#someElement" />
234</svg>"##;
235
236        let result = optimize_svg_content(input).unwrap();
237
238        // Verify definitions and references are preserved
239        assert!(result.contains("<defs>"));
240        assert!(result.contains("</defs>"));
241        assert!(result.contains("<linearGradient"));
242        assert!(result.contains("id=\"grad1\""));
243        assert!(result.contains("<stop"));
244        assert!(result.contains("stop-color=\"red\""));
245        assert!(result.contains("<pattern"));
246        assert!(result.contains("id=\"pattern1\""));
247        assert!(result.contains("fill=\"url("));
248        assert!(result.contains("grad1)\""));
249        assert!(result.contains("pattern1)\""));
250        assert!(result.contains("<use"));
251        assert!(result.contains("xlink:href=\""));
252        assert!(result.contains("someElement\""));
253    }
254
255    #[test]
256    fn test_preserves_animations() {
257        let input = r#"<svg xmlns="http://www.w3.org/2000/svg">
258  <circle r="10">
259    <animate attributeName="r" values="10;20;10" dur="2s" repeatCount="indefinite" />
260    <animateTransform attributeName="transform" type="rotate" 
261                      values="0;360" dur="1s" repeatCount="indefinite" />
262  </circle>
263</svg>"#;
264
265        let result = optimize_svg_content(input).unwrap();
266
267        // Verify animations are preserved
268        assert!(result.contains("<animate"));
269        assert!(result.contains("attributeName=\"r\""));
270        assert!(result.contains("values=\"10;20;10\""));
271        assert!(result.contains("dur=\"2s\""));
272        assert!(result.contains("repeatCount=\"indefinite\""));
273        assert!(result.contains("<animateTransform"));
274        assert!(result.contains("type=\"rotate\""));
275        assert!(result.contains("values=\"0;360\""));
276    }
277
278    #[test]
279    fn test_normalizes_whitespace_but_preserves_structure() {
280        let input = r#"<svg    xmlns="http://www.w3.org/2000/svg"    >
281
282
283  <circle     cx="50"     cy="50"     r="40"     />
284
285
286  <rect   x="10"   y="10"   />
287
288</svg>"#;
289
290        let result = optimize_svg_content(input).unwrap();
291
292        // Verify structure is preserved but whitespace is normalized
293        assert!(result.contains("<svg"));
294        assert!(result.contains("xmlns=\"http://www.w3.org/2000/svg\""));
295        assert!(result.contains("<circle"));
296        assert!(result.contains("cx=\"50\""));
297        assert!(result.contains("cy=\"50\""));
298        assert!(result.contains("r=\"40\""));
299        assert!(result.contains("<rect"));
300        assert!(result.contains("x=\"10\""));
301        assert!(result.contains("y=\"10\""));
302        assert!(result.contains("</svg>"));
303
304        // Verify excessive whitespace is removed
305        assert!(!result.contains("    xmlns"));
306        assert!(!result.contains("     cx"));
307        assert!(!result.contains("\n\n\n"));
308    }
309
310    #[test]
311    fn test_handles_multiline_comments() {
312        let input = r#"<svg xmlns="http://www.w3.org/2000/svg">
313  <!--
314    This is a multiline comment
315    that spans multiple lines
316    and should be removed
317  -->
318  <circle r="10" />
319</svg>"#;
320
321        let result = optimize_svg_content(input).unwrap();
322
323        // Verify multiline comment is removed
324        assert!(!result.contains("This is a multiline comment"));
325        assert!(!result.contains("that spans multiple lines"));
326        assert!(!result.contains("and should be removed"));
327
328        // Verify content is preserved
329        assert!(result.contains("<svg"));
330        assert!(result.contains("<circle"));
331        assert!(result.contains("r=\"10\""));
332    }
333
334    #[test]
335    fn test_preserves_viewbox_and_coordinate_systems() {
336        let input = r#"<svg xmlns="http://www.w3.org/2000/svg" 
337             viewBox="0 0 200 200" 
338             preserveAspectRatio="xMidYMid meet">
339  <g transform="translate(50, 50) scale(2)">
340    <circle r="10" />
341  </g>
342</svg>"#;
343
344        let result = optimize_svg_content(input).unwrap();
345
346        // Verify coordinate system attributes are preserved
347        assert!(result.contains("viewBox=\"0 0 200 200\""));
348        assert!(result.contains("preserveAspectRatio=\"xMidYMid meet\""));
349        assert!(result.contains("transform=\"translate(50, 50) scale(2)\""));
350    }
351
352    #[test]
353    fn test_empty_svg_handled_gracefully() {
354        let input = r#"<svg xmlns="http://www.w3.org/2000/svg"></svg>"#;
355
356        let result = optimize_svg_content(input).unwrap();
357
358        // Verify basic structure is preserved even for empty SVG
359        assert!(result.contains("<svg"));
360        assert!(result.contains("xmlns=\"http://www.w3.org/2000/svg\""));
361        assert!(result.contains("</svg>"));
362    }
363
364    #[test]
365    fn test_preserves_data_attributes_and_ids() {
366        let input = r#"<svg xmlns="http://www.w3.org/2000/svg" data-name="icon">
367  <circle id="main-circle" data-value="42" class="important" />
368  <rect id="background" data-layer="base" />
369</svg>"#;
370
371        let result = optimize_svg_content(input).unwrap();
372
373        // Verify data attributes and IDs are preserved
374        assert!(result.contains("data-name=\"icon\""));
375        assert!(result.contains("id=\"main-circle\""));
376        assert!(result.contains("data-value=\"42\""));
377        assert!(result.contains("class=\"important\""));
378        assert!(result.contains("id=\"background\""));
379        assert!(result.contains("data-layer=\"base\""));
380    }
381}