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(¤t, 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 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 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 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: ®ex::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: ®ex::Captures| {
143 format!("id=\"{}{}\"", prefix, &caps[1])
144 })
145 .to_string();
146
147 url_re
148 .replace_all(&with_ids, |caps: ®ex::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}