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
139 .view_box
140 .clone()
141 .unwrap_or_else(|| "0 0 24 24".to_string());
142 let mut paths = Vec::new();
143
144 for cap in RegexLite::new(r#"<path\s+([^>]*?)/?>(?:</path>)?"#, svg) {
146 if let Some(attrs) = cap {
147 if let Some(path) = parse_path_attrs(&attrs, &root) {
148 paths.push(path);
149 }
150 }
151 }
152
153 for cap in RegexLite::new(r#"<circle\s+([^>]*?)/?>(?:</circle>)?"#, svg) {
155 if let Some(attrs) = cap {
156 if let Some(path) = circle_to_path(&attrs, &root) {
157 paths.push(path);
158 }
159 }
160 }
161
162 for cap in RegexLite::new(r#"<line\s+([^>]*?)/?>(?:</line>)?"#, svg) {
164 if let Some(attrs) = cap {
165 if let Some(path) = line_to_path(&attrs, &root) {
166 paths.push(path);
167 }
168 }
169 }
170
171 for cap in RegexLite::new(r#"<rect\s+([^>]*?)/?>(?:</rect>)?"#, svg) {
173 if let Some(attrs) = cap {
174 if let Some(path) = rect_to_path(&attrs, &root) {
175 paths.push(path);
176 }
177 }
178 }
179
180 IconData { view_box, paths }
181}
182
183#[derive(Debug, Default, Clone)]
191struct RootDefaults {
192 view_box: Option<String>,
193 fill: Option<String>,
194 stroke: Option<String>,
195 stroke_width: Option<f64>,
196 stroke_linecap: Option<String>,
197 stroke_linejoin: Option<String>,
198}
199
200impl RootDefaults {
201 fn extract(svg: &str) -> Self {
209 let lower = svg.to_lowercase();
210 let Some(svg_start) = lower.find("<svg") else {
211 return Self::default();
212 };
213 let after = &svg[svg_start + 4..];
214 let Some(end) = after.find('>') else {
218 return Self::default();
219 };
220 let root_attrs = &after[..end];
221
222 Self {
223 view_box: extract_attr(root_attrs, "viewBox"),
224 fill: extract_attr(root_attrs, "fill"),
225 stroke: extract_attr(root_attrs, "stroke"),
226 stroke_width: extract_attr(root_attrs, "stroke-width").and_then(|s| s.parse().ok()),
227 stroke_linecap: extract_attr(root_attrs, "stroke-linecap"),
228 stroke_linejoin: extract_attr(root_attrs, "stroke-linejoin"),
229 }
230 }
231}
232
233struct RegexLite<'a> {
240 tag_start: &'a str,
241 source: &'a str,
242 pos: usize,
243}
244
245impl<'a> RegexLite<'a> {
246 fn new(pattern: &'a str, source: &'a str) -> Self {
247 let tag_start = if pattern.contains("<path") {
249 "<path"
250 } else if pattern.contains("<circle") {
251 "<circle"
252 } else if pattern.contains("<line") {
253 "<line"
254 } else if pattern.contains("<rect") {
255 "<rect"
256 } else {
257 "<unknown"
258 };
259 Self {
260 tag_start,
261 source,
262 pos: 0,
263 }
264 }
265}
266
267impl<'a> Iterator for RegexLite<'a> {
268 type Item = Option<String>;
269
270 fn next(&mut self) -> Option<Self::Item> {
271 let remaining = &self.source[self.pos..];
272 let lower = remaining.to_lowercase();
274 let tag_lower = self.tag_start.to_lowercase();
275
276 if let Some(start) = lower.find(&tag_lower) {
277 let abs_start = self.pos + start + self.tag_start.len();
278 let after = &self.source[abs_start..];
279
280 if let Some(end) = after.find("/>").or_else(|| after.find('>')) {
282 let attrs = after[..end].trim().to_string();
283 self.pos = abs_start + end + 2;
284 Some(Some(attrs))
285 } else {
286 self.pos = self.source.len();
287 None
288 }
289 } else {
290 None
291 }
292 }
293}
294
295fn extract_attr(source: &str, name: &str) -> Option<String> {
296 let patterns = [format!("{}=\"", name), format!("{}='", name)];
298
299 for pattern in &patterns {
300 if let Some(start) = source.find(pattern.as_str()) {
301 let value_start = start + pattern.len();
302 let delim = if pattern.ends_with('"') { '"' } else { '\'' };
303 if let Some(end) = source[value_start..].find(delim) {
304 return Some(source[value_start..value_start + end].to_string());
305 }
306 }
307 }
308 None
309}
310
311fn resolve_str(attrs: &str, name: &str, root: Option<&String>, fallback: &str) -> String {
313 extract_attr(attrs, name)
314 .or_else(|| root.cloned())
315 .unwrap_or_else(|| fallback.to_string())
316}
317
318fn resolve_stroke_width(attrs: &str, root: Option<f64>) -> f64 {
320 extract_attr(attrs, "stroke-width")
321 .and_then(|s| s.parse().ok())
322 .or(root)
323 .unwrap_or(2.0)
324}
325
326fn parse_path_attrs(attrs: &str, root: &RootDefaults) -> Option<IconPath> {
327 let d = extract_attr(attrs, "d")?;
328 if d.is_empty() {
329 return None;
330 }
331
332 Some(IconPath {
333 d,
334 fill: resolve_str(attrs, "fill", root.fill.as_ref(), "none"),
335 stroke: resolve_str(attrs, "stroke", root.stroke.as_ref(), "currentColor"),
336 stroke_width: resolve_stroke_width(attrs, root.stroke_width),
337 stroke_linecap: resolve_str(
338 attrs,
339 "stroke-linecap",
340 root.stroke_linecap.as_ref(),
341 "round",
342 ),
343 stroke_linejoin: resolve_str(
344 attrs,
345 "stroke-linejoin",
346 root.stroke_linejoin.as_ref(),
347 "round",
348 ),
349 })
350}
351
352fn circle_to_path(attrs: &str, root: &RootDefaults) -> Option<IconPath> {
353 let cx: f64 = extract_attr(attrs, "cx")
354 .and_then(|s| s.parse().ok())
355 .unwrap_or(0.0);
356 let cy: f64 = extract_attr(attrs, "cy")
357 .and_then(|s| s.parse().ok())
358 .unwrap_or(0.0);
359 let r: f64 = extract_attr(attrs, "r")
360 .and_then(|s| s.parse().ok())
361 .unwrap_or(0.0);
362 if r <= 0.0 {
363 return None;
364 }
365
366 let d = format!(
367 "M{},{} a{},{} 0 1,0 {},0 a{},{} 0 1,0 -{},0",
368 cx - r,
369 cy,
370 r,
371 r,
372 r * 2.0,
373 r,
374 r,
375 r * 2.0
376 );
377
378 Some(IconPath {
379 d,
380 fill: resolve_str(attrs, "fill", root.fill.as_ref(), "none"),
381 stroke: resolve_str(attrs, "stroke", root.stroke.as_ref(), "currentColor"),
382 stroke_width: resolve_stroke_width(attrs, root.stroke_width),
383 stroke_linecap: resolve_str(
384 attrs,
385 "stroke-linecap",
386 root.stroke_linecap.as_ref(),
387 "round",
388 ),
389 stroke_linejoin: resolve_str(
390 attrs,
391 "stroke-linejoin",
392 root.stroke_linejoin.as_ref(),
393 "round",
394 ),
395 })
396}
397
398fn line_to_path(attrs: &str, root: &RootDefaults) -> Option<IconPath> {
399 let x1 = extract_attr(attrs, "x1").unwrap_or_else(|| "0".to_string());
400 let y1 = extract_attr(attrs, "y1").unwrap_or_else(|| "0".to_string());
401 let x2 = extract_attr(attrs, "x2").unwrap_or_else(|| "0".to_string());
402 let y2 = extract_attr(attrs, "y2").unwrap_or_else(|| "0".to_string());
403
404 let d = format!("M{},{}L{},{}", x1, y1, x2, y2);
405
406 Some(IconPath {
407 d,
408 fill: resolve_str(attrs, "fill", root.fill.as_ref(), "none"),
409 stroke: resolve_str(attrs, "stroke", root.stroke.as_ref(), "currentColor"),
410 stroke_width: resolve_stroke_width(attrs, root.stroke_width),
411 stroke_linecap: resolve_str(
412 attrs,
413 "stroke-linecap",
414 root.stroke_linecap.as_ref(),
415 "round",
416 ),
417 stroke_linejoin: resolve_str(
418 attrs,
419 "stroke-linejoin",
420 root.stroke_linejoin.as_ref(),
421 "round",
422 ),
423 })
424}
425
426fn rect_to_path(attrs: &str, root: &RootDefaults) -> Option<IconPath> {
427 let x: f64 = extract_attr(attrs, "x")
428 .and_then(|s| s.parse().ok())
429 .unwrap_or(0.0);
430 let y: f64 = extract_attr(attrs, "y")
431 .and_then(|s| s.parse().ok())
432 .unwrap_or(0.0);
433 let w: f64 = extract_attr(attrs, "width")
434 .and_then(|s| s.parse().ok())
435 .unwrap_or(0.0);
436 let h: f64 = extract_attr(attrs, "height")
437 .and_then(|s| s.parse().ok())
438 .unwrap_or(0.0);
439 if w <= 0.0 || h <= 0.0 {
440 return None;
441 }
442
443 let d = format!("M{},{}h{}v{}h-{}z", x, y, w, h, w);
444
445 Some(IconPath {
446 d,
447 fill: resolve_str(attrs, "fill", root.fill.as_ref(), "none"),
448 stroke: resolve_str(attrs, "stroke", root.stroke.as_ref(), "currentColor"),
449 stroke_width: resolve_stroke_width(attrs, root.stroke_width),
450 stroke_linecap: resolve_str(
451 attrs,
452 "stroke-linecap",
453 root.stroke_linecap.as_ref(),
454 "round",
455 ),
456 stroke_linejoin: resolve_str(
457 attrs,
458 "stroke-linejoin",
459 root.stroke_linejoin.as_ref(),
460 "round",
461 ),
462 })
463}
464
465pub fn resolve_icons_in_ir(registry: &ResourceRegistry, node: &mut super::IRNode) {
478 use super::{IRNode, Value};
479
480 crate::ir::walk::walk_ir_mut(node, &mut |n| {
481 let IRNode::Element(element) = n else { return };
482 if element.element_type != "Icon" {
483 return;
484 }
485
486 let icon_name = element
490 .props
491 .get("0")
492 .or_else(|| element.props.get("name"))
493 .and_then(|v| match v {
494 Value::Resource(name) => Some(name.clone()),
495 Value::Static(serde_json::Value::String(s)) => Some(s.clone()),
496 _ => None,
497 });
498
499 let Some(name) = icon_name else { return };
500 let Some(icon_data) = registry.resolve(&name) else {
501 return;
502 };
503 let icon_props = ResourceRegistry::to_props(icon_data);
504
505 if let Some(paths) = icon_props.get("paths") {
506 element
507 .props
508 .insert("__iconPaths".to_string(), Value::Static(paths.clone()));
509 }
510
511 if let Some(view_box) = icon_props.get("viewBox") {
512 element
513 .props
514 .insert("__iconViewBox".to_string(), Value::Static(view_box.clone()));
515 }
516 });
517}
518
519#[cfg(test)]
520mod tests {
521 use super::*;
522
523 #[test]
524 fn test_parse_svg_inherits_root_presentation_attributes() {
525 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>"#;
527 let icon = parse_svg(svg);
528 assert_eq!(icon.paths.len(), 1);
529 let p = &icon.paths[0];
530 assert_eq!(
531 p.stroke_width, 1.5,
532 "stroke-width=1.5 from <svg> root should be inherited, got {}",
533 p.stroke_width
534 );
535 assert_eq!(p.stroke, "currentColor");
536 assert_eq!(p.fill, "none");
537 assert_eq!(p.stroke_linecap, "round");
538 assert_eq!(p.stroke_linejoin, "round");
539 }
540
541 #[test]
542 fn test_parse_svg_child_attributes_override_root() {
543 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>"#;
546 let icon = parse_svg(svg);
547 assert_eq!(icon.paths[0].stroke, "blue");
548 assert_eq!(icon.paths[0].stroke_width, 3.0);
549 }
550
551 #[test]
552 fn test_parse_svg_root_scoping_does_not_leak_from_children() {
553 let svg = r#"<svg viewBox="0 0 24 24"><path d="M0 0L10 10" stroke-width="5"/><path d="M1 1L2 2"/></svg>"#;
557 let icon = parse_svg(svg);
558 assert_eq!(icon.paths.len(), 2);
559 assert_eq!(
560 icon.paths[0].stroke_width, 5.0,
561 "first path carries its own width"
562 );
563 assert_eq!(
564 icon.paths[1].stroke_width, 2.0,
565 "second path must fall back to hardcoded 2.0, not inherit from sibling"
566 );
567 }
568
569 #[test]
570 fn test_parse_svg_no_root_tag_falls_back_to_defaults() {
571 let svg = r#"<path d="M5 12h14"/>"#;
574 let icon = parse_svg(svg);
575 assert_eq!(icon.paths.len(), 1);
576 assert_eq!(icon.paths[0].stroke_width, 2.0);
577 assert_eq!(icon.paths[0].stroke, "currentColor");
578 assert_eq!(icon.paths[0].fill, "none");
579 }
580
581 #[test]
582 fn test_register_and_resolve() {
583 let mut registry = ResourceRegistry::new();
584
585 registry.register(
587 "heart",
588 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>"#,
589 );
590
591 let icon = registry.resolve("heart");
593 assert!(icon.is_some());
594 assert_eq!(icon.unwrap().paths.len(), 1);
595
596 let icon = registry.resolve("missing");
598 assert!(icon.is_none());
599 }
600
601 #[test]
602 fn test_register_map() {
603 let mut registry = ResourceRegistry::new();
604 let mut map = IndexMap::new();
605 map.insert(
606 "arrow".to_string(),
607 r#"<svg viewBox="0 0 24 24"><path d="M5 12h14"/></svg>"#.to_string(),
608 );
609 map.insert(
610 "star".to_string(),
611 r#"<svg viewBox="0 0 24 24"><path d="M12 2l3 7h7"/></svg>"#.to_string(),
612 );
613 registry.register_map(map);
614
615 assert!(registry.resolve("arrow").is_some());
616 assert!(registry.resolve("star").is_some());
617 assert!(registry.resolve("missing").is_none());
618 }
619
620 #[test]
621 fn test_to_props() {
622 let icon = IconData {
623 view_box: "0 0 24 24".to_string(),
624 paths: vec![IconPath {
625 d: "M5 12h14".to_string(),
626 fill: "none".to_string(),
627 stroke: "currentColor".to_string(),
628 stroke_width: 2.0,
629 stroke_linecap: "round".to_string(),
630 stroke_linejoin: "round".to_string(),
631 }],
632 };
633
634 let props = ResourceRegistry::to_props(&icon);
635 assert_eq!(props["viewBox"], "0 0 24 24");
636 assert!(props["paths"].is_array());
637 assert_eq!(props["paths"][0]["d"], "M5 12h14");
638 assert_eq!(props["paths"][0]["stroke"], "currentColor");
639 }
640
641 #[test]
642 fn test_parse_svg_basic_path() {
643 let svg = r#"<svg viewBox="0 0 24 24"><path d="M5 12h14" stroke="currentColor"/></svg>"#;
644 let icon = parse_svg(svg);
645 assert_eq!(icon.view_box, "0 0 24 24");
646 assert_eq!(icon.paths.len(), 1);
647 assert_eq!(icon.paths[0].d, "M5 12h14");
648 }
649
650 #[test]
651 fn test_parse_svg_multiple_paths() {
652 let svg = r#"<svg viewBox="0 0 24 24">
653 <path d="M5 12h14" stroke="currentColor"/>
654 <path d="M12 5v14" stroke="red" stroke-width="3"/>
655 </svg>"#;
656 let icon = parse_svg(svg);
657 assert_eq!(icon.paths.len(), 2);
658 assert_eq!(icon.paths[0].d, "M5 12h14");
659 assert_eq!(icon.paths[1].d, "M12 5v14");
660 assert_eq!(icon.paths[1].stroke, "red");
661 assert_eq!(icon.paths[1].stroke_width, 3.0);
662 }
663
664 #[test]
665 fn test_parse_svg_default_viewbox() {
666 let svg = r#"<svg><path d="M0 0L10 10"/></svg>"#;
667 let icon = parse_svg(svg);
668 assert_eq!(icon.view_box, "0 0 24 24");
669 }
670
671 #[test]
672 fn test_parse_svg_circle() {
673 let svg = r#"<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/></svg>"#;
674 let icon = parse_svg(svg);
675 assert_eq!(icon.paths.len(), 1);
676 assert!(icon.paths[0].d.starts_with("M2,12"));
677 }
678
679 #[test]
680 fn test_parse_svg_rect() {
681 let svg = r#"<svg viewBox="0 0 24 24"><rect x="2" y="2" width="20" height="20"/></svg>"#;
682 let icon = parse_svg(svg);
683 assert_eq!(icon.paths.len(), 1);
684 assert_eq!(icon.paths[0].d, "M2,2h20v20h-20z");
685 }
686
687 #[test]
688 fn test_parse_svg_line() {
689 let svg = r#"<svg viewBox="0 0 24 24"><line x1="0" y1="0" x2="24" y2="24"/></svg>"#;
690 let icon = parse_svg(svg);
691 assert_eq!(icon.paths.len(), 1);
692 assert_eq!(icon.paths[0].d, "M0,0L24,24");
693 }
694
695 #[test]
696 fn test_parse_svg_empty_path_skipped() {
697 let svg = r#"<svg><path d=""/></svg>"#;
698 let icon = parse_svg(svg);
699 assert_eq!(icon.paths.len(), 0);
700 }
701
702 #[test]
703 fn test_parse_svg_zero_radius_circle_skipped() {
704 let svg = r#"<svg><circle cx="12" cy="12" r="0"/></svg>"#;
705 let icon = parse_svg(svg);
706 assert_eq!(icon.paths.len(), 0);
707 }
708
709 #[test]
710 fn test_resolve_icon_via_resource_reference() {
711 use crate::ir::{Element, IRNode, Value};
712
713 let mut registry = ResourceRegistry::new();
714 registry.register(
715 "heart",
716 r#"<svg viewBox="0 0 24 24"><path d="M20 4.6L12 21z" stroke="currentColor"/></svg>"#,
717 );
718
719 let mut element = Element::new("Icon");
721 element
722 .props
723 .insert("0".to_string(), Value::Resource("heart".to_string()));
724 let mut node = IRNode::Element(element);
725
726 resolve_icons_in_ir(®istry, &mut node);
727
728 if let IRNode::Element(el) = &node {
729 assert!(
730 el.props.contains_key("__iconPaths"),
731 "Should inject __iconPaths"
732 );
733 assert!(
734 el.props.contains_key("__iconViewBox"),
735 "Should inject __iconViewBox"
736 );
737 match el.props.get("__iconViewBox").unwrap() {
738 Value::Static(v) => assert_eq!(v, "0 0 24 24"),
739 other => panic!("Expected Static viewBox, got: {:?}", other),
740 }
741 } else {
742 panic!("Expected Element");
743 }
744 }
745
746 #[test]
747 fn test_resolve_icon_via_static_string() {
748 use crate::ir::{Element, IRNode, Value};
749
750 let mut registry = ResourceRegistry::new();
751 registry.register(
752 "star",
753 r#"<svg viewBox="0 0 24 24"><path d="M12 2l3 7h7"/></svg>"#,
754 );
755
756 let mut element = Element::new("Icon");
758 element
759 .props
760 .insert("0".to_string(), Value::Static(serde_json::json!("star")));
761 let mut node = IRNode::Element(element);
762
763 resolve_icons_in_ir(®istry, &mut node);
764
765 if let IRNode::Element(el) = &node {
766 assert!(el.props.contains_key("__iconPaths"));
767 } else {
768 panic!("Expected Element");
769 }
770 }
771
772 #[test]
773 fn test_resolve_icon_missing_resource() {
774 use crate::ir::{Element, IRNode, Value};
775
776 let registry = ResourceRegistry::new(); let mut element = Element::new("Icon");
779 element
780 .props
781 .insert("0".to_string(), Value::Resource("nonexistent".to_string()));
782 let mut node = IRNode::Element(element);
783
784 resolve_icons_in_ir(®istry, &mut node);
785
786 if let IRNode::Element(el) = &node {
787 assert!(
788 !el.props.contains_key("__iconPaths"),
789 "Should not inject paths for missing resource"
790 );
791 } else {
792 panic!("Expected Element");
793 }
794 }
795}