use indexmap::IndexMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IconPath {
pub d: String,
#[serde(default = "default_fill")]
pub fill: String,
#[serde(default = "default_stroke")]
pub stroke: String,
#[serde(default = "default_stroke_width")]
pub stroke_width: f64,
#[serde(default = "default_stroke_linecap")]
pub stroke_linecap: String,
#[serde(default = "default_stroke_linejoin")]
pub stroke_linejoin: String,
}
fn default_fill() -> String {
"none".to_string()
}
fn default_stroke() -> String {
"currentColor".to_string()
}
fn default_stroke_width() -> f64 {
2.0
}
fn default_stroke_linecap() -> String {
"round".to_string()
}
fn default_stroke_linejoin() -> String {
"round".to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IconData {
#[serde(default = "default_viewbox")]
pub view_box: String,
pub paths: Vec<IconPath>,
}
fn default_viewbox() -> String {
"0 0 24 24".to_string()
}
#[derive(Debug, Default)]
pub struct ResourceRegistry {
resources: IndexMap<String, IconData>,
}
impl ResourceRegistry {
pub fn new() -> Self {
Self {
resources: IndexMap::new(),
}
}
pub fn register(&mut self, name: &str, svg: &str) {
self.resources.insert(name.to_string(), parse_svg(svg));
}
pub fn register_map(&mut self, map: IndexMap<String, String>) {
for (name, svg) in map {
self.resources.insert(name, parse_svg(&svg));
}
}
pub fn resolve(&self, name: &str) -> Option<&IconData> {
self.resources.get(name)
}
pub fn to_props(icon: &IconData) -> serde_json::Value {
let paths: Vec<serde_json::Value> = icon
.paths
.iter()
.map(|p| {
serde_json::json!({
"d": p.d,
"fill": p.fill,
"stroke": p.stroke,
"strokeWidth": p.stroke_width,
"strokeLinecap": p.stroke_linecap,
"strokeLinejoin": p.stroke_linejoin,
})
})
.collect();
serde_json::json!({
"paths": paths,
"viewBox": icon.view_box,
})
}
pub fn is_empty(&self) -> bool {
self.resources.is_empty()
}
}
pub fn parse_svg(svg: &str) -> IconData {
let root = RootDefaults::extract(svg);
let view_box = root.view_box.clone().unwrap_or_else(|| "0 0 24 24".to_string());
let mut paths = Vec::new();
for cap in RegexLite::new(r#"<path\s+([^>]*?)/?>(?:</path>)?"#, svg) {
if let Some(attrs) = cap {
if let Some(path) = parse_path_attrs(&attrs, &root) {
paths.push(path);
}
}
}
for cap in RegexLite::new(r#"<circle\s+([^>]*?)/?>(?:</circle>)?"#, svg) {
if let Some(attrs) = cap {
if let Some(path) = circle_to_path(&attrs, &root) {
paths.push(path);
}
}
}
for cap in RegexLite::new(r#"<line\s+([^>]*?)/?>(?:</line>)?"#, svg) {
if let Some(attrs) = cap {
if let Some(path) = line_to_path(&attrs, &root) {
paths.push(path);
}
}
}
for cap in RegexLite::new(r#"<rect\s+([^>]*?)/?>(?:</rect>)?"#, svg) {
if let Some(attrs) = cap {
if let Some(path) = rect_to_path(&attrs, &root) {
paths.push(path);
}
}
}
IconData { view_box, paths }
}
#[derive(Debug, Default, Clone)]
struct RootDefaults {
view_box: Option<String>,
fill: Option<String>,
stroke: Option<String>,
stroke_width: Option<f64>,
stroke_linecap: Option<String>,
stroke_linejoin: Option<String>,
}
impl RootDefaults {
fn extract(svg: &str) -> Self {
let lower = svg.to_lowercase();
let Some(svg_start) = lower.find("<svg") else {
return Self::default();
};
let after = &svg[svg_start + 4..];
let Some(end) = after.find('>') else {
return Self::default();
};
let root_attrs = &after[..end];
Self {
view_box: extract_attr(root_attrs, "viewBox"),
fill: extract_attr(root_attrs, "fill"),
stroke: extract_attr(root_attrs, "stroke"),
stroke_width: extract_attr(root_attrs, "stroke-width")
.and_then(|s| s.parse().ok()),
stroke_linecap: extract_attr(root_attrs, "stroke-linecap"),
stroke_linejoin: extract_attr(root_attrs, "stroke-linejoin"),
}
}
}
struct RegexLite<'a> {
tag_start: &'a str,
source: &'a str,
pos: usize,
}
impl<'a> RegexLite<'a> {
fn new(pattern: &'a str, source: &'a str) -> Self {
let tag_start = if pattern.contains("<path") {
"<path"
} else if pattern.contains("<circle") {
"<circle"
} else if pattern.contains("<line") {
"<line"
} else if pattern.contains("<rect") {
"<rect"
} else {
"<unknown"
};
Self {
tag_start,
source,
pos: 0,
}
}
}
impl<'a> Iterator for RegexLite<'a> {
type Item = Option<String>;
fn next(&mut self) -> Option<Self::Item> {
let remaining = &self.source[self.pos..];
let lower = remaining.to_lowercase();
let tag_lower = self.tag_start.to_lowercase();
if let Some(start) = lower.find(&tag_lower) {
let abs_start = self.pos + start + self.tag_start.len();
let after = &self.source[abs_start..];
if let Some(end) = after.find("/>").or_else(|| after.find('>')) {
let attrs = after[..end].trim().to_string();
self.pos = abs_start + end + 2;
Some(Some(attrs))
} else {
self.pos = self.source.len();
None
}
} else {
None
}
}
}
fn extract_attr(source: &str, name: &str) -> Option<String> {
let patterns = [
format!("{}=\"", name),
format!("{}='", name),
];
for pattern in &patterns {
if let Some(start) = source.find(pattern.as_str()) {
let value_start = start + pattern.len();
let delim = if pattern.ends_with('"') { '"' } else { '\'' };
if let Some(end) = source[value_start..].find(delim) {
return Some(source[value_start..value_start + end].to_string());
}
}
}
None
}
fn resolve_str(attrs: &str, name: &str, root: Option<&String>, fallback: &str) -> String {
extract_attr(attrs, name)
.or_else(|| root.cloned())
.unwrap_or_else(|| fallback.to_string())
}
fn resolve_stroke_width(attrs: &str, root: Option<f64>) -> f64 {
extract_attr(attrs, "stroke-width")
.and_then(|s| s.parse().ok())
.or(root)
.unwrap_or(2.0)
}
fn parse_path_attrs(attrs: &str, root: &RootDefaults) -> Option<IconPath> {
let d = extract_attr(attrs, "d")?;
if d.is_empty() {
return None;
}
Some(IconPath {
d,
fill: resolve_str(attrs, "fill", root.fill.as_ref(), "none"),
stroke: resolve_str(attrs, "stroke", root.stroke.as_ref(), "currentColor"),
stroke_width: resolve_stroke_width(attrs, root.stroke_width),
stroke_linecap: resolve_str(attrs, "stroke-linecap", root.stroke_linecap.as_ref(), "round"),
stroke_linejoin: resolve_str(attrs, "stroke-linejoin", root.stroke_linejoin.as_ref(), "round"),
})
}
fn circle_to_path(attrs: &str, root: &RootDefaults) -> Option<IconPath> {
let cx: f64 = extract_attr(attrs, "cx").and_then(|s| s.parse().ok()).unwrap_or(0.0);
let cy: f64 = extract_attr(attrs, "cy").and_then(|s| s.parse().ok()).unwrap_or(0.0);
let r: f64 = extract_attr(attrs, "r").and_then(|s| s.parse().ok()).unwrap_or(0.0);
if r <= 0.0 {
return None;
}
let d = format!(
"M{},{} a{},{} 0 1,0 {},0 a{},{} 0 1,0 -{},0",
cx - r, cy, r, r, r * 2.0, r, r, r * 2.0
);
Some(IconPath {
d,
fill: resolve_str(attrs, "fill", root.fill.as_ref(), "none"),
stroke: resolve_str(attrs, "stroke", root.stroke.as_ref(), "currentColor"),
stroke_width: resolve_stroke_width(attrs, root.stroke_width),
stroke_linecap: resolve_str(attrs, "stroke-linecap", root.stroke_linecap.as_ref(), "round"),
stroke_linejoin: resolve_str(attrs, "stroke-linejoin", root.stroke_linejoin.as_ref(), "round"),
})
}
fn line_to_path(attrs: &str, root: &RootDefaults) -> Option<IconPath> {
let x1 = extract_attr(attrs, "x1").unwrap_or_else(|| "0".to_string());
let y1 = extract_attr(attrs, "y1").unwrap_or_else(|| "0".to_string());
let x2 = extract_attr(attrs, "x2").unwrap_or_else(|| "0".to_string());
let y2 = extract_attr(attrs, "y2").unwrap_or_else(|| "0".to_string());
let d = format!("M{},{}L{},{}", x1, y1, x2, y2);
Some(IconPath {
d,
fill: resolve_str(attrs, "fill", root.fill.as_ref(), "none"),
stroke: resolve_str(attrs, "stroke", root.stroke.as_ref(), "currentColor"),
stroke_width: resolve_stroke_width(attrs, root.stroke_width),
stroke_linecap: resolve_str(attrs, "stroke-linecap", root.stroke_linecap.as_ref(), "round"),
stroke_linejoin: resolve_str(attrs, "stroke-linejoin", root.stroke_linejoin.as_ref(), "round"),
})
}
fn rect_to_path(attrs: &str, root: &RootDefaults) -> Option<IconPath> {
let x: f64 = extract_attr(attrs, "x").and_then(|s| s.parse().ok()).unwrap_or(0.0);
let y: f64 = extract_attr(attrs, "y").and_then(|s| s.parse().ok()).unwrap_or(0.0);
let w: f64 = extract_attr(attrs, "width").and_then(|s| s.parse().ok()).unwrap_or(0.0);
let h: f64 = extract_attr(attrs, "height").and_then(|s| s.parse().ok()).unwrap_or(0.0);
if w <= 0.0 || h <= 0.0 {
return None;
}
let d = format!("M{},{}h{}v{}h-{}z", x, y, w, h, w);
Some(IconPath {
d,
fill: resolve_str(attrs, "fill", root.fill.as_ref(), "none"),
stroke: resolve_str(attrs, "stroke", root.stroke.as_ref(), "currentColor"),
stroke_width: resolve_stroke_width(attrs, root.stroke_width),
stroke_linecap: resolve_str(attrs, "stroke-linecap", root.stroke_linecap.as_ref(), "round"),
stroke_linejoin: resolve_str(attrs, "stroke-linejoin", root.stroke_linejoin.as_ref(), "round"),
})
}
pub fn resolve_icons_in_ir(registry: &ResourceRegistry, node: &mut super::IRNode) {
use super::{IRNode, Value};
crate::ir::walk::walk_ir_mut(node, &mut |n| {
let IRNode::Element(element) = n else { return };
if element.element_type != "Icon" {
return;
}
let icon_name = element
.props
.get("0")
.or_else(|| element.props.get("name"))
.and_then(|v| match v {
Value::Resource(name) => Some(name.clone()),
Value::Static(serde_json::Value::String(s)) => Some(s.clone()),
_ => None,
});
let Some(name) = icon_name else { return };
let Some(icon_data) = registry.resolve(&name) else { return };
let icon_props = ResourceRegistry::to_props(icon_data);
if let Some(paths) = icon_props.get("paths") {
element
.props
.insert("__iconPaths".to_string(), Value::Static(paths.clone()));
}
if let Some(view_box) = icon_props.get("viewBox") {
element
.props
.insert("__iconViewBox".to_string(), Value::Static(view_box.clone()));
}
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_svg_inherits_root_presentation_attributes() {
let svg = r#"<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M21 8.25c0-2.485-2.099-4.5-4.688-4.5-1.935 0-3.597 1.126-4.312 2.733-.715-1.607-2.377-2.733-4.313-2.733C5.1 3.75 3 5.765 3 8.25c0 7.22 9 12 9 12s9-4.78 9-12Z"/></svg>"#;
let icon = parse_svg(svg);
assert_eq!(icon.paths.len(), 1);
let p = &icon.paths[0];
assert_eq!(p.stroke_width, 1.5, "stroke-width=1.5 from <svg> root should be inherited, got {}", p.stroke_width);
assert_eq!(p.stroke, "currentColor");
assert_eq!(p.fill, "none");
assert_eq!(p.stroke_linecap, "round");
assert_eq!(p.stroke_linejoin, "round");
}
#[test]
fn test_parse_svg_child_attributes_override_root() {
let svg = r#"<svg viewBox="0 0 24 24" stroke="red" stroke-width="1.5"><path d="M0 0L10 10" stroke="blue" stroke-width="3"/></svg>"#;
let icon = parse_svg(svg);
assert_eq!(icon.paths[0].stroke, "blue");
assert_eq!(icon.paths[0].stroke_width, 3.0);
}
#[test]
fn test_parse_svg_root_scoping_does_not_leak_from_children() {
let svg = r#"<svg viewBox="0 0 24 24"><path d="M0 0L10 10" stroke-width="5"/><path d="M1 1L2 2"/></svg>"#;
let icon = parse_svg(svg);
assert_eq!(icon.paths.len(), 2);
assert_eq!(icon.paths[0].stroke_width, 5.0, "first path carries its own width");
assert_eq!(
icon.paths[1].stroke_width, 2.0,
"second path must fall back to hardcoded 2.0, not inherit from sibling"
);
}
#[test]
fn test_parse_svg_no_root_tag_falls_back_to_defaults() {
let svg = r#"<path d="M5 12h14"/>"#;
let icon = parse_svg(svg);
assert_eq!(icon.paths.len(), 1);
assert_eq!(icon.paths[0].stroke_width, 2.0);
assert_eq!(icon.paths[0].stroke, "currentColor");
assert_eq!(icon.paths[0].fill, "none");
}
#[test]
fn test_register_and_resolve() {
let mut registry = ResourceRegistry::new();
registry.register(
"heart",
r#"<svg viewBox="0 0 24 24"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z" stroke="currentColor"/></svg>"#,
);
let icon = registry.resolve("heart");
assert!(icon.is_some());
assert_eq!(icon.unwrap().paths.len(), 1);
let icon = registry.resolve("missing");
assert!(icon.is_none());
}
#[test]
fn test_register_map() {
let mut registry = ResourceRegistry::new();
let mut map = IndexMap::new();
map.insert(
"arrow".to_string(),
r#"<svg viewBox="0 0 24 24"><path d="M5 12h14"/></svg>"#.to_string(),
);
map.insert(
"star".to_string(),
r#"<svg viewBox="0 0 24 24"><path d="M12 2l3 7h7"/></svg>"#.to_string(),
);
registry.register_map(map);
assert!(registry.resolve("arrow").is_some());
assert!(registry.resolve("star").is_some());
assert!(registry.resolve("missing").is_none());
}
#[test]
fn test_to_props() {
let icon = IconData {
view_box: "0 0 24 24".to_string(),
paths: vec![IconPath {
d: "M5 12h14".to_string(),
fill: "none".to_string(),
stroke: "currentColor".to_string(),
stroke_width: 2.0,
stroke_linecap: "round".to_string(),
stroke_linejoin: "round".to_string(),
}],
};
let props = ResourceRegistry::to_props(&icon);
assert_eq!(props["viewBox"], "0 0 24 24");
assert!(props["paths"].is_array());
assert_eq!(props["paths"][0]["d"], "M5 12h14");
assert_eq!(props["paths"][0]["stroke"], "currentColor");
}
#[test]
fn test_parse_svg_basic_path() {
let svg = r#"<svg viewBox="0 0 24 24"><path d="M5 12h14" stroke="currentColor"/></svg>"#;
let icon = parse_svg(svg);
assert_eq!(icon.view_box, "0 0 24 24");
assert_eq!(icon.paths.len(), 1);
assert_eq!(icon.paths[0].d, "M5 12h14");
}
#[test]
fn test_parse_svg_multiple_paths() {
let svg = r#"<svg viewBox="0 0 24 24">
<path d="M5 12h14" stroke="currentColor"/>
<path d="M12 5v14" stroke="red" stroke-width="3"/>
</svg>"#;
let icon = parse_svg(svg);
assert_eq!(icon.paths.len(), 2);
assert_eq!(icon.paths[0].d, "M5 12h14");
assert_eq!(icon.paths[1].d, "M12 5v14");
assert_eq!(icon.paths[1].stroke, "red");
assert_eq!(icon.paths[1].stroke_width, 3.0);
}
#[test]
fn test_parse_svg_default_viewbox() {
let svg = r#"<svg><path d="M0 0L10 10"/></svg>"#;
let icon = parse_svg(svg);
assert_eq!(icon.view_box, "0 0 24 24");
}
#[test]
fn test_parse_svg_circle() {
let svg = r#"<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>"#;
let icon = parse_svg(svg);
assert_eq!(icon.paths.len(), 1);
assert!(icon.paths[0].d.starts_with("M2,12"));
}
#[test]
fn test_parse_svg_rect() {
let svg = r#"<svg viewBox="0 0 24 24"><rect x="2" y="2" width="20" height="20"/></svg>"#;
let icon = parse_svg(svg);
assert_eq!(icon.paths.len(), 1);
assert_eq!(icon.paths[0].d, "M2,2h20v20h-20z");
}
#[test]
fn test_parse_svg_line() {
let svg = r#"<svg viewBox="0 0 24 24"><line x1="0" y1="0" x2="24" y2="24"/></svg>"#;
let icon = parse_svg(svg);
assert_eq!(icon.paths.len(), 1);
assert_eq!(icon.paths[0].d, "M0,0L24,24");
}
#[test]
fn test_parse_svg_empty_path_skipped() {
let svg = r#"<svg><path d=""/></svg>"#;
let icon = parse_svg(svg);
assert_eq!(icon.paths.len(), 0);
}
#[test]
fn test_parse_svg_zero_radius_circle_skipped() {
let svg = r#"<svg><circle cx="12" cy="12" r="0"/></svg>"#;
let icon = parse_svg(svg);
assert_eq!(icon.paths.len(), 0);
}
#[test]
fn test_resolve_icon_via_resource_reference() {
use crate::ir::{Element, IRNode, Value};
let mut registry = ResourceRegistry::new();
registry.register(
"heart",
r#"<svg viewBox="0 0 24 24"><path d="M20 4.6L12 21z" stroke="currentColor"/></svg>"#,
);
let mut element = Element::new("Icon");
element
.props
.insert("0".to_string(), Value::Resource("heart".to_string()));
let mut node = IRNode::Element(element);
resolve_icons_in_ir(®istry, &mut node);
if let IRNode::Element(el) = &node {
assert!(el.props.contains_key("__iconPaths"), "Should inject __iconPaths");
assert!(el.props.contains_key("__iconViewBox"), "Should inject __iconViewBox");
match el.props.get("__iconViewBox").unwrap() {
Value::Static(v) => assert_eq!(v, "0 0 24 24"),
other => panic!("Expected Static viewBox, got: {:?}", other),
}
} else {
panic!("Expected Element");
}
}
#[test]
fn test_resolve_icon_via_static_string() {
use crate::ir::{Element, IRNode, Value};
let mut registry = ResourceRegistry::new();
registry.register(
"star",
r#"<svg viewBox="0 0 24 24"><path d="M12 2l3 7h7"/></svg>"#,
);
let mut element = Element::new("Icon");
element.props.insert(
"0".to_string(),
Value::Static(serde_json::json!("star")),
);
let mut node = IRNode::Element(element);
resolve_icons_in_ir(®istry, &mut node);
if let IRNode::Element(el) = &node {
assert!(el.props.contains_key("__iconPaths"));
} else {
panic!("Expected Element");
}
}
#[test]
fn test_resolve_icon_missing_resource() {
use crate::ir::{Element, IRNode, Value};
let registry = ResourceRegistry::new();
let mut element = Element::new("Icon");
element
.props
.insert("0".to_string(), Value::Resource("nonexistent".to_string()));
let mut node = IRNode::Element(element);
resolve_icons_in_ir(®istry, &mut node);
if let IRNode::Element(el) = &node {
assert!(!el.props.contains_key("__iconPaths"), "Should not inject paths for missing resource");
} else {
panic!("Expected Element");
}
}
}