vexy-vsvg-plugin-sdk 2.4.2

Plugin SDK for vexy-vsvg
Documentation
// 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");