Skip to main content

bnto_vector/optimize/
mod.rs

1// SVG optimizer — parse with roxmltree, run passes, write with xmlwriter.
2//
3// roxmltree is read-only, so the pattern is: parse once, walk the tree,
4// selectively write to xmlwriter — skipping or transforming nodes based
5// on the active pass configuration.
6
7pub mod processor;
8mod write;
9pub mod xml_passes;
10
11use roxmltree::Document;
12use xmlwriter::XmlWriter;
13
14/// Configuration controlling which optimization passes run.
15#[derive(Debug, Clone)]
16pub struct OptimizeConfig {
17    pub remove_comments: bool,
18    pub remove_metadata: bool,
19    pub collapse_groups: bool,
20    pub minify: bool,
21    pub precision: u8,
22}
23
24impl Default for OptimizeConfig {
25    fn default() -> Self {
26        Self {
27            remove_comments: true,
28            remove_metadata: true,
29            collapse_groups: true,
30            minify: true,
31            precision: 3,
32        }
33    }
34}
35
36/// Known editor namespace URIs — elements and attributes with these
37/// namespaces are removed as editor cruft.
38const EDITOR_NAMESPACE_URIS: &[&str] = &[
39    "http://www.inkscape.org/namespaces/inkscape",
40    "http://sodipodi.sourceforge.net/DTD/sodipodi-0.0.dtd",
41    "http://purl.org/dc/elements/1.1/",
42    "http://creativecommons.org/ns#",
43    "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
44    "http://www.bohemiancoding.com/sketch/ns",
45    "http://ns.adobe.com/AdobeIllustrator/10.0/",
46    "http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/",
47    "http://ns.adobe.com/Extensibility/1.0/",
48    "http://ns.adobe.com/Flows/1.0/",
49    "http://ns.adobe.com/Graphs/1.0/",
50    "http://ns.adobe.com/ImageReplacement/1.0/",
51    "http://ns.adobe.com/SaveForWeb/1.0/",
52    "http://ns.adobe.com/Variables/1.0/",
53    "http://ns.adobe.com/xap/1.0/",
54    "http://ns.adobe.com/xap/1.0/mm/",
55    "http://ns.adobe.com/xap/1.0/sType/ResourceRef#",
56    "http://www.serif.com/",
57    "http://www.figma.com/figma/ns",
58    "http://www.vectornator.io/",
59    "http://schemas.microsoft.com/visio/2003/SVGExtensions/",
60    "http://krita.org/namespaces/svg/krita",
61    "http://www.w3.org/2004/02/skos/core#",
62    "http://www.boxysvg.com/ns",
63];
64
65/// Run the SVG optimization pipeline.
66///
67/// Parses the input SVG with roxmltree, walks the tree once, and writes
68/// optimized output with xmlwriter — skipping nodes that match removal
69/// criteria based on the config.
70pub fn optimize_svg(input: &str, config: &OptimizeConfig) -> Result<String, OptimizeError> {
71    // Strip XML declaration and DOCTYPE — roxmltree doesn't support DTD.
72    let cleaned = strip_xml_prolog(input);
73    let doc = Document::parse(&cleaned).map_err(|e| OptimizeError::Parse(e.to_string()))?;
74
75    // Phase 1: analysis — collect editor namespace prefixes from the root SVG element.
76    let editor_prefixes = xml_passes::collect_editor_prefixes(&doc, EDITOR_NAMESPACE_URIS);
77
78    // Phase 2: streaming write — walk tree, apply passes inline.
79    let mut opts = xmlwriter::Options::default();
80    if config.minify {
81        opts.indent = xmlwriter::Indent::None;
82    }
83    let mut writer = XmlWriter::new(opts);
84    let mut declared_ns = std::collections::HashSet::new();
85    write::write_node(
86        doc.root(),
87        &mut writer,
88        config,
89        &editor_prefixes,
90        &mut declared_ns,
91    );
92
93    Ok(writer.end_document())
94}
95
96/// Strip XML declaration (`<?xml ... ?>`) and DOCTYPE from input.
97///
98/// roxmltree rejects DTD declarations. Real-world SVGs from editors
99/// often include `<!DOCTYPE svg ...>` which we simply discard.
100fn strip_xml_prolog(input: &str) -> String {
101    let mut result = String::with_capacity(input.len());
102    let mut remaining = input.trim_start();
103
104    // Skip XML declaration
105    if remaining.starts_with("<?xml")
106        && let Some(end) = remaining.find("?>")
107    {
108        remaining = remaining[end + 2..].trim_start();
109    }
110
111    // Skip DOCTYPE
112    if remaining.starts_with("<!DOCTYPE")
113        && let Some(end) = remaining.find('>')
114    {
115        remaining = remaining[end + 1..].trim_start();
116    }
117
118    result.push_str(remaining);
119    result
120}
121
122#[derive(Debug, thiserror::Error)]
123pub enum OptimizeError {
124    #[error("SVG parse error: {0}")]
125    Parse(String),
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131
132    fn verbose_svg() -> String {
133        std::fs::read_to_string(concat!(
134            env!("CARGO_MANIFEST_DIR"),
135            "/../../../test-fixtures/vector/verbose.svg"
136        ))
137        .expect("verbose.svg fixture must exist")
138    }
139
140    fn simple_svg() -> &'static str {
141        r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100"><rect x="0" y="0" width="100" height="100" fill="blue"/></svg>"#
142    }
143
144    #[test]
145    fn test_optimize_produces_valid_svg() {
146        let result = optimize_svg(&verbose_svg(), &OptimizeConfig::default()).unwrap();
147        assert!(result.contains("<svg"));
148        assert!(result.contains("</svg>"));
149    }
150
151    #[test]
152    fn test_optimize_reduces_size() {
153        let input = verbose_svg();
154        let result = optimize_svg(&input, &OptimizeConfig::default()).unwrap();
155        assert!(
156            result.len() < input.len(),
157            "Optimized ({} bytes) should be smaller than input ({} bytes)",
158            result.len(),
159            input.len()
160        );
161    }
162
163    #[test]
164    fn test_removes_metadata_elements() {
165        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><metadata><title>Test</title></metadata><rect width="10" height="10"/></svg>"#;
166        let result = optimize_svg(svg, &OptimizeConfig::default()).unwrap();
167        assert!(!result.contains("<metadata"), "metadata should be removed");
168        assert!(result.contains("<rect"), "visual elements should survive");
169    }
170
171    #[test]
172    fn test_removes_comments() {
173        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><!-- remove me --><rect width="10" height="10"/></svg>"#;
174        let result = optimize_svg(svg, &OptimizeConfig::default()).unwrap();
175        assert!(!result.contains("<!--"), "comments should be removed");
176        assert!(result.contains("<rect"), "visual elements should survive");
177    }
178
179    #[test]
180    fn test_removes_editor_namespaces() {
181        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape" inkscape:version="1.3"><rect width="10" height="10" inkscape:label="bg"/></svg>"#;
182        let result = optimize_svg(svg, &OptimizeConfig::default()).unwrap();
183        assert!(
184            !result.contains("inkscape"),
185            "inkscape attributes should be removed"
186        );
187    }
188
189    #[test]
190    fn test_removes_empty_groups() {
191        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><g></g><rect width="10" height="10"/></svg>"#;
192        let result = optimize_svg(svg, &OptimizeConfig::default()).unwrap();
193        // The empty <g></g> should be gone
194        assert!(result.contains("<rect"), "visual elements should survive");
195        // Count <g occurrences — should be 0
196        assert!(
197            !result.contains("<g"),
198            "empty groups should be removed, got: {result}"
199        );
200    }
201
202    #[test]
203    fn test_collapses_single_child_groups() {
204        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><g><rect width="10" height="10"/></g></svg>"#;
205        let result = optimize_svg(svg, &OptimizeConfig::default()).unwrap();
206        assert!(!result.contains("<g"), "wrapper <g> should be collapsed");
207        assert!(result.contains("<rect"), "child element should survive");
208    }
209
210    #[test]
211    fn test_preserves_meaningful_groups() {
212        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><g id="layer1"><rect width="10" height="10"/></g></svg>"#;
213        let result = optimize_svg(svg, &OptimizeConfig::default()).unwrap();
214        assert!(result.contains("<g"), "group with id should be preserved");
215        assert!(result.contains("layer1"), "id attribute should survive");
216    }
217
218    #[test]
219    fn test_removes_empty_attributes() {
220        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><rect width="10" height="10" fill="" stroke="blue"/></svg>"#;
221        let result = optimize_svg(svg, &OptimizeConfig::default()).unwrap();
222        assert!(
223            !result.contains(r#"fill="""#),
224            "empty fill should be removed"
225        );
226        assert!(result.contains("stroke"), "non-empty stroke should survive");
227    }
228
229    #[test]
230    fn test_minify_strips_whitespace() {
231        let svg = "<svg xmlns=\"http://www.w3.org/2000/svg\">\n  <rect width=\"10\" height=\"10\"/>\n</svg>";
232        let result = optimize_svg(svg, &OptimizeConfig::default()).unwrap();
233        assert!(
234            !result.contains('\n'),
235            "newlines should be stripped when minifying"
236        );
237    }
238
239    #[test]
240    fn test_param_removecomments_false_preserves_comments() {
241        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><!-- keep me --><rect width="10" height="10"/></svg>"#;
242        let config = OptimizeConfig {
243            remove_comments: false,
244            ..Default::default()
245        };
246        let result = optimize_svg(svg, &config).unwrap();
247        assert!(
248            result.contains("<!-- keep me -->"),
249            "comment should be preserved"
250        );
251    }
252
253    #[test]
254    fn test_param_removemetadata_false_preserves_metadata() {
255        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><metadata><title>Keep</title></metadata><rect width="10" height="10"/></svg>"#;
256        let config = OptimizeConfig {
257            remove_metadata: false,
258            ..Default::default()
259        };
260        let result = optimize_svg(svg, &config).unwrap();
261        assert!(result.contains("<metadata"), "metadata should be preserved");
262    }
263
264    #[test]
265    fn test_param_collapsegroups_false_preserves_groups() {
266        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg"><g><rect width="10" height="10"/></g></svg>"#;
267        let config = OptimizeConfig {
268            collapse_groups: false,
269            ..Default::default()
270        };
271        let result = optimize_svg(svg, &config).unwrap();
272        assert!(result.contains("<g"), "group should be preserved");
273    }
274
275    #[test]
276    fn test_param_minify_false_preserves_whitespace() {
277        let svg = "<svg xmlns=\"http://www.w3.org/2000/svg\">\n  <rect width=\"10\" height=\"10\"/>\n</svg>";
278        let config = OptimizeConfig {
279            minify: false,
280            ..Default::default()
281        };
282        let result = optimize_svg(svg, &config).unwrap();
283        // With minify off, the text content should still have some structure
284        assert!(result.contains("<rect"), "visual elements should survive");
285    }
286
287    #[test]
288    fn test_roundtrip_preserves_visual_elements() {
289        let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" width="100" height="100" viewBox="0 0 100 100"><rect x="0" y="0" width="100" height="100" fill="blue"/><circle cx="50" cy="50" r="30" fill="orange"/><path d="M 10 10 L 90 90"/><text x="50" y="55">Hello</text></svg>"#;
290        let result = optimize_svg(svg, &OptimizeConfig::default()).unwrap();
291        assert!(result.contains("<rect"), "rect should survive");
292        assert!(result.contains("<circle"), "circle should survive");
293        assert!(result.contains("<path"), "path should survive");
294        assert!(result.contains("<text"), "text should survive");
295        assert!(result.contains("Hello"), "text content should survive");
296    }
297
298    #[test]
299    fn test_invalid_svg_returns_error() {
300        let result = optimize_svg("not valid svg", &OptimizeConfig::default());
301        assert!(result.is_err(), "invalid SVG should return error");
302    }
303
304    #[test]
305    fn test_simple_svg_roundtrips() {
306        let result = optimize_svg(simple_svg(), &OptimizeConfig::default()).unwrap();
307        assert!(result.contains("<svg"));
308        assert!(result.contains("<rect"));
309        assert!(result.contains("fill=\"blue\""));
310    }
311
312    // --- Quantitative proof tests ---
313    // These tests assert measurable optimization outcomes,
314    // not just structural correctness.
315
316    #[test]
317    fn test_verbose_svg_achieves_minimum_reduction() {
318        let input = verbose_svg();
319        let result = optimize_svg(&input, &OptimizeConfig::default()).unwrap();
320        let reduction_pct = (input.len() - result.len()) as f64 / input.len() as f64 * 100.0;
321        assert!(
322            reduction_pct >= 50.0,
323            "Expected >=50% reduction on verbose Inkscape SVG, got {reduction_pct:.1}% \
324             (input={} bytes, output={} bytes)",
325            input.len(),
326            result.len(),
327        );
328    }
329
330    #[test]
331    fn test_no_redundant_xmlns_on_child_elements() {
332        let result = optimize_svg(&verbose_svg(), &OptimizeConfig::default()).unwrap();
333        // xmlns should appear exactly once (on the root <svg>)
334        let xmlns_count = result.matches("xmlns=").count();
335        assert_eq!(
336            xmlns_count, 1,
337            "xmlns= should appear once (root only), found {xmlns_count} in: {result}"
338        );
339    }
340
341    #[test]
342    fn test_no_editor_namespace_survives() {
343        let result = optimize_svg(&verbose_svg(), &OptimizeConfig::default()).unwrap();
344        for keyword in &["inkscape", "sodipodi", "sketch", "rdf", "dc:", "cc:"] {
345            assert!(
346                !result.contains(keyword),
347                "Editor namespace '{keyword}' should be removed, found in: {result}"
348            );
349        }
350    }
351
352    #[test]
353    fn test_no_xml_prolog_in_output() {
354        let result = optimize_svg(&verbose_svg(), &OptimizeConfig::default()).unwrap();
355        assert!(
356            !result.contains("<?xml"),
357            "XML declaration should be stripped"
358        );
359        assert!(!result.contains("<!DOCTYPE"), "DOCTYPE should be stripped");
360    }
361
362    #[test]
363    fn test_empty_group_nesting_eliminated() {
364        let result = optimize_svg(&verbose_svg(), &OptimizeConfig::default()).unwrap();
365        // The verbose fixture has nested <g> wrappers — verify they're collapsed.
366        // Only the meaningful group (id="layer1") should survive.
367        let g_count = result.matches("<g").count();
368        assert!(
369            g_count <= 1,
370            "Expected at most 1 <g> tag (the id=layer1 group), found {g_count}"
371        );
372    }
373}