use regex::Regex;
use svgpack_config::{Preset, SvgpackConfig};
#[derive(Debug, Clone)]
pub struct OptimizeStats {
pub original_bytes: usize,
pub optimized_bytes: usize,
}
#[derive(Debug, Clone)]
pub struct OptimizeResult {
pub data: String,
pub stats: OptimizeStats,
}
pub fn optimize_svg(input: &str, config: &SvgpackConfig) -> OptimizeResult {
let mut current = input.to_string();
let loops = if config.multipass {
config.max_passes.max(1)
} else {
1
};
for _ in 0..loops {
let before = current.len();
current = run_passes(¤t, config);
if current.len() >= before {
break;
}
}
OptimizeResult {
stats: OptimizeStats {
original_bytes: input.len(),
optimized_bytes: current.len(),
},
data: current,
}
}
fn run_passes(input: &str, config: &SvgpackConfig) -> String {
let mut out = input.to_string();
if config.pass_enabled("remove-xml-declaration", true) {
out = regex_replace(r#"(?s)<\?xml[^>]*\?>"#, "", &out);
}
if config.pass_enabled("remove-doctype", true) {
out = regex_replace(r#"(?s)<!DOCTYPE[^>]*>"#, "", &out);
}
if config.pass_enabled("remove-comments", true) {
out = regex_replace(r#"(?s)<!--.*?-->"#, "", &out);
}
if config.pass_enabled("remove-metadata", true) {
out = regex_replace(r#"(?s)<metadata\b[^>]*>.*?</metadata>"#, "", &out);
}
if config.pass_enabled("cleanup-attributes", true) {
out = cleanup_attribute_spacing(&out);
}
if config.pass_enabled("remove-empty-containers", true) {
out = regex_replace(r#"(?s)<g\b[^>]*>\s*</g>"#, "", &out);
out = regex_replace(r#"(?s)<defs\b[^>]*>\s*</defs>"#, "", &out);
}
if config.pass_enabled("minify-colors", true) {
out = minify_common_colors(&out);
}
if config.pass_enabled("minify-numbers", true) {
out = trim_numeric_literals(&out);
}
if config.pass_enabled("prefix-ids", matches!(config.preset, Preset::IconLibrary)) {
out = prefix_ids(&out, &config.id_prefix());
}
out = collapse_whitespace(&out);
out.trim().to_string()
}
fn regex_replace(pattern: &str, replace: &str, input: &str) -> String {
Regex::new(pattern)
.map(|re| re.replace_all(input, replace).to_string())
.unwrap_or_else(|_| input.to_string())
}
fn cleanup_attribute_spacing(input: &str) -> String {
regex_replace(r#"\s*=\s*"#, "=", input)
}
fn collapse_whitespace(input: &str) -> String {
let out = regex_replace(r#">\s+<"#, "><", input);
regex_replace(r#"\s{2,}"#, " ", &out)
}
fn minify_common_colors(input: &str) -> String {
input
.replace("#ffffff", "#fff")
.replace("#FFFFFF", "#fff")
.replace("#000000", "#000")
.replace("#000000ff", "#000")
.replace("rgb(0,0,0)", "#000")
.replace("rgb(255,255,255)", "#fff")
}
fn trim_numeric_literals(input: &str) -> String {
let number_re = match Regex::new(r"-?\d+\.\d+") {
Ok(re) => re,
Err(_) => return input.to_string(),
};
number_re
.replace_all(input, |caps: ®ex::Captures| {
let mut n = caps[0].to_string();
while n.contains('.') && n.ends_with('0') {
n.pop();
}
if n.ends_with('.') {
n.pop();
}
if n.starts_with("0.") {
n = n.replacen("0.", ".", 1);
} else if n.starts_with("-0.") {
n = n.replacen("-0.", "-.", 1);
}
n
})
.to_string()
}
fn prefix_ids(input: &str, prefix: &str) -> String {
let id_re = match Regex::new(r#"id="([^"]+)""#) {
Ok(re) => re,
Err(_) => return input.to_string(),
};
let url_re = match Regex::new(r#"url\(#([^)]+)\)"#) {
Ok(re) => re,
Err(_) => return input.to_string(),
};
let with_ids = id_re
.replace_all(input, |caps: ®ex::Captures| {
format!("id=\"{}{}\"", prefix, &caps[1])
})
.to_string();
url_re
.replace_all(&with_ids, |caps: ®ex::Captures| {
format!("url(#{prefix}{})", &caps[1])
})
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use svgpack_config::{PassOptions, SvgpackConfig};
fn base_svg() -> &'static str {
r##"<?xml version="1.0"?>
<!-- comment -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN">
<svg viewBox="0 0 24 24">
<metadata>x</metadata>
<defs></defs>
<path id="a" fill="#ffffff" d="M 0.5000 1.0000 L 2.0000 3.0000" />
<use href="#a" fill="rgb(0,0,0)" filter="url(#a)" />
</svg>"##
}
#[test]
fn removes_cleanup_artifacts_by_default() {
let cfg = SvgpackConfig::default();
let result = optimize_svg(base_svg(), &cfg).data;
assert!(!result.contains("<?xml"));
assert!(!result.contains("<!--"));
assert!(!result.contains("<!DOCTYPE"));
assert!(!result.contains("<metadata"));
assert!(!result.contains("<defs></defs>"));
}
#[test]
fn minifies_numbers_and_colors() {
let cfg = SvgpackConfig::default();
let result = optimize_svg(base_svg(), &cfg).data;
assert!(result.contains(".5"));
assert!(result.contains("1"));
assert!(result.contains("#fff"));
assert!(result.contains("#000"));
}
#[test]
fn can_disable_pass() {
let mut cfg = SvgpackConfig::default();
cfg.passes.insert(
"remove-comments".to_string(),
PassOptions {
enabled: false,
..Default::default()
},
);
let result = optimize_svg(base_svg(), &cfg).data;
assert!(result.contains("<!-- comment -->"));
}
#[test]
fn prefixes_ids_for_icon_preset() {
let mut cfg = SvgpackConfig {
preset: Preset::IconLibrary,
..Default::default()
};
cfg.passes.insert(
"prefix-ids".to_string(),
PassOptions {
enabled: true,
prefix: Some("ds-".to_string()),
..Default::default()
},
);
let result = optimize_svg(base_svg(), &cfg).data;
assert!(result.contains("ds-a"));
}
}