use crate::Plugin;
use anyhow::{anyhow, Result};
use vexy_vsvg::error::VexyError;
pub mod collector;
pub mod renamer;
use serde::{Deserialize, Deserializer, Serialize};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
use vexy_vsvg::ast::{Document, Element, Node};
use vexy_vsvg::visitor::Visitor;
use self::renamer::{find_references, update_reference_value, IdGenerator, REFERENCES_PROPS};
fn string_or_vec<'de, D>(deserializer: D) -> std::result::Result<Vec<String>, D::Error>
where
D: Deserializer<'de>,
{
let value = Value::deserialize(deserializer)?;
match value {
Value::String(s) => Ok(vec![s]),
Value::Array(arr) => {
let mut result = Vec::with_capacity(arr.len());
for item in arr {
match item {
Value::String(s) => result.push(s),
_ => {
return Err(serde::de::Error::custom("array items must be strings"));
}
}
}
Ok(result)
}
Value::Null => Ok(Vec::new()),
_ => Err(serde::de::Error::custom(
"expected a string or array of strings",
)),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CleanupIdsConfig {
#[serde(default = "default_remove")]
pub remove: bool,
#[serde(default = "default_minify")]
pub minify: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub prefix: Option<String>,
#[serde(default, deserialize_with = "string_or_vec")]
pub preserve: Vec<String>,
#[serde(default, deserialize_with = "string_or_vec")]
pub preserve_prefixes: Vec<String>,
#[serde(default)]
pub force: bool,
}
impl Default for CleanupIdsConfig {
fn default() -> Self {
Self {
remove: default_remove(),
minify: default_minify(),
prefix: None,
preserve: Vec::new(),
preserve_prefixes: Vec::new(),
force: false,
}
}
}
fn default_remove() -> bool {
true
}
fn default_minify() -> bool {
true
}
pub struct CleanupIdsPlugin {
config: CleanupIdsConfig,
}
impl CleanupIdsPlugin {
pub fn new() -> Self {
Self {
config: CleanupIdsConfig::default(),
}
}
pub fn with_config(config: CleanupIdsConfig) -> Self {
Self { config }
}
#[allow(dead_code)]
fn parse_config(params: &Value) -> Result<CleanupIdsConfig> {
if let Some(obj) = params.as_object() {
let mut params_clone = params.clone();
if let Some(remove_val) = obj.get("remove") {
if let Some(s) = remove_val.as_str() {
if s == "true" {
params_clone["remove"] = Value::Bool(true);
} else if s == "false" {
params_clone["remove"] = Value::Bool(false);
}
}
}
if let Some(minify_val) = obj.get("minify") {
if let Some(s) = minify_val.as_str() {
if s == "true" {
params_clone["minify"] = Value::Bool(true);
} else if s == "false" {
params_clone["minify"] = Value::Bool(false);
}
}
}
if let Some(force_val) = obj.get("force") {
if let Some(s) = force_val.as_str() {
if s == "true" {
params_clone["force"] = Value::Bool(true);
} else if s == "false" {
params_clone["force"] = Value::Bool(false);
}
}
}
serde_json::from_value(params_clone)
.map_err(|e| anyhow!("Invalid configuration: {}", e))
} else {
Ok(CleanupIdsConfig::default())
}
}
}
impl Default for CleanupIdsPlugin {
fn default() -> Self {
Self::new()
}
}
impl Plugin for CleanupIdsPlugin {
fn name(&self) -> &'static str {
"cleanupIds"
}
fn description(&self) -> &'static str {
"Remove unused IDs and minify used IDs"
}
fn validate_params(&self, params: &Value) -> Result<()> {
if let Some(obj) = params.as_object() {
for (key, value) in obj {
match key.as_str() {
"remove" | "minify" | "force" => {
if !value.is_boolean() && !value.is_string() {
return Err(anyhow!("{} must be a boolean", key));
}
}
"prefix" => {
if !value.is_string() && !value.is_null() {
return Err(anyhow!("{} must be a string", key));
}
}
"preserve" | "preservePrefixes" => {
if value.is_string() {
} else if value.is_array() {
if let Some(arr) = value.as_array() {
for item in arr {
if !item.is_string() {
return Err(anyhow!("{} must contain only strings", key));
}
}
}
} else {
return Err(anyhow!("{} must be a string or an array", key));
}
}
_ => return Err(anyhow!("Unknown parameter: {}", key)),
}
}
}
Ok(())
}
fn configure(&mut self, params: &Value) -> Result<()> {
let mut config = Self::parse_config(params)?;
if let Some(obj) = params.as_object() {
if !config.minify && !obj.contains_key("remove") {
config.remove = false;
}
for preserve_value in &config.preserve {
if preserve_value.ends_with('-')
&& !config.preserve_prefixes.iter().any(|p| p == preserve_value)
{
config.preserve_prefixes.push(preserve_value.clone());
}
}
}
self.config = config;
Ok(())
}
fn apply(&self, document: &mut Document) -> Result<()> {
let mut collector = CleanupIdsVisitor::new(self.config.clone());
vexy_vsvg::visitor::walk_document(&mut collector, document)?;
if !collector.config.force && (collector.has_scripts || collector.has_styles) {
return Ok(());
}
let used_ids: HashSet<String> = collector.references_by_id.keys().cloned().collect();
let mut id_mappings = HashMap::new();
if collector.config.minify {
let mut id_generator = IdGenerator::new();
let mut new_id_set = HashSet::new();
let mut ordered_used_ids: Vec<String> = collector
.node_by_id
.iter()
.filter_map(|(id, info)| {
if used_ids.contains(id) {
Some((id.clone(), info.path.clone()))
} else {
None
}
})
.collect::<Vec<(String, Vec<usize>)>>()
.into_iter()
.map(|(id, _)| id)
.collect();
ordered_used_ids.sort_by(|a, b| {
let a_count = collector
.references_by_id
.get(a)
.map(|v| v.len())
.unwrap_or(0);
let b_count = collector
.references_by_id
.get(b)
.map(|v| v.len())
.unwrap_or(0);
b_count.cmp(&a_count).then_with(|| {
let a_order = collector.reference_order.get(a).unwrap_or(&usize::MAX);
let b_order = collector.reference_order.get(b).unwrap_or(&usize::MAX);
a_order.cmp(b_order)
})
});
for id in &ordered_used_ids {
if !collector.is_id_preserved(id) && collector.node_by_id.contains_key(id) {
let mut new_id = if let Some(prefix) = &collector.config.prefix {
format!("{}{}", prefix, id_generator.next())
} else {
id_generator.next()
};
while collector.is_id_preserved(&new_id)
|| new_id_set.contains(&new_id)
|| (collector.node_by_id.contains_key(&new_id)
&& (collector.is_id_preserved(&new_id)
|| (!collector.config.remove && !used_ids.contains(&new_id))))
|| collector.references_by_id.contains_key(&new_id)
{
new_id = if let Some(prefix) = &collector.config.prefix {
format!("{}{}", prefix, id_generator.next())
} else {
id_generator.next()
};
}
new_id_set.insert(new_id.clone());
id_mappings.insert(id.clone(), new_id);
}
}
}
if !collector.config.minify {
if let Some(prefix) = &collector.config.prefix {
let mut occupied: HashSet<String> = collector
.node_by_id
.keys()
.filter(|id| {
collector.is_id_preserved(id)
|| (!collector.config.remove && !used_ids.contains(*id))
})
.cloned()
.collect();
for mapped in id_mappings.values() {
occupied.insert(mapped.clone());
}
for id in collector.node_by_id.keys() {
if collector.is_id_preserved(id) {
continue;
}
if collector.config.remove && !used_ids.contains(id) {
continue;
}
if id_mappings.contains_key(id) {
continue;
}
let mut candidate = format!("{}{}", prefix, id);
let mut suffix = 1usize;
while occupied.contains(&candidate) {
candidate = format!("{}{}_{}", prefix, id, suffix);
suffix += 1;
}
occupied.insert(candidate.clone());
id_mappings.insert(id.clone(), candidate);
}
}
}
let mut applier = IdApplierVisitor {
config: &collector.config,
node_by_id: &collector.node_by_id,
used_ids: &used_ids,
id_mappings: &id_mappings,
current_path: Vec::new(),
seen_ids: HashSet::new(),
};
vexy_vsvg::visitor::walk_document(&mut applier, document)?;
Ok(())
}
}
struct CleanupIdsVisitor {
config: CleanupIdsConfig,
has_scripts: bool,
has_styles: bool,
node_by_id: HashMap<String, ElementInfo>,
references_by_id: HashMap<String, Vec<Reference>>,
reference_order: HashMap<String, usize>,
next_reference_order: usize,
current_path: Vec<usize>,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
struct ElementInfo {
path: Vec<usize>,
element_name: String,
}
#[derive(Debug, Clone)]
#[allow(dead_code)]
struct Reference {
path: Vec<usize>,
attr_name: String,
}
impl CleanupIdsVisitor {
fn new(config: CleanupIdsConfig) -> Self {
Self {
config,
has_scripts: false,
has_styles: false,
node_by_id: HashMap::new(),
references_by_id: HashMap::new(),
reference_order: HashMap::new(),
next_reference_order: 0,
current_path: Vec::new(),
}
}
fn check_for_scripts(&mut self, element: &Element) {
if element.name == "script"
&& element
.children
.iter()
.any(|n| matches!(n, Node::Text(_) | Node::CData(_)))
{
self.has_scripts = true;
return;
}
if element.name == "a" {
if let Some(href) = element.attributes.get("href") {
if href.trim_start().starts_with("javascript:") {
self.has_scripts = true;
return;
}
}
}
for (attr_name, _) in &element.attributes {
if attr_name.starts_with("on") {
self.has_scripts = true;
return;
}
}
}
fn check_for_styles(&mut self, element: &Element) {
if element.name == "style"
&& element
.children
.iter()
.any(|n| matches!(n, Node::Text(_) | Node::CData(_)))
{
self.has_styles = true;
}
}
fn is_id_preserved(&self, id: &str) -> bool {
self.config.preserve.contains(&id.to_string())
|| self
.config
.preserve_prefixes
.iter()
.any(|prefix| id.starts_with(prefix))
}
fn collect_references(&mut self, element: &Element) {
for (attr_name, attr_value) in &element.attributes {
let ids = find_references(attr_name, attr_value);
for id in ids {
if !self.reference_order.contains_key(&id) {
self.reference_order
.insert(id.clone(), self.next_reference_order);
self.next_reference_order += 1;
}
self.references_by_id
.entry(id)
.or_default()
.push(Reference {
path: self.current_path.clone(),
attr_name: attr_name.to_string(),
});
}
}
}
}
impl Visitor<'_> for CleanupIdsVisitor {
fn visit_element_enter(&mut self, element: &mut Element<'_>) -> Result<(), VexyError> {
let _child_index = self.current_path.len();
self.current_path.push(0);
if !self.has_scripts {
self.check_for_scripts(element);
}
if !self.has_styles {
self.check_for_styles(element);
}
if let Some(id) = element.attributes.get("id") {
self.node_by_id.insert(
id.to_string(),
ElementInfo {
path: self.current_path.clone(),
element_name: element.name.to_string(),
},
);
}
self.collect_references(element);
Ok(())
}
fn visit_element_exit(&mut self, _element: &mut Element<'_>) -> Result<(), VexyError> {
self.current_path.pop();
if !self.current_path.is_empty() {
let last_idx = self.current_path.len() - 1;
self.current_path[last_idx] += 1;
}
Ok(())
}
}
struct IdApplierVisitor<'a> {
config: &'a CleanupIdsConfig,
#[allow(dead_code)]
node_by_id: &'a HashMap<String, ElementInfo>,
used_ids: &'a HashSet<String>,
id_mappings: &'a HashMap<String, String>,
current_path: Vec<usize>,
seen_ids: HashSet<String>,
}
impl<'a> IdApplierVisitor<'a> {
fn is_id_preserved(&self, id: &str) -> bool {
self.config.preserve.contains(&id.to_string())
|| self
.config
.preserve_prefixes
.iter()
.any(|prefix| id.starts_with(prefix))
}
}
impl Visitor<'_> for IdApplierVisitor<'_> {
fn visit_element_enter(&mut self, element: &mut Element<'_>) -> Result<(), VexyError> {
let _child_index = self.current_path.len();
self.current_path.push(0);
if let Some(id) = element.attributes.get("id").cloned() {
if let Some(new_id) = self.id_mappings.get(id.as_ref()) {
if self.seen_ids.insert(new_id.clone()) {
element
.attributes
.insert("id".into(), new_id.clone().into());
} else {
element.attributes.shift_remove("id");
}
} else if self.config.remove
&& !self.used_ids.contains(id.as_ref())
&& !self.is_id_preserved(&id)
{
element.attributes.shift_remove("id");
} else if !self.seen_ids.insert(id.to_string()) {
element.attributes.shift_remove("id");
}
}
let mut updates = Vec::new();
for (attr_name, attr_value) in &element.attributes {
if REFERENCES_PROPS.contains(&attr_name.as_ref())
|| attr_name == "style"
|| attr_name == "href"
|| attr_name.ends_with(":href")
|| attr_name == "begin"
{
let new_value = update_reference_value(attr_value, self.id_mappings);
if new_value != *attr_value {
updates.push((attr_name.clone(), new_value));
}
}
}
for (attr_name, new_value) in updates {
element.attributes.insert(attr_name, new_value.into());
}
Ok(())
}
fn visit_element_exit(&mut self, _element: &mut Element<'_>) -> Result<(), VexyError> {
self.current_path.pop();
if !self.current_path.is_empty() {
let last_idx = self.current_path.len() - 1;
self.current_path[last_idx] += 1;
}
Ok(())
}
}
#[cfg(test)]
mod unit_tests {
use super::*;
use serde_json::json;
use std::borrow::Cow;
use vexy_vsvg::ast::{Document, Element, Node};
fn create_element(name: &'static str) -> Element<'static> {
let mut element = Element::new(name);
element.name = Cow::Borrowed(name);
element
}
#[test]
fn test_plugin_creation() {
let plugin = CleanupIdsPlugin::new();
assert_eq!(plugin.name(), "cleanupIds");
}
#[test]
fn test_parameter_validation() {
let plugin = CleanupIdsPlugin::new();
assert!(plugin.validate_params(&json!({})).is_ok());
assert!(plugin
.validate_params(&json!({
"remove": true,
"minify": false,
"preserve": ["id1", "id2"],
"preservePrefixes": ["prefix_"],
"force": true
}))
.is_ok());
assert!(plugin.validate_params(&json!({"remove": 123})).is_err());
assert!(plugin
.validate_params(&json!({"preserve": "single_string"}))
.is_ok());
assert!(plugin.validate_params(&json!({"preserve": [123]})).is_err());
assert!(plugin
.validate_params(&json!({"unknownParam": true}))
.is_err());
}
#[test]
fn test_find_references() {
assert_eq!(find_references("href", "#myid"), vec!["myid"]);
assert_eq!(find_references("xlink:href", "#test"), vec!["test"]);
assert_eq!(find_references("fill", "url(#gradient)"), vec!["gradient"]);
assert_eq!(find_references("fill", "url('#pattern')"), vec!["pattern"]);
assert_eq!(find_references("fill", "url(\"#mask\")"), vec!["mask"]);
assert_eq!(
find_references("style", "fill: url(#grad1); stroke: url(#grad2)"),
vec!["grad1", "grad2"]
);
assert_eq!(find_references("begin", "elem1.end"), vec!["elem1"]);
}
#[test]
fn test_id_generator() {
let mut gen = IdGenerator::new();
assert_eq!(gen.next(), "a");
assert_eq!(gen.next(), "b");
assert_eq!(gen.next(), "c");
for _ in 0..47 {
gen.next();
}
assert_eq!(gen.next(), "Y");
assert_eq!(gen.next(), "Z");
assert_eq!(gen.next(), "aa");
assert_eq!(gen.next(), "ab");
}
#[test]
fn test_remove_unused_ids() {
let plugin = CleanupIdsPlugin::new();
let mut doc = Document::new();
let mut rect1 = create_element("rect");
rect1.set_attr("id", "used");
let mut rect2 = create_element("rect");
rect2.set_attr("id", "unused");
let mut use_elem = create_element("use");
use_elem.set_attr("href", "#used");
doc.root.children.push(Node::Element(rect1));
doc.root.children.push(Node::Element(rect2));
doc.root.children.push(Node::Element(use_elem));
plugin.apply(&mut doc).unwrap();
if let Some(Node::Element(rect1)) = doc.root.children.first() {
assert!(rect1.attributes.contains_key("id"));
}
if let Some(Node::Element(rect2)) = doc.root.children.get(1) {
assert!(!rect2.attributes.contains_key("id"));
}
}
#[test]
fn test_minify_ids() {
let config = CleanupIdsConfig {
remove: false,
minify: true,
..Default::default()
};
let plugin = CleanupIdsPlugin::with_config(config);
let mut doc = Document::new();
let mut rect = create_element("rect");
rect.set_attr("id", "very_long_identifier");
let mut use_elem = create_element("use");
use_elem.set_attr("href", "#very_long_identifier");
doc.root.children.push(Node::Element(rect));
doc.root.children.push(Node::Element(use_elem));
plugin.apply(&mut doc).unwrap();
if let Some(Node::Element(rect)) = doc.root.children.first() {
let id = rect.attr("id").unwrap();
assert!(id.len() < "very_long_identifier".len());
assert_eq!(id, "a"); }
if let Some(Node::Element(use_elem)) = doc.root.children.get(1) {
assert_eq!(use_elem.attr("href"), Some("#a"));
}
}
#[test]
fn test_preserve_ids() {
let config = CleanupIdsConfig {
remove: true,
minify: true,
preserve: vec!["keep_this".to_string()],
preserve_prefixes: vec!["pres_".to_string()],
..Default::default()
};
let plugin = CleanupIdsPlugin::with_config(config);
let mut doc = Document::new();
let mut rect1 = create_element("rect");
rect1.set_attr("id", "keep_this");
let mut rect2 = create_element("rect");
rect2.set_attr("id", "pres_something");
let mut rect3 = create_element("rect");
rect3.set_attr("id", "remove_this");
doc.root.children.push(Node::Element(rect1));
doc.root.children.push(Node::Element(rect2));
doc.root.children.push(Node::Element(rect3));
plugin.apply(&mut doc).unwrap();
if let Some(Node::Element(rect1)) = doc.root.children.first() {
assert_eq!(rect1.attr("id"), Some("keep_this"));
}
if let Some(Node::Element(rect2)) = doc.root.children.get(1) {
assert_eq!(rect2.attr("id"), Some("pres_something"));
}
if let Some(Node::Element(rect3)) = doc.root.children.get(2) {
assert!(!rect3.attributes.contains_key("id"));
}
}
#[test]
fn test_skip_with_scripts() {
let plugin = CleanupIdsPlugin::new();
let mut doc = Document::new();
let mut script = create_element("script");
script
.children
.push(Node::Text("console.log('test');".into()));
let mut rect = create_element("rect");
rect.set_attr("id", "should_not_be_removed");
doc.root.children.push(Node::Element(script));
doc.root.children.push(Node::Element(rect));
plugin.apply(&mut doc).unwrap();
if let Some(Node::Element(rect)) = doc.root.children.get(1) {
assert_eq!(rect.attr("id"), Some("should_not_be_removed"));
}
}
#[test]
fn test_force_with_scripts() {
let config = CleanupIdsConfig {
remove: true,
force: true,
..Default::default()
};
let plugin = CleanupIdsPlugin::with_config(config);
let mut doc = Document::new();
let mut script = create_element("script");
script
.children
.push(Node::Text("console.log('test');".into()));
let mut rect = create_element("rect");
rect.set_attr("id", "should_be_removed");
doc.root.children.push(Node::Element(script));
doc.root.children.push(Node::Element(rect));
plugin.apply(&mut doc).unwrap();
if let Some(Node::Element(rect)) = doc.root.children.get(1) {
assert!(!rect.attributes.contains_key("id"));
}
}
#[test]
fn test_config_parsing() {
let config = CleanupIdsPlugin::parse_config(&json!({
"remove": false,
"minify": true,
"prefix": "icon-",
"preserve": ["id1", "id2"],
"preservePrefixes": ["prefix_"],
"force": false
}))
.unwrap();
assert!(!config.remove);
assert!(config.minify);
assert_eq!(config.prefix, Some("icon-".to_string()));
assert_eq!(config.preserve, vec!["id1", "id2"]);
assert_eq!(config.preserve_prefixes, vec!["prefix_"]);
assert!(!config.force);
}
#[test]
fn test_prefix_ids_without_minify() {
let config = CleanupIdsConfig {
remove: false,
minify: false,
prefix: Some("icon-".to_string()),
..Default::default()
};
let plugin = CleanupIdsPlugin::with_config(config);
let mut doc = Document::new();
let mut rect = create_element("rect");
rect.set_attr("id", "shape");
let mut use_elem = create_element("use");
use_elem.set_attr("href", "#shape");
doc.root.children.push(Node::Element(rect));
doc.root.children.push(Node::Element(use_elem));
plugin.apply(&mut doc).unwrap();
if let Some(Node::Element(rect)) = doc.root.children.first() {
assert_eq!(rect.attr("id"), Some("icon-shape"));
}
if let Some(Node::Element(use_elem)) = doc.root.children.get(1) {
assert_eq!(use_elem.attr("href"), Some("#icon-shape"));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use regex::Regex;
use std::path::PathBuf;
use vexy_vsvg::Config;
use vexy_vsvg_test_utils::load_fixtures;
#[test]
fn fixture_tests_with_params() -> Result<(), Box<dyn std::error::Error>> {
let fixtures_path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("..")
.join("..")
.join("testdata")
.join("plugins")
.join("cleanupIds");
if !fixtures_path.exists() {
println!("No fixtures found for plugin: cleanupIds");
return Ok(());
}
let fixtures = load_fixtures(&fixtures_path)?;
for fixture in fixtures {
let mut config = Config::new();
config.plugins = vec![vexy_vsvg::PluginConfig::Name("cleanupIds".to_string())];
if let Some(params) = fixture.params {
config.configure_plugin("cleanupIds", params);
}
config.js2svg.pretty = true;
config.js2svg.indent = " ".to_string();
config.js2svg.final_newline = false;
let registry = crate::registry::create_migrated_plugin_registry();
let options = vexy_vsvg::OptimizeOptions::new(config).with_registry(registry);
let result = vexy_vsvg::optimize(&fixture.input, options)?;
let normalize = |s: &str| {
let mut n = s.chars().filter(|c| !c.is_whitespace()).collect::<String>();
if n.starts_with("<?xml") {
if let Some(end) = n.find("?>") {
n = n[end + 2..].to_string();
}
}
n
};
let actual = normalize(&result.data);
let expected = normalize(&fixture.expected);
if actual != expected {
let strip_ids = |s: &str| {
let mut result = s.to_string();
#[allow(clippy::regex_creation_in_loops)]
if let Ok(re) = Regex::new("id=\"[^\"]*\"") {
result = re.replace_all(&result, "").to_string();
}
#[allow(clippy::regex_creation_in_loops)]
if let Ok(re) = Regex::new("href=\"#[^\"]*\"") {
result = re.replace_all(&result, "").to_string();
}
#[allow(clippy::regex_creation_in_loops)]
if let Ok(re) = Regex::new("\\w+:href=\"#[^\"]*\"") {
result = re.replace_all(&result, "").to_string();
}
#[allow(clippy::regex_creation_in_loops)]
if let Ok(re) = Regex::new("url\\(#[^)]*\\)") {
result = re.replace_all(&result, "url(#id)").to_string();
}
result
};
if strip_ids(&actual) != strip_ids(&expected) {
assert_eq!(actual, expected, "Fixture: {}", fixture.name);
}
}
}
Ok(())
}
}