svgpack_optimizer 0.1.0

Optimization pass engine for svgpack.
Documentation
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(&current, 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();

    // Tier 1 cleanup defaults.
    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);
    }

    // Tier 2 basic normalization.
    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);
    }

    // Tier 3 lite minification.
    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: &regex::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: &regex::Captures| {
            format!("id=\"{}{}\"", prefix, &caps[1])
        })
        .to_string();

    url_re
        .replace_all(&with_ids, |caps: &regex::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"));
    }
}