1pub mod processor;
8mod write;
9pub mod xml_passes;
10
11use roxmltree::Document;
12use xmlwriter::XmlWriter;
13
14#[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
36const 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
65pub fn optimize_svg(input: &str, config: &OptimizeConfig) -> Result<String, OptimizeError> {
71 let cleaned = strip_xml_prolog(input);
73 let doc = Document::parse(&cleaned).map_err(|e| OptimizeError::Parse(e.to_string()))?;
74
75 let editor_prefixes = xml_passes::collect_editor_prefixes(&doc, EDITOR_NAMESPACE_URIS);
77
78 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
96fn strip_xml_prolog(input: &str) -> String {
101 let mut result = String::with_capacity(input.len());
102 let mut remaining = input.trim_start();
103
104 if remaining.starts_with("<?xml")
106 && let Some(end) = remaining.find("?>")
107 {
108 remaining = remaining[end + 2..].trim_start();
109 }
110
111 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 assert!(result.contains("<rect"), "visual elements should survive");
195 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 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 #[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 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 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}