vexy-vsvg-plugin-sdk 2.4.2

Plugin SDK for vexy-vsvg
Documentation
// this_file: crates/vexy-vsvg-plugin-sdk/src/plugins/cleanup_ids/renamer.rs

//! ID renaming and reference-update utilities for the cleanupIds plugin.
//!
//! This module provides:
//! - **ID generation**: Produces short IDs (`a`, `b`, ..., `Z`, `aa`, `ab`, ...)
//! - **Reference detection**: Regex patterns to find ID references in attributes
//! - **Reference updates**: Functions to rewrite references when IDs are renamed

use once_cell::sync::Lazy;
use regex::Regex;
use std::collections::HashMap;

/// Matches `url(#id)` patterns in CSS property values.
pub(crate) static REG_REFERENCES_URL: Lazy<Regex> =
    Lazy::new(|| Regex::new(r#"\burl\(#([^)]+)\)"#).unwrap());

/// Matches `url("#id")` or `url('#id')` with quotes.
pub(crate) static REG_REFERENCES_URL_QUOTED: Lazy<Regex> =
    Lazy::new(|| Regex::new(r#"\burl\(["']#([^"']+)["']\)"#).unwrap());

/// Matches `href="#id"` or `xlink:href="#id"` references.
pub(crate) static REG_REFERENCES_HREF: Lazy<Regex> = Lazy::new(|| Regex::new(r"^#(.+?)$").unwrap());

/// Matches animation `begin` attribute patterns like `"elementId.end"`.
pub(crate) static REG_REFERENCES_BEGIN: Lazy<Regex> =
    Lazy::new(|| Regex::new(r"(\w+)\.[a-zA-Z]").unwrap());

/// Character set for generating short IDs: `a-z`, `A-Z` (52 chars).
///
/// IDs cycle through this alphabet: `a`, `b`, ..., `Z`, `aa`, `ab`, ...
///
/// We use both cases to maximize the single-character ID space (52 options vs 26).
/// Numbers aren't used because IDs can't start with a digit per HTML/XML rules.
pub(crate) const GENERATE_ID_CHARS: &[char] = &[
    'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's',
    't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L',
    'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z',
];

/// SVG presentation attributes that can contain `url(#id)` references.
pub(crate) const REFERENCES_PROPS: &[&str] = &[
    "clip-path",
    "color-profile",
    "fill",
    "filter",
    "marker-end",
    "marker-mid",
    "marker-start",
    "mask",
    "stroke",
    "style",
];

/// Generates short, sequential IDs for minification.
///
/// Produces IDs in order: `a`, `b`, `c`, ..., `Z`, `aa`, `ab`, ..., `zz`, `aaa`, ...
///
/// This is essentially a base-52 counter where each digit maps to a letter.
/// The most frequently-referenced IDs get the shortest names to maximize savings.
#[derive(Debug, Clone)]
pub struct IdGenerator {
    /// Current position in the ID sequence, represented as digits in base-52.
    /// `[0]` → `"a"`, `[51]` → `"Z"`, `[0, 0]` → `"aa"`.
    current: Vec<usize>,
}

impl Default for IdGenerator {
    fn default() -> Self {
        Self::new()
    }
}

impl IdGenerator {
    pub fn new() -> Self {
        Self { current: vec![0] }
    }

    /// Returns the next ID in the sequence and advances the generator.
    ///
    /// Implements base-52 increment with carry: when a digit reaches 51, it wraps
    /// to 0 and increments the next digit.
    #[allow(clippy::should_implement_trait)]
    pub fn next(&mut self) -> String {
        let mut result = String::new();
        for &idx in &self.current {
            result.push(GENERATE_ID_CHARS[idx]);
        }

        // Increment (like adding 1 to a base-52 number)
        let mut carry = true;
        for i in (0..self.current.len()).rev() {
            if carry {
                self.current[i] += 1;
                if self.current[i] >= GENERATE_ID_CHARS.len() {
                    // Overflow: wrap to 0 and carry to next digit
                    self.current[i] = 0;
                } else {
                    carry = false;
                }
            }
        }
        if carry {
            // All digits overflowed: add new leading digit (e.g., Z → aa)
            self.current.push(0);
        }

        result
    }
}

/// Extracts all ID references from an attribute value.
///
/// Handles various reference formats:
/// - `href="#id"` or `xlink:href="#id"`
/// - `fill="url(#id)"` or `fill="url('#id')"`
/// - `begin="elem.end"` (animation timing)
pub(crate) fn find_references(attr_name: &str, attr_value: &str) -> Vec<String> {
    let mut ids = Vec::new();

    // Check href attributes
    if attr_name == "href" || attr_name.ends_with(":href") {
        if let Some(captures) = REG_REFERENCES_HREF.captures(attr_value) {
            if let Some(id) = captures.get(1) {
                ids.push(id.as_str().to_string());
            }
        }
        return ids;
    }

    // Check begin attribute
    if attr_name == "begin" {
        for captures in REG_REFERENCES_BEGIN.captures_iter(attr_value) {
            if let Some(id) = captures.get(1) {
                ids.push(id.as_str().to_string());
            }
        }
        return ids;
    }

    // Check properties that can contain URL references
    if REFERENCES_PROPS.contains(&attr_name) || attr_name == "style" {
        // Check url(#id) patterns
        for captures in REG_REFERENCES_URL.captures_iter(attr_value) {
            if let Some(id) = captures.get(1) {
                ids.push(id.as_str().to_string());
            }
        }
        // Check url("#id") and url('#id') patterns
        for captures in REG_REFERENCES_URL_QUOTED.captures_iter(attr_value) {
            if let Some(id) = captures.get(1) {
                ids.push(id.as_str().to_string());
            }
        }
    }

    ids
}

/// Rewrites all ID references in an attribute value using the provided mappings.
///
/// Handles all reference formats: `href="#id"`, `url(#id)`, `url("#id")`, `begin="id.end"`.
///
/// Example: `"url(#oldId)"` with mapping `{"oldId": "a"}` → `"url(#a)"`
pub(crate) fn update_reference_value(value: &str, id_mappings: &HashMap<String, String>) -> String {
    let mut result = value.to_string();

    // Update plain #id references (for href attributes)
    result = REG_REFERENCES_HREF
        .replace_all(&result, |caps: &regex::Captures| {
            if let Some(id) = caps.get(1) {
                if let Some(new_id) = id_mappings.get(id.as_str()) {
                    return format!("#{}", new_id);
                }
            }
            caps[0].to_string()
        })
        .to_string();

    // Update url(#id) patterns
    result = REG_REFERENCES_URL
        .replace_all(&result, |caps: &regex::Captures| {
            if let Some(id) = caps.get(1) {
                if let Some(new_id) = id_mappings.get(id.as_str()) {
                    return format!("url(#{})", new_id);
                }
            }
            caps[0].to_string()
        })
        .to_string();

    // Update url("#id") and url('#id') patterns
    result = REG_REFERENCES_URL_QUOTED
        .replace_all(&result, |caps: &regex::Captures| {
            if let Some(id) = caps.get(1) {
                if let Some(new_id) = id_mappings.get(id.as_str()) {
                    // Preserve the quote style
                    let full_match = &caps[0];
                    let quote = if full_match.contains('"') { '"' } else { '\'' };
                    return format!("url({}#{}{})", quote, new_id, quote);
                }
            }
            caps[0].to_string()
        })
        .to_string();

    // Update begin="id.event" references.
    result = REG_REFERENCES_BEGIN
        .replace_all(&result, |caps: &regex::Captures| {
            if let Some(id) = caps.get(1) {
                if let Some(new_id) = id_mappings.get(id.as_str()) {
                    return caps[0].replacen(id.as_str(), new_id, 1);
                }
            }
            caps[0].to_string()
        })
        .to_string();

    result
}