Skip to main content

svgpack_optimizer/
lib.rs

1use regex::Regex;
2use svgpack_config::{Preset, SvgpackConfig};
3
4#[derive(Debug, Clone)]
5pub struct OptimizeStats {
6    pub original_bytes: usize,
7    pub optimized_bytes: usize,
8}
9
10#[derive(Debug, Clone)]
11pub struct OptimizeResult {
12    pub data: String,
13    pub stats: OptimizeStats,
14}
15
16pub fn optimize_svg(input: &str, config: &SvgpackConfig) -> OptimizeResult {
17    let mut current = input.to_string();
18    let loops = if config.multipass {
19        config.max_passes.max(1)
20    } else {
21        1
22    };
23
24    for _ in 0..loops {
25        let before = current.len();
26        current = run_passes(&current, config);
27        if current.len() >= before {
28            break;
29        }
30    }
31
32    OptimizeResult {
33        stats: OptimizeStats {
34            original_bytes: input.len(),
35            optimized_bytes: current.len(),
36        },
37        data: current,
38    }
39}
40
41fn run_passes(input: &str, config: &SvgpackConfig) -> String {
42    let mut out = input.to_string();
43
44    // Tier 1 cleanup defaults.
45    if config.pass_enabled("remove-xml-declaration", true) {
46        out = regex_replace(r#"(?s)<\?xml[^>]*\?>"#, "", &out);
47    }
48    if config.pass_enabled("remove-doctype", true) {
49        out = regex_replace(r#"(?s)<!DOCTYPE[^>]*>"#, "", &out);
50    }
51    if config.pass_enabled("remove-comments", true) {
52        out = regex_replace(r#"(?s)<!--.*?-->"#, "", &out);
53    }
54    if config.pass_enabled("remove-metadata", true) {
55        out = regex_replace(r#"(?s)<metadata\b[^>]*>.*?</metadata>"#, "", &out);
56    }
57
58    // Tier 2 basic normalization.
59    if config.pass_enabled("cleanup-attributes", true) {
60        out = cleanup_attribute_spacing(&out);
61    }
62    if config.pass_enabled("remove-empty-containers", true) {
63        out = regex_replace(r#"(?s)<g\b[^>]*>\s*</g>"#, "", &out);
64        out = regex_replace(r#"(?s)<defs\b[^>]*>\s*</defs>"#, "", &out);
65    }
66
67    // Tier 3 lite minification.
68    if config.pass_enabled("minify-colors", true) {
69        out = minify_common_colors(&out);
70    }
71    if config.pass_enabled("minify-numbers", true) {
72        out = trim_numeric_literals(&out);
73    }
74    if config.pass_enabled("prefix-ids", matches!(config.preset, Preset::IconLibrary)) {
75        out = prefix_ids(&out, &config.id_prefix());
76    }
77    out = collapse_whitespace(&out);
78
79    out.trim().to_string()
80}
81
82fn regex_replace(pattern: &str, replace: &str, input: &str) -> String {
83    Regex::new(pattern)
84        .map(|re| re.replace_all(input, replace).to_string())
85        .unwrap_or_else(|_| input.to_string())
86}
87
88fn cleanup_attribute_spacing(input: &str) -> String {
89    regex_replace(r#"\s*=\s*"#, "=", input)
90}
91
92fn collapse_whitespace(input: &str) -> String {
93    let out = regex_replace(r#">\s+<"#, "><", input);
94    regex_replace(r#"\s{2,}"#, " ", &out)
95}
96
97fn minify_common_colors(input: &str) -> String {
98    input
99        .replace("#ffffff", "#fff")
100        .replace("#FFFFFF", "#fff")
101        .replace("#000000", "#000")
102        .replace("#000000ff", "#000")
103        .replace("rgb(0,0,0)", "#000")
104        .replace("rgb(255,255,255)", "#fff")
105}
106
107fn trim_numeric_literals(input: &str) -> String {
108    let number_re = match Regex::new(r"-?\d+\.\d+") {
109        Ok(re) => re,
110        Err(_) => return input.to_string(),
111    };
112    number_re
113        .replace_all(input, |caps: &regex::Captures| {
114            let mut n = caps[0].to_string();
115            while n.contains('.') && n.ends_with('0') {
116                n.pop();
117            }
118            if n.ends_with('.') {
119                n.pop();
120            }
121            if n.starts_with("0.") {
122                n = n.replacen("0.", ".", 1);
123            } else if n.starts_with("-0.") {
124                n = n.replacen("-0.", "-.", 1);
125            }
126            n
127        })
128        .to_string()
129}
130
131fn prefix_ids(input: &str, prefix: &str) -> String {
132    let id_re = match Regex::new(r#"id="([^"]+)""#) {
133        Ok(re) => re,
134        Err(_) => return input.to_string(),
135    };
136    let url_re = match Regex::new(r#"url\(#([^)]+)\)"#) {
137        Ok(re) => re,
138        Err(_) => return input.to_string(),
139    };
140
141    let with_ids = id_re
142        .replace_all(input, |caps: &regex::Captures| {
143            format!("id=\"{}{}\"", prefix, &caps[1])
144        })
145        .to_string();
146
147    url_re
148        .replace_all(&with_ids, |caps: &regex::Captures| {
149            format!("url(#{prefix}{})", &caps[1])
150        })
151        .to_string()
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157    use svgpack_config::{PassOptions, SvgpackConfig};
158
159    fn base_svg() -> &'static str {
160        r##"<?xml version="1.0"?>
161        <!-- comment -->
162        <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN">
163        <svg viewBox="0 0 24 24">
164          <metadata>x</metadata>
165          <defs></defs>
166          <path id="a" fill="#ffffff" d="M 0.5000 1.0000 L 2.0000 3.0000" />
167          <use href="#a" fill="rgb(0,0,0)" filter="url(#a)" />
168        </svg>"##
169    }
170
171    #[test]
172    fn removes_cleanup_artifacts_by_default() {
173        let cfg = SvgpackConfig::default();
174        let result = optimize_svg(base_svg(), &cfg).data;
175        assert!(!result.contains("<?xml"));
176        assert!(!result.contains("<!--"));
177        assert!(!result.contains("<!DOCTYPE"));
178        assert!(!result.contains("<metadata"));
179        assert!(!result.contains("<defs></defs>"));
180    }
181
182    #[test]
183    fn minifies_numbers_and_colors() {
184        let cfg = SvgpackConfig::default();
185        let result = optimize_svg(base_svg(), &cfg).data;
186        assert!(result.contains(".5"));
187        assert!(result.contains("1"));
188        assert!(result.contains("#fff"));
189        assert!(result.contains("#000"));
190    }
191
192    #[test]
193    fn can_disable_pass() {
194        let mut cfg = SvgpackConfig::default();
195        cfg.passes.insert(
196            "remove-comments".to_string(),
197            PassOptions {
198                enabled: false,
199                ..Default::default()
200            },
201        );
202        let result = optimize_svg(base_svg(), &cfg).data;
203        assert!(result.contains("<!-- comment -->"));
204    }
205
206    #[test]
207    fn prefixes_ids_for_icon_preset() {
208        let mut cfg = SvgpackConfig {
209            preset: Preset::IconLibrary,
210            ..Default::default()
211        };
212        cfg.passes.insert(
213            "prefix-ids".to_string(),
214            PassOptions {
215                enabled: true,
216                prefix: Some("ds-".to_string()),
217                ..Default::default()
218            },
219        );
220        let result = optimize_svg(base_svg(), &cfg).data;
221        assert!(result.contains("ds-a"));
222    }
223}