Skip to main content

bnto_vector/optimize/
xml_passes.rs

1// XML-level pass helpers for the SVG optimizer.
2//
3// Each function is a pure decision — takes a node or attribute,
4// returns whether it should be kept or skipped. No tree mutation.
5
6use roxmltree::{Attribute, Document, ExpandedName};
7
8/// Collect namespace prefixes bound to editor URIs on the root SVG element.
9///
10/// roxmltree exposes namespace declarations via `node.namespaces()`,
11/// not as regular attributes. Each namespace has `.name()` (the prefix)
12/// and `.uri()` (the namespace URI).
13pub fn collect_editor_prefixes(doc: &Document, editor_uris: &[&str]) -> Vec<String> {
14    let svg = match doc.root().first_element_child() {
15        Some(el) => el,
16        None => return Vec::new(),
17    };
18
19    let mut prefixes = Vec::new();
20    for ns in svg.namespaces() {
21        if let Some(prefix) = ns.name()
22            && editor_uris.contains(&ns.uri())
23        {
24            prefixes.push(prefix.to_string());
25        }
26    }
27    prefixes
28}
29
30/// Check if an element belongs to an editor namespace (e.g., `sodipodi:namedview`).
31pub fn is_editor_element(tag: ExpandedName, editor_prefixes: &[String]) -> bool {
32    let ns = match tag.namespace() {
33        Some(ns) => ns,
34        None => return false,
35    };
36
37    // Check if the namespace URI is an SVG or xlink namespace (keep those)
38    if ns == "http://www.w3.org/2000/svg" || ns == "http://www.w3.org/1999/xlink" {
39        return false;
40    }
41
42    // If the element has a resolved namespace that matches editor URIs,
43    // it should be removed. We also check prefixes for xmlns declarations
44    // that roxmltree already resolved.
45    let _ = editor_prefixes; // prefixes checked via namespace URI resolution
46    true
47}
48
49/// Elements that can be removed when empty (no surviving children).
50pub fn is_collapsible_container(tag_name: &str) -> bool {
51    matches!(
52        tag_name,
53        "g" | "defs" | "symbol" | "marker" | "clipPath" | "mask" | "pattern"
54    )
55}
56
57/// Check if a `<g>` has no meaningful attributes (only editor cruft or nothing).
58///
59/// A group with `id`, `class`, `style`, `transform`, `opacity`, `clip-path`,
60/// `mask`, `filter`, or `fill`/`stroke` attributes is meaningful and should
61/// NOT be collapsed even if it has only one child.
62pub fn has_no_meaningful_attributes(node: &roxmltree::Node, editor_prefixes: &[String]) -> bool {
63    for attr in node.attributes() {
64        if is_editor_attribute(&attr, editor_prefixes) {
65            continue;
66        }
67        if attr.value().is_empty() {
68            continue;
69        }
70        // Any non-editor, non-empty attribute makes the group meaningful
71        return false;
72    }
73    true
74}
75
76/// Check if an attribute belongs to an editor namespace.
77pub fn is_editor_attribute(attr: &Attribute, editor_prefixes: &[String]) -> bool {
78    // If the attribute has a resolved namespace URI, check it
79    if let Some(ns) = attr.namespace()
80        && ns != "http://www.w3.org/2000/svg"
81        && ns != "http://www.w3.org/1999/xlink"
82        && ns != "http://www.w3.org/XML/1998/namespace"
83    {
84        return true;
85    }
86
87    // Check if the attribute name has an editor prefix (for unresolved prefixed attrs)
88    if let Some(prefix) = attr.name().split(':').next()
89        && attr.name().contains(':')
90        && editor_prefixes.contains(&prefix.to_string())
91    {
92        return true;
93    }
94
95    false
96}
97
98/// Minify text content — collapse whitespace to a single space, trim.
99pub fn minify_text(text: &str) -> String {
100    let trimmed = text.trim();
101    if trimmed.is_empty() {
102        return String::new();
103    }
104
105    // Collapse runs of whitespace into single spaces
106    let mut result = String::with_capacity(trimmed.len());
107    let mut prev_was_space = false;
108    for ch in trimmed.chars() {
109        if ch.is_whitespace() {
110            if !prev_was_space {
111                result.push(' ');
112                prev_was_space = true;
113            }
114        } else {
115            result.push(ch);
116            prev_was_space = false;
117        }
118    }
119    result
120}
121
122#[cfg(test)]
123mod tests {
124    use super::*;
125
126    #[test]
127    fn test_collect_editor_prefixes_finds_inkscape() {
128        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"><rect/></svg>"#;
129        let doc = Document::parse(svg).unwrap();
130        let uris = &["http://www.inkscape.org/namespaces/inkscape"];
131        let prefixes = collect_editor_prefixes(&doc, uris);
132        assert_eq!(prefixes, vec!["inkscape"]);
133    }
134
135    #[test]
136    fn test_collect_editor_prefixes_ignores_svg_namespace() {
137        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><rect/></svg>"#;
138        let doc = Document::parse(svg).unwrap();
139        let uris = &["http://www.inkscape.org/namespaces/inkscape"];
140        let prefixes = collect_editor_prefixes(&doc, uris);
141        assert!(prefixes.is_empty());
142    }
143
144    #[test]
145    fn test_collapsible_containers() {
146        assert!(is_collapsible_container("g"));
147        assert!(is_collapsible_container("defs"));
148        assert!(is_collapsible_container("symbol"));
149        assert!(!is_collapsible_container("rect"));
150        assert!(!is_collapsible_container("svg"));
151        assert!(!is_collapsible_container("text"));
152    }
153
154    #[test]
155    fn test_minify_text_collapses_whitespace() {
156        assert_eq!(minify_text("  hello   world  "), "hello world");
157        assert_eq!(minify_text("\n  \t  "), "");
158        assert_eq!(minify_text("no change"), "no change");
159        assert_eq!(minify_text("  a  b  c  "), "a b c");
160    }
161
162    #[test]
163    fn test_minify_text_empty_string() {
164        assert_eq!(minify_text(""), "");
165        assert_eq!(minify_text("   "), "");
166    }
167}