Skip to main content

vexy_vsvg/css/
variables.rs

1// this_file: crates/vexy-vsvg/src/css/variables.rs
2
3use std::collections::HashMap;
4use std::sync::OnceLock;
5
6use regex::Regex;
7
8use crate::ast::Element;
9
10/// Resolves CSS variables (custom properties) within the SVG document.
11///
12/// This resolver handles:
13/// 1. Inline styles (`style="..."`)
14/// 2. Inheritance from parent elements
15/// 3. Fallback values (`var(--name, fallback)`)
16pub struct CssVariableResolver {
17    // Maps element ID/reference to its computed variable scope?
18    // Actually, resolving is context-dependent.
19}
20
21impl CssVariableResolver {
22    /// extract variables from a style string
23    fn _parse_inline_style(style: &str) -> HashMap<String, String> {
24        static VAR_DECL_RE: OnceLock<Regex> = OnceLock::new();
25
26        let mut vars = HashMap::new();
27        for cap in VAR_DECL_RE
28            .get_or_init(|| Regex::new(r"(?m)(--[\w-]+)\s*:\s*([^;]+)").unwrap())
29            .captures_iter(style)
30        {
31            if let (Some(name), Some(value)) = (cap.get(1), cap.get(2)) {
32                vars.insert(
33                    name.as_str().trim().to_string(),
34                    value.as_str().trim().to_string(),
35                );
36            }
37        }
38        vars
39    }
40
41    /// Resolve a variable value starting from a given element and walking up the tree.
42    /// This requires a traversal context as the AST does not maintain parent links.
43    pub fn resolve_value(name: &str, computed_vars: &HashMap<String, String>) -> Option<String> {
44        computed_vars.get(name).cloned()
45    }
46}
47
48/// A context object that tracks CSS variables during document traversal.
49#[derive(Debug, Clone, Default)]
50pub struct CssScope {
51    /// Variables defined at this scope or inherited
52    variables: HashMap<String, String>,
53}
54
55impl CssScope {
56    pub fn new() -> Self {
57        Self::default()
58    }
59
60    /// Create a child scope inheriting from this one, updated with element's inline styles
61    pub fn child_scope(&self, element: &Element) -> Self {
62        let mut new_scope = self.clone();
63
64        if let Some(style) = element.attr("style") {
65            static VAR_DECL_RE: OnceLock<Regex> = OnceLock::new();
66
67            for cap in VAR_DECL_RE
68                .get_or_init(|| Regex::new(r"(--[\w-]+)\s*:\s*([^;]+)").unwrap())
69                .captures_iter(style)
70            {
71                if let (Some(name), Some(value)) = (cap.get(1), cap.get(2)) {
72                    new_scope.variables.insert(
73                        name.as_str().trim().to_string(),
74                        value.as_str().trim().to_string(),
75                    );
76                }
77            }
78        }
79
80        new_scope
81    }
82
83    /// Get the value of a variable in this scope
84    pub fn get(&self, name: &str) -> Option<&str> {
85        self.variables.get(name).map(|s| s.as_str())
86    }
87
88    /// Resolve a value string that might contain var() references
89    pub fn resolve(&self, value: &str) -> String {
90        static VAR_REF_RE: OnceLock<Regex> = OnceLock::new();
91
92        if !value.contains("var(--") {
93            return value.to_string();
94        }
95
96        let mut result = value.to_string();
97
98        // Use a loop to handle nested resolutions if needed, or just single pass
99        // Extract match info to avoid borrow checker issues
100        let match_info = if let Some(caps) = VAR_REF_RE
101            .get_or_init(|| Regex::new(r"var\((--[\w-]+)(?:,\s*([^)]+))?\)").unwrap())
102            .captures(&result)
103        {
104            let full_match = caps.get(0).unwrap();
105            let var_name = caps.get(1).unwrap().as_str().to_string();
106            let fallback = caps.get(2).map(|m| m.as_str().to_string());
107            Some((full_match.range(), var_name, fallback))
108        } else {
109            None
110        };
111
112        if let Some((range, var_name, fallback)) = match_info {
113            let resolved = self.get(&var_name).map(String::from).or(fallback);
114
115            if let Some(replacement) = resolved {
116                result.replace_range(range, &replacement);
117            }
118        }
119
120        result
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use super::*;
127    use crate::ast::Element;
128
129    #[test]
130    fn test_scope_inheritance() {
131        let _root = Element::new("root"); // No vars
132        let root_scope = CssScope::new();
133
134        let mut parent = Element::new("g");
135        parent.set_attr("style", "--color: red; --size: 10px;");
136        let parent_scope = root_scope.child_scope(&parent);
137
138        assert_eq!(parent_scope.get("--color"), Some("red"));
139
140        let mut child = Element::new("rect");
141        child.set_attr("style", "--color: blue;"); // Override
142        let child_scope = parent_scope.child_scope(&child);
143
144        assert_eq!(child_scope.get("--color"), Some("blue"));
145        assert_eq!(child_scope.get("--size"), Some("10px")); // Inherited
146    }
147
148    #[test]
149    fn test_resolve_var() {
150        let mut el = Element::new("g");
151        el.set_attr("style", "--main-bg: #fff;");
152        let scope = CssScope::new().child_scope(&el);
153
154        assert_eq!(scope.resolve("var(--main-bg)"), "#fff");
155        assert_eq!(scope.resolve("1px solid var(--main-bg)"), "1px solid #fff");
156        assert_eq!(scope.resolve("var(--missing, black)"), "black");
157    }
158}