1use indexmap::IndexMap;
2use serde::{Deserialize, Serialize};
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct IconPath {
7 pub d: String,
9 #[serde(default = "default_fill")]
11 pub fill: String,
12 #[serde(default = "default_stroke")]
14 pub stroke: String,
15 #[serde(default = "default_stroke_width")]
17 pub stroke_width: f64,
18 #[serde(default = "default_stroke_linecap")]
20 pub stroke_linecap: String,
21 #[serde(default = "default_stroke_linejoin")]
23 pub stroke_linejoin: String,
24}
25
26fn default_fill() -> String {
27 "none".to_string()
28}
29fn default_stroke() -> String {
30 "currentColor".to_string()
31}
32fn default_stroke_width() -> f64 {
33 2.0
34}
35fn default_stroke_linecap() -> String {
36 "round".to_string()
37}
38fn default_stroke_linejoin() -> String {
39 "round".to_string()
40}
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct IconData {
45 #[serde(default = "default_viewbox")]
47 pub view_box: String,
48 pub paths: Vec<IconPath>,
50}
51
52fn default_viewbox() -> String {
53 "0 0 24 24".to_string()
54}
55
56#[derive(Debug, Default)]
62pub struct ResourceRegistry {
63 resources: IndexMap<String, IconData>,
65}
66
67impl ResourceRegistry {
68 pub fn new() -> Self {
69 Self {
70 resources: IndexMap::new(),
71 }
72 }
73
74 pub fn register(&mut self, name: &str, svg: &str) {
76 self.resources.insert(name.to_string(), parse_svg(svg));
77 }
78
79 pub fn register_map(&mut self, map: IndexMap<String, String>) {
81 for (name, svg) in map {
82 self.resources.insert(name, parse_svg(&svg));
83 }
84 }
85
86 pub fn resolve(&self, name: &str) -> Option<&IconData> {
88 self.resources.get(name)
89 }
90
91 pub fn to_props(icon: &IconData) -> serde_json::Value {
94 let paths: Vec<serde_json::Value> = icon
95 .paths
96 .iter()
97 .map(|p| {
98 serde_json::json!({
99 "d": p.d,
100 "fill": p.fill,
101 "stroke": p.stroke,
102 "strokeWidth": p.stroke_width,
103 "strokeLinecap": p.stroke_linecap,
104 "strokeLinejoin": p.stroke_linejoin,
105 })
106 })
107 .collect();
108
109 serde_json::json!({
110 "paths": paths,
111 "viewBox": icon.view_box,
112 })
113 }
114
115 pub fn is_empty(&self) -> bool {
117 self.resources.is_empty()
118 }
119}
120
121pub fn parse_svg(svg: &str) -> IconData {
137 let root = RootDefaults::extract(svg);
138 let view_box = root.view_box.clone().unwrap_or_else(|| "0 0 24 24".to_string());
139 let mut paths = Vec::new();
140
141 for cap in RegexLite::new(r#"<path\s+([^>]*?)/?>(?:</path>)?"#, svg) {
143 if let Some(attrs) = cap {
144 if let Some(path) = parse_path_attrs(&attrs, &root) {
145 paths.push(path);
146 }
147 }
148 }
149
150 for cap in RegexLite::new(r#"<circle\s+([^>]*?)/?>(?:</circle>)?"#, svg) {
152 if let Some(attrs) = cap {
153 if let Some(path) = circle_to_path(&attrs, &root) {
154 paths.push(path);
155 }
156 }
157 }
158
159 for cap in RegexLite::new(r#"<line\s+([^>]*?)/?>(?:</line>)?"#, svg) {
161 if let Some(attrs) = cap {
162 if let Some(path) = line_to_path(&attrs, &root) {
163 paths.push(path);
164 }
165 }
166 }
167
168 for cap in RegexLite::new(r#"<rect\s+([^>]*?)/?>(?:</rect>)?"#, svg) {
170 if let Some(attrs) = cap {
171 if let Some(path) = rect_to_path(&attrs, &root) {
172 paths.push(path);
173 }
174 }
175 }
176
177 IconData { view_box, paths }
178}
179
180#[derive(Debug, Default, Clone)]
188struct RootDefaults {
189 view_box: Option<String>,
190 fill: Option<String>,
191 stroke: Option<String>,
192 stroke_width: Option<f64>,
193 stroke_linecap: Option<String>,
194 stroke_linejoin: Option<String>,
195}
196
197impl RootDefaults {
198 fn extract(svg: &str) -> Self {
206 let lower = svg.to_lowercase();
207 let Some(svg_start) = lower.find("<svg") else {
208 return Self::default();
209 };
210 let after = &svg[svg_start + 4..];
211 let Some(end) = after.find('>') else {
215 return Self::default();
216 };
217 let root_attrs = &after[..end];
218
219 Self {
220 view_box: extract_attr(root_attrs, "viewBox"),
221 fill: extract_attr(root_attrs, "fill"),
222 stroke: extract_attr(root_attrs, "stroke"),
223 stroke_width: extract_attr(root_attrs, "stroke-width")
224 .and_then(|s| s.parse().ok()),
225 stroke_linecap: extract_attr(root_attrs, "stroke-linecap"),
226 stroke_linejoin: extract_attr(root_attrs, "stroke-linejoin"),
227 }
228 }
229}
230
231struct RegexLite<'a> {
238 tag_start: &'a str,
239 source: &'a str,
240 pos: usize,
241}
242
243impl<'a> RegexLite<'a> {
244 fn new(pattern: &'a str, source: &'a str) -> Self {
245 let tag_start = if pattern.contains("<path") {
247 "<path"
248 } else if pattern.contains("<circle") {
249 "<circle"
250 } else if pattern.contains("<line") {
251 "<line"
252 } else if pattern.contains("<rect") {
253 "<rect"
254 } else {
255 "<unknown"
256 };
257 Self {
258 tag_start,
259 source,
260 pos: 0,
261 }
262 }
263}
264
265impl<'a> Iterator for RegexLite<'a> {
266 type Item = Option<String>;
267
268 fn next(&mut self) -> Option<Self::Item> {
269 let remaining = &self.source[self.pos..];
270 let lower = remaining.to_lowercase();
272 let tag_lower = self.tag_start.to_lowercase();
273
274 if let Some(start) = lower.find(&tag_lower) {
275 let abs_start = self.pos + start + self.tag_start.len();
276 let after = &self.source[abs_start..];
277
278 if let Some(end) = after.find("/>").or_else(|| after.find('>')) {
280 let attrs = after[..end].trim().to_string();
281 self.pos = abs_start + end + 2;
282 Some(Some(attrs))
283 } else {
284 self.pos = self.source.len();
285 None
286 }
287 } else {
288 None
289 }
290 }
291}
292
293fn extract_attr(source: &str, name: &str) -> Option<String> {
294 let patterns = [
296 format!("{}=\"", name),
297 format!("{}='", name),
298 ];
299
300 for pattern in &patterns {
301 if let Some(start) = source.find(pattern.as_str()) {
302 let value_start = start + pattern.len();
303 let delim = if pattern.ends_with('"') { '"' } else { '\'' };
304 if let Some(end) = source[value_start..].find(delim) {
305 return Some(source[value_start..value_start + end].to_string());
306 }
307 }
308 }
309 None
310}
311
312fn resolve_str(attrs: &str, name: &str, root: Option<&String>, fallback: &str) -> String {
314 extract_attr(attrs, name)
315 .or_else(|| root.cloned())
316 .unwrap_or_else(|| fallback.to_string())
317}
318
319fn resolve_stroke_width(attrs: &str, root: Option<f64>) -> f64 {
321 extract_attr(attrs, "stroke-width")
322 .and_then(|s| s.parse().ok())
323 .or(root)
324 .unwrap_or(2.0)
325}
326
327fn parse_path_attrs(attrs: &str, root: &RootDefaults) -> Option<IconPath> {
328 let d = extract_attr(attrs, "d")?;
329 if d.is_empty() {
330 return None;
331 }
332
333 Some(IconPath {
334 d,
335 fill: resolve_str(attrs, "fill", root.fill.as_ref(), "none"),
336 stroke: resolve_str(attrs, "stroke", root.stroke.as_ref(), "currentColor"),
337 stroke_width: resolve_stroke_width(attrs, root.stroke_width),
338 stroke_linecap: resolve_str(attrs, "stroke-linecap", root.stroke_linecap.as_ref(), "round"),
339 stroke_linejoin: resolve_str(attrs, "stroke-linejoin", root.stroke_linejoin.as_ref(), "round"),
340 })
341}
342
343fn circle_to_path(attrs: &str, root: &RootDefaults) -> Option<IconPath> {
344 let cx: f64 = extract_attr(attrs, "cx").and_then(|s| s.parse().ok()).unwrap_or(0.0);
345 let cy: f64 = extract_attr(attrs, "cy").and_then(|s| s.parse().ok()).unwrap_or(0.0);
346 let r: f64 = extract_attr(attrs, "r").and_then(|s| s.parse().ok()).unwrap_or(0.0);
347 if r <= 0.0 {
348 return None;
349 }
350
351 let d = format!(
352 "M{},{} a{},{} 0 1,0 {},0 a{},{} 0 1,0 -{},0",
353 cx - r, cy, r, r, r * 2.0, r, r, r * 2.0
354 );
355
356 Some(IconPath {
357 d,
358 fill: resolve_str(attrs, "fill", root.fill.as_ref(), "none"),
359 stroke: resolve_str(attrs, "stroke", root.stroke.as_ref(), "currentColor"),
360 stroke_width: resolve_stroke_width(attrs, root.stroke_width),
361 stroke_linecap: resolve_str(attrs, "stroke-linecap", root.stroke_linecap.as_ref(), "round"),
362 stroke_linejoin: resolve_str(attrs, "stroke-linejoin", root.stroke_linejoin.as_ref(), "round"),
363 })
364}
365
366fn line_to_path(attrs: &str, root: &RootDefaults) -> Option<IconPath> {
367 let x1 = extract_attr(attrs, "x1").unwrap_or_else(|| "0".to_string());
368 let y1 = extract_attr(attrs, "y1").unwrap_or_else(|| "0".to_string());
369 let x2 = extract_attr(attrs, "x2").unwrap_or_else(|| "0".to_string());
370 let y2 = extract_attr(attrs, "y2").unwrap_or_else(|| "0".to_string());
371
372 let d = format!("M{},{}L{},{}", x1, y1, x2, y2);
373
374 Some(IconPath {
375 d,
376 fill: resolve_str(attrs, "fill", root.fill.as_ref(), "none"),
377 stroke: resolve_str(attrs, "stroke", root.stroke.as_ref(), "currentColor"),
378 stroke_width: resolve_stroke_width(attrs, root.stroke_width),
379 stroke_linecap: resolve_str(attrs, "stroke-linecap", root.stroke_linecap.as_ref(), "round"),
380 stroke_linejoin: resolve_str(attrs, "stroke-linejoin", root.stroke_linejoin.as_ref(), "round"),
381 })
382}
383
384fn rect_to_path(attrs: &str, root: &RootDefaults) -> Option<IconPath> {
385 let x: f64 = extract_attr(attrs, "x").and_then(|s| s.parse().ok()).unwrap_or(0.0);
386 let y: f64 = extract_attr(attrs, "y").and_then(|s| s.parse().ok()).unwrap_or(0.0);
387 let w: f64 = extract_attr(attrs, "width").and_then(|s| s.parse().ok()).unwrap_or(0.0);
388 let h: f64 = extract_attr(attrs, "height").and_then(|s| s.parse().ok()).unwrap_or(0.0);
389 if w <= 0.0 || h <= 0.0 {
390 return None;
391 }
392
393 let d = format!("M{},{}h{}v{}h-{}z", x, y, w, h, w);
394
395 Some(IconPath {
396 d,
397 fill: resolve_str(attrs, "fill", root.fill.as_ref(), "none"),
398 stroke: resolve_str(attrs, "stroke", root.stroke.as_ref(), "currentColor"),
399 stroke_width: resolve_stroke_width(attrs, root.stroke_width),
400 stroke_linecap: resolve_str(attrs, "stroke-linecap", root.stroke_linecap.as_ref(), "round"),
401 stroke_linejoin: resolve_str(attrs, "stroke-linejoin", root.stroke_linejoin.as_ref(), "round"),
402 })
403}
404
405pub fn resolve_icons_in_ir(registry: &ResourceRegistry, node: &mut super::IRNode) {
418 use super::{IRNode, Value};
419
420 crate::ir::walk::walk_ir_mut(node, &mut |n| {
421 let IRNode::Element(element) = n else { return };
422 if element.element_type != "Icon" {
423 return;
424 }
425
426 let icon_name = element
430 .props
431 .get("0")
432 .or_else(|| element.props.get("name"))
433 .and_then(|v| match v {
434 Value::Resource(name) => Some(name.clone()),
435 Value::Static(serde_json::Value::String(s)) => Some(s.clone()),
436 _ => None,
437 });
438
439 let Some(name) = icon_name else { return };
440 let Some(icon_data) = registry.resolve(&name) else { return };
441 let icon_props = ResourceRegistry::to_props(icon_data);
442
443 if let Some(paths) = icon_props.get("paths") {
444 element
445 .props
446 .insert("__iconPaths".to_string(), Value::Static(paths.clone()));
447 }
448
449 if let Some(view_box) = icon_props.get("viewBox") {
450 element
451 .props
452 .insert("__iconViewBox".to_string(), Value::Static(view_box.clone()));
453 }
454 });
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460
461 #[test]
462 fn test_parse_svg_inherits_root_presentation_attributes() {
463 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>"#;
465 let icon = parse_svg(svg);
466 assert_eq!(icon.paths.len(), 1);
467 let p = &icon.paths[0];
468 assert_eq!(p.stroke_width, 1.5, "stroke-width=1.5 from <svg> root should be inherited, got {}", p.stroke_width);
469 assert_eq!(p.stroke, "currentColor");
470 assert_eq!(p.fill, "none");
471 assert_eq!(p.stroke_linecap, "round");
472 assert_eq!(p.stroke_linejoin, "round");
473 }
474
475 #[test]
476 fn test_parse_svg_child_attributes_override_root() {
477 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>"#;
480 let icon = parse_svg(svg);
481 assert_eq!(icon.paths[0].stroke, "blue");
482 assert_eq!(icon.paths[0].stroke_width, 3.0);
483 }
484
485 #[test]
486 fn test_parse_svg_root_scoping_does_not_leak_from_children() {
487 let svg = r#"<svg viewBox="0 0 24 24"><path d="M0 0L10 10" stroke-width="5"/><path d="M1 1L2 2"/></svg>"#;
491 let icon = parse_svg(svg);
492 assert_eq!(icon.paths.len(), 2);
493 assert_eq!(icon.paths[0].stroke_width, 5.0, "first path carries its own width");
494 assert_eq!(
495 icon.paths[1].stroke_width, 2.0,
496 "second path must fall back to hardcoded 2.0, not inherit from sibling"
497 );
498 }
499
500 #[test]
501 fn test_parse_svg_no_root_tag_falls_back_to_defaults() {
502 let svg = r#"<path d="M5 12h14"/>"#;
505 let icon = parse_svg(svg);
506 assert_eq!(icon.paths.len(), 1);
507 assert_eq!(icon.paths[0].stroke_width, 2.0);
508 assert_eq!(icon.paths[0].stroke, "currentColor");
509 assert_eq!(icon.paths[0].fill, "none");
510 }
511
512 #[test]
513 fn test_register_and_resolve() {
514 let mut registry = ResourceRegistry::new();
515
516 registry.register(
518 "heart",
519 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>"#,
520 );
521
522 let icon = registry.resolve("heart");
524 assert!(icon.is_some());
525 assert_eq!(icon.unwrap().paths.len(), 1);
526
527 let icon = registry.resolve("missing");
529 assert!(icon.is_none());
530 }
531
532 #[test]
533 fn test_register_map() {
534 let mut registry = ResourceRegistry::new();
535 let mut map = IndexMap::new();
536 map.insert(
537 "arrow".to_string(),
538 r#"<svg viewBox="0 0 24 24"><path d="M5 12h14"/></svg>"#.to_string(),
539 );
540 map.insert(
541 "star".to_string(),
542 r#"<svg viewBox="0 0 24 24"><path d="M12 2l3 7h7"/></svg>"#.to_string(),
543 );
544 registry.register_map(map);
545
546 assert!(registry.resolve("arrow").is_some());
547 assert!(registry.resolve("star").is_some());
548 assert!(registry.resolve("missing").is_none());
549 }
550
551 #[test]
552 fn test_to_props() {
553 let icon = IconData {
554 view_box: "0 0 24 24".to_string(),
555 paths: vec![IconPath {
556 d: "M5 12h14".to_string(),
557 fill: "none".to_string(),
558 stroke: "currentColor".to_string(),
559 stroke_width: 2.0,
560 stroke_linecap: "round".to_string(),
561 stroke_linejoin: "round".to_string(),
562 }],
563 };
564
565 let props = ResourceRegistry::to_props(&icon);
566 assert_eq!(props["viewBox"], "0 0 24 24");
567 assert!(props["paths"].is_array());
568 assert_eq!(props["paths"][0]["d"], "M5 12h14");
569 assert_eq!(props["paths"][0]["stroke"], "currentColor");
570 }
571
572 #[test]
573 fn test_parse_svg_basic_path() {
574 let svg = r#"<svg viewBox="0 0 24 24"><path d="M5 12h14" stroke="currentColor"/></svg>"#;
575 let icon = parse_svg(svg);
576 assert_eq!(icon.view_box, "0 0 24 24");
577 assert_eq!(icon.paths.len(), 1);
578 assert_eq!(icon.paths[0].d, "M5 12h14");
579 }
580
581 #[test]
582 fn test_parse_svg_multiple_paths() {
583 let svg = r#"<svg viewBox="0 0 24 24">
584 <path d="M5 12h14" stroke="currentColor"/>
585 <path d="M12 5v14" stroke="red" stroke-width="3"/>
586 </svg>"#;
587 let icon = parse_svg(svg);
588 assert_eq!(icon.paths.len(), 2);
589 assert_eq!(icon.paths[0].d, "M5 12h14");
590 assert_eq!(icon.paths[1].d, "M12 5v14");
591 assert_eq!(icon.paths[1].stroke, "red");
592 assert_eq!(icon.paths[1].stroke_width, 3.0);
593 }
594
595 #[test]
596 fn test_parse_svg_default_viewbox() {
597 let svg = r#"<svg><path d="M0 0L10 10"/></svg>"#;
598 let icon = parse_svg(svg);
599 assert_eq!(icon.view_box, "0 0 24 24");
600 }
601
602 #[test]
603 fn test_parse_svg_circle() {
604 let svg = r#"<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>"#;
605 let icon = parse_svg(svg);
606 assert_eq!(icon.paths.len(), 1);
607 assert!(icon.paths[0].d.starts_with("M2,12"));
608 }
609
610 #[test]
611 fn test_parse_svg_rect() {
612 let svg = r#"<svg viewBox="0 0 24 24"><rect x="2" y="2" width="20" height="20"/></svg>"#;
613 let icon = parse_svg(svg);
614 assert_eq!(icon.paths.len(), 1);
615 assert_eq!(icon.paths[0].d, "M2,2h20v20h-20z");
616 }
617
618 #[test]
619 fn test_parse_svg_line() {
620 let svg = r#"<svg viewBox="0 0 24 24"><line x1="0" y1="0" x2="24" y2="24"/></svg>"#;
621 let icon = parse_svg(svg);
622 assert_eq!(icon.paths.len(), 1);
623 assert_eq!(icon.paths[0].d, "M0,0L24,24");
624 }
625
626 #[test]
627 fn test_parse_svg_empty_path_skipped() {
628 let svg = r#"<svg><path d=""/></svg>"#;
629 let icon = parse_svg(svg);
630 assert_eq!(icon.paths.len(), 0);
631 }
632
633 #[test]
634 fn test_parse_svg_zero_radius_circle_skipped() {
635 let svg = r#"<svg><circle cx="12" cy="12" r="0"/></svg>"#;
636 let icon = parse_svg(svg);
637 assert_eq!(icon.paths.len(), 0);
638 }
639
640 #[test]
641 fn test_resolve_icon_via_resource_reference() {
642 use crate::ir::{Element, IRNode, Value};
643
644 let mut registry = ResourceRegistry::new();
645 registry.register(
646 "heart",
647 r#"<svg viewBox="0 0 24 24"><path d="M20 4.6L12 21z" stroke="currentColor"/></svg>"#,
648 );
649
650 let mut element = Element::new("Icon");
652 element
653 .props
654 .insert("0".to_string(), Value::Resource("heart".to_string()));
655 let mut node = IRNode::Element(element);
656
657 resolve_icons_in_ir(®istry, &mut node);
658
659 if let IRNode::Element(el) = &node {
660 assert!(el.props.contains_key("__iconPaths"), "Should inject __iconPaths");
661 assert!(el.props.contains_key("__iconViewBox"), "Should inject __iconViewBox");
662 match el.props.get("__iconViewBox").unwrap() {
663 Value::Static(v) => assert_eq!(v, "0 0 24 24"),
664 other => panic!("Expected Static viewBox, got: {:?}", other),
665 }
666 } else {
667 panic!("Expected Element");
668 }
669 }
670
671 #[test]
672 fn test_resolve_icon_via_static_string() {
673 use crate::ir::{Element, IRNode, Value};
674
675 let mut registry = ResourceRegistry::new();
676 registry.register(
677 "star",
678 r#"<svg viewBox="0 0 24 24"><path d="M12 2l3 7h7"/></svg>"#,
679 );
680
681 let mut element = Element::new("Icon");
683 element.props.insert(
684 "0".to_string(),
685 Value::Static(serde_json::json!("star")),
686 );
687 let mut node = IRNode::Element(element);
688
689 resolve_icons_in_ir(®istry, &mut node);
690
691 if let IRNode::Element(el) = &node {
692 assert!(el.props.contains_key("__iconPaths"));
693 } else {
694 panic!("Expected Element");
695 }
696 }
697
698 #[test]
699 fn test_resolve_icon_missing_resource() {
700 use crate::ir::{Element, IRNode, Value};
701
702 let registry = ResourceRegistry::new(); let mut element = Element::new("Icon");
705 element
706 .props
707 .insert("0".to_string(), Value::Resource("nonexistent".to_string()));
708 let mut node = IRNode::Element(element);
709
710 resolve_icons_in_ir(®istry, &mut node);
711
712 if let IRNode::Element(el) = &node {
713 assert!(!el.props.contains_key("__iconPaths"), "Should not inject paths for missing resource");
714 } else {
715 panic!("Expected Element");
716 }
717 }
718}