1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
// this_file: crates/vexy-vsvg-plugin-sdk/src/plugins/cleanup_attrs.rs
//! Tidies messy attribute values by normalizing whitespace.
//!
//! SVG attributes often contain unnecessary formatting artifacts: newlines inserted
//! by editors, trailing spaces, or runs of multiple spaces. This plugin strips them
//! out, making attributes cleaner and more compressible.
//!
//! ## What it does
//!
//! - **Removes newlines**: Converts `\n` and `\r` to single spaces
//! - **Trims edges**: Strips leading and trailing whitespace
//! - **Collapses spaces**: Turns `"value with spaces"` into `"value with spaces"`
//!
//! ## What it preserves
//!
//! Certain attributes need exact whitespace for correctness, so the plugin skips:
//! - `xml:space` (controls whitespace handling in text elements)
//! - `preserveAspectRatio` (space-separated values)
//! - `viewBox` (space-separated coordinates)
//! - `points` (polygon/polyline coordinates)
//! - `d` (path data—spaces have meaning in syntax)
//!
//! ## Reference
//!
//! Ported from SVGO's `cleanupAttrs` plugin.
use anyhow::Result;
use serde::{Deserialize, Serialize};
use vexy_vsvg::ast::{Document, Element};
use vexy_vsvg::error::VexyError;
use vexy_vsvg::visitor::Visitor;
use crate::Plugin;
/// Configuration for the cleanup attrs plugin.
///
/// All three cleanup operations are enabled by default for maximum compression.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct CleanupAttrsParams {
/// Remove newlines (`\n`, `\r`) from attribute values, replacing them with spaces.
pub newlines: bool,
/// Trim leading and trailing whitespace from attribute values.
pub trim: bool,
/// Collapse multiple consecutive spaces into a single space.
pub spaces: bool,
}
impl Default for CleanupAttrsParams {
fn default() -> Self {
Self {
newlines: true,
trim: true,
spaces: true,
}
}
}
/// Cleans up attribute values by normalizing whitespace.
///
/// # Example
///
/// ```text
/// Before: <rect class=" my-class " title="Title\nwith\nnewlines" />
/// After: <rect class="my-class" title="Title with newlines" />
/// ```
#[derive(Default)]
pub struct CleanupAttrsPlugin {
params: CleanupAttrsParams,
}
impl CleanupAttrsPlugin {
/// Create a new CleanupAttrsPlugin with default settings
pub fn new() -> Self {
Self {
params: CleanupAttrsParams::default(),
}
}
/// Create plugin with specific parameters
pub fn with_params(params: CleanupAttrsParams) -> Self {
Self { params }
}
}
impl Plugin for CleanupAttrsPlugin {
fn name(&self) -> &'static str {
"cleanupAttrs"
}
fn description(&self) -> &'static str {
"Cleanup attributes from newlines, trailing and repeating spaces"
}
fn validate_params(&self, params: &serde_json::Value) -> anyhow::Result<()> {
// Accept null or empty object as valid (use defaults)
if params.is_null() || params.as_object().is_some_and(|obj| obj.is_empty()) {
return Ok(());
}
// Try to deserialize the params to validate their structure
serde_json::from_value::<CleanupAttrsParams>(params.clone())
.map_err(|e| anyhow::anyhow!("Invalid parameters: {}", e))?;
Ok(())
}
fn apply(&self, document: &mut Document) -> anyhow::Result<()> {
// Use the default params for now
let params = self.params.clone();
let mut visitor = CleanupAttrsVisitor::new(params);
vexy_vsvg::visitor::walk_document(&mut visitor, document)?;
// Self::cleanup_formatting_whitespace_recursive(&mut document.root);
Ok(())
}
}
/// Visitor that walks the document tree and cleans attribute values on each element.
struct CleanupAttrsVisitor {
params: CleanupAttrsParams,
}
impl CleanupAttrsVisitor {
fn new(params: CleanupAttrsParams) -> Self {
Self { params }
}
/// Applies all enabled cleanup operations to an attribute value.
fn clean_attribute_value(&self, value: &str) -> String {
let mut result = value.to_string();
// Remove newlines if requested
if self.params.newlines {
result = result.replace(['\n', '\r'], " ");
}
// Trim whitespace if requested
if self.params.trim {
result = result.trim().to_string();
}
// Collapse multiple spaces if requested
if self.params.spaces {
let mut cleaned = String::with_capacity(result.len());
let mut prev_space = false;
for ch in result.chars() {
if ch.is_whitespace() {
if !prev_space {
cleaned.push(' ');
prev_space = true;
}
} else {
cleaned.push(ch);
prev_space = false;
}
}
result = cleaned;
}
// Final trim if we've done any processing
if self.params.newlines || self.params.spaces {
result = result.trim().to_string();
}
result
}
/// Returns `true` if the attribute should be cleaned up.
///
/// Skips attributes where whitespace is syntactically significant.
fn should_cleanup_attribute(&self, name: &str) -> bool {
!matches!(
name,
"xml:space" | "preserveAspectRatio" | "viewBox" | "points" | "d"
)
}
}
impl Visitor<'_> for CleanupAttrsVisitor {
fn visit_element_enter(&mut self, element: &mut Element<'_>) -> Result<(), VexyError> {
for namespace in element.namespaces.values_mut() {
let cleaned = self.clean_attribute_value(namespace);
if cleaned != namespace.as_ref() {
*namespace = cleaned.into();
}
}
// Clean up attribute values
for (name, value) in element.attributes.iter_mut() {
if self.should_cleanup_attribute(name) && !value.is_empty() {
let cleaned = self.clean_attribute_value(value);
if cleaned != value.as_ref() {
*value = cleaned.into();
}
}
}
Ok(())
}
}
#[cfg(test)]
mod unit_tests {
use serde_json::json;
use vexy_vsvg::ast::Document;
use super::*;
#[test]
fn test_plugin_creation() {
let plugin = CleanupAttrsPlugin::new();
assert_eq!(plugin.name(), "cleanupAttrs");
assert!(plugin.params.newlines);
assert!(plugin.params.trim);
assert!(plugin.params.spaces);
}
#[test]
fn test_parameter_validation() {
let plugin = CleanupAttrsPlugin::new();
// Valid parameters
assert!(plugin.validate_params(&json!({})).is_ok());
assert!(plugin.validate_params(&json!({"newlines": true})).is_ok());
assert!(plugin.validate_params(&json!({"trim": false})).is_ok());
assert!(plugin.validate_params(&json!({"spaces": true})).is_ok());
assert!(plugin
.validate_params(&json!({"newlines": false, "trim": true, "spaces": false}))
.is_ok());
// Invalid parameters
assert!(plugin
.validate_params(&json!({"newlines": "invalid"}))
.is_err());
assert!(plugin.validate_params(&json!({"trim": 123})).is_err());
}
#[test]
fn test_attribute_value_cleaning() {
let params = CleanupAttrsParams::default();
let visitor = CleanupAttrsVisitor::new(params);
// Test newline removal
assert_eq!(
visitor.clean_attribute_value("value\nwith\nnewlines"),
"value with newlines"
);
// Test trimming
assert_eq!(visitor.clean_attribute_value(" value "), "value");
// Test space collapsing
assert_eq!(
visitor.clean_attribute_value("value with spaces"),
"value with spaces"
);
// Test combined
assert_eq!(
visitor.clean_attribute_value(" value\n with \n all issues "),
"value with all issues"
);
// Test empty and whitespace-only
assert_eq!(visitor.clean_attribute_value(""), "");
assert_eq!(visitor.clean_attribute_value(" "), "");
}
#[test]
fn test_selective_params() {
// Test with only newlines enabled
let params = CleanupAttrsParams {
newlines: true,
trim: false,
spaces: false,
};
let visitor = CleanupAttrsVisitor::new(params);
assert_eq!(
visitor.clean_attribute_value(" value\nwith\nnewlines "),
"value with newlines"
);
// Test with only trim enabled
let params = CleanupAttrsParams {
newlines: false,
trim: true,
spaces: false,
};
let visitor = CleanupAttrsVisitor::new(params);
assert_eq!(visitor.clean_attribute_value(" value "), "value");
// Test with only spaces enabled
let params = CleanupAttrsParams {
newlines: false,
trim: false,
spaces: true,
};
let visitor = CleanupAttrsVisitor::new(params);
assert_eq!(
visitor.clean_attribute_value("value with spaces"),
"value with spaces"
);
}
#[test]
fn test_plugin_apply() {
let plugin = CleanupAttrsPlugin::new();
let mut doc = Document::new();
// Add attributes to root element for testing
doc.root.set_attr("class", " my-class ");
doc.root.set_attr("title", "Title\nwith\nnewlines");
doc.root
.set_attr("style", "color: red; font-size: 14px");
// Apply the plugin
let result = plugin.apply(&mut doc);
assert!(result.is_ok());
// Check that attributes were cleaned
assert_eq!(doc.root.attr("class"), Some("my-class"));
assert_eq!(doc.root.attr("title"), Some("Title with newlines"));
assert_eq!(doc.root.attr("style"), Some("color: red; font-size: 14px"));
}
#[test]
fn test_skip_certain_attributes() {
let visitor = CleanupAttrsVisitor::new(CleanupAttrsParams::default());
// These attributes should not be cleaned up
assert!(!visitor.should_cleanup_attribute("xml:space"));
assert!(!visitor.should_cleanup_attribute("preserveAspectRatio"));
assert!(!visitor.should_cleanup_attribute("viewBox"));
assert!(!visitor.should_cleanup_attribute("points"));
assert!(!visitor.should_cleanup_attribute("d"));
// These should be cleaned up
assert!(visitor.should_cleanup_attribute("class"));
assert!(visitor.should_cleanup_attribute("style"));
assert!(visitor.should_cleanup_attribute("title"));
}
}
// Use parameterized testing framework for SVGO fixture tests
#[cfg(test)]
vexy_vsvg_test_utils::plugin_fixture_tests!(CleanupAttrsPlugin, "cleanupAttrs");