1use rustc_hash::{FxHashMap, FxHashSet};
2use serde_json::Value;
3use std::fmt::Write as _;
4
5#[derive(Debug, Clone, thiserror::Error)]
6pub enum IconRegistryError {
7 #[error("Iconify JSON error: {0}")]
8 Json(String),
9 #[error("Iconify JSON is missing a non-empty prefix")]
10 MissingPrefix,
11}
12
13#[derive(Debug, Clone, PartialEq)]
14pub struct IconSvg {
15 body: String,
16 left: f64,
17 top: f64,
18 width: f64,
19 height: f64,
20}
21
22impl IconSvg {
23 pub fn new(body: impl Into<String>, width: f64, height: f64) -> Self {
24 Self {
25 body: body.into(),
26 left: 0.0,
27 top: 0.0,
28 width: width.max(1.0),
29 height: height.max(1.0),
30 }
31 }
32
33 pub fn with_viewbox(mut self, left: f64, top: f64, width: f64, height: f64) -> Self {
34 self.left = left;
35 self.top = top;
36 self.width = width.max(1.0);
37 self.height = height.max(1.0);
38 self
39 }
40
41 pub fn to_svg(&self, width_px: f64, height_px: f64, extra_class: Option<&str>) -> String {
42 self.to_svg_with_id_scope(width_px, height_px, extra_class, None)
43 }
44
45 pub fn to_svg_with_id_scope(
46 &self,
47 width_px: f64,
48 height_px: f64,
49 extra_class: Option<&str>,
50 id_scope: Option<&str>,
51 ) -> String {
52 let body = id_scope
53 .map(|scope| scope_svg_internal_ids(&self.body, scope))
54 .unwrap_or_else(|| self.body.clone());
55 let xmlns_xlink = if self.body.contains("xlink:") {
56 r#" xmlns:xlink="http://www.w3.org/1999/xlink""#
57 } else {
58 ""
59 };
60 let class_attr = extra_class
61 .map(|class| format!(r#" class="{}""#, escape_xml_attr(class)))
62 .unwrap_or_default();
63 format!(
64 r#"<svg xmlns="http://www.w3.org/2000/svg"{xmlns_xlink}{class_attr} width="{w}" height="{h}" viewBox="{left} {top} {vw} {vh}">{body}</svg>"#,
65 w = fmt(width_px.max(1.0)),
66 h = fmt(height_px.max(1.0)),
67 left = fmt(self.left),
68 top = fmt(self.top),
69 vw = fmt(self.width),
70 vh = fmt(self.height),
71 body = body
72 )
73 }
74}
75
76#[derive(Debug, Clone, Default)]
77pub struct IconRegistry {
78 icons: FxHashMap<String, IconSvg>,
79}
80
81impl IconRegistry {
82 pub fn new() -> Self {
83 Self::default()
84 }
85
86 pub fn is_empty(&self) -> bool {
87 self.icons.is_empty()
88 }
89
90 pub fn insert(&mut self, name: impl Into<String>, icon: IconSvg) {
91 self.icons
92 .insert(normalize_icon_key(name.into().as_str()), icon);
93 }
94
95 pub fn register_iconify_json_str(
96 &mut self,
97 json: &str,
98 prefix_override: Option<&str>,
99 ) -> Result<(), IconRegistryError> {
100 let value: Value =
101 serde_json::from_str(json).map_err(|err| IconRegistryError::Json(err.to_string()))?;
102 self.register_iconify_json_value(&value, prefix_override)
103 }
104
105 pub fn register_iconify_json_value(
106 &mut self,
107 value: &Value,
108 prefix_override: Option<&str>,
109 ) -> Result<(), IconRegistryError> {
110 let prefix = prefix_override
111 .map(str::trim)
112 .filter(|prefix| !prefix.is_empty())
113 .or_else(|| value.get("prefix").and_then(Value::as_str).map(str::trim))
114 .filter(|prefix| !prefix.is_empty())
115 .ok_or(IconRegistryError::MissingPrefix)?;
116
117 let defaults = IconDefaults {
118 left: number_field(value, "left").unwrap_or(0.0),
119 top: number_field(value, "top").unwrap_or(0.0),
120 width: number_field(value, "width").unwrap_or(16.0),
121 height: number_field(value, "height").unwrap_or(16.0),
122 };
123
124 let icons = value
125 .get("icons")
126 .and_then(Value::as_object)
127 .ok_or_else(|| IconRegistryError::Json("missing `icons` object".to_string()))?;
128
129 for (name, icon_value) in icons {
130 if let Some(icon) = icon_from_value(icon_value, defaults) {
131 self.insert(format!("{prefix}:{name}"), icon);
132 }
133 }
134
135 if let Some(aliases) = value.get("aliases").and_then(Value::as_object) {
136 for alias_name in aliases.keys() {
137 let mut seen = FxHashSet::default();
138 if let Some(icon) =
139 resolve_alias_icon(alias_name, aliases, icons, defaults, &mut seen)
140 {
141 self.insert(format!("{prefix}:{alias_name}"), icon);
142 }
143 }
144 }
145
146 Ok(())
147 }
148
149 pub fn svg_for(
150 &self,
151 icon_name: &str,
152 width_px: f64,
153 height_px: f64,
154 fallback_prefix: Option<&str>,
155 extra_class: Option<&str>,
156 ) -> Option<String> {
157 let key = resolve_icon_key(icon_name, fallback_prefix)?;
158 self.icons
159 .get(&key)
160 .map(|icon| icon.to_svg(width_px, height_px, extra_class))
161 }
162
163 pub fn svg_for_scoped(
164 &self,
165 icon_name: &str,
166 width_px: f64,
167 height_px: f64,
168 fallback_prefix: Option<&str>,
169 extra_class: Option<&str>,
170 id_scope: &str,
171 ) -> Option<String> {
172 let key = resolve_icon_key(icon_name, fallback_prefix)?;
173 self.icons
174 .get(&key)
175 .map(|icon| icon.to_svg_with_id_scope(width_px, height_px, extra_class, Some(id_scope)))
176 }
177}
178
179pub(in crate::svg) fn scope_svg_internal_ids(body: &str, scope: &str) -> String {
180 let ids = collect_svg_internal_ids(body);
181 if ids.is_empty() {
182 return body.to_string();
183 }
184
185 let prefix = deterministic_iconify_id_prefix(scope);
186 let suffix = format!("IconifyIdsuffix{:016x}", stable_hash64(scope));
187 let mut rewritten = body.to_string();
188 for (index, id) in ids.iter().enumerate() {
189 let new_id = format!("{prefix}{index}{suffix}");
190 rewritten = replace_iconify_id_occurrences(&rewritten, id, &new_id);
191 }
192 rewritten.replace(&suffix, "")
193}
194
195fn collect_svg_internal_ids(body: &str) -> Vec<String> {
196 let mut ids = Vec::new();
197 let mut index = 0;
198 while let Some((attr_start, quote, value_start)) = find_next_id_attr(body, index) {
199 let Some(relative_end) = body[value_start..].find(quote) else {
200 break;
201 };
202 let value_end = value_start + relative_end;
203 if value_start < value_end {
204 ids.push(body[value_start..value_end].to_string());
205 }
206 index = attr_start + 1;
207 }
208 ids
209}
210
211fn find_next_id_attr(body: &str, from: usize) -> Option<(usize, char, usize)> {
212 let bytes = body.as_bytes();
213 let mut i = from;
214 while i + 4 <= bytes.len() {
215 if bytes[i].is_ascii_whitespace()
216 && bytes[i + 1] == b'i'
217 && bytes[i + 2] == b'd'
218 && bytes[i + 3] == b'='
219 && i + 4 < bytes.len()
220 && (bytes[i + 4] == b'"' || bytes[i + 4] == b'\'')
221 {
222 return Some((i, bytes[i + 4] as char, i + 5));
223 }
224 i += 1;
225 }
226 None
227}
228
229fn deterministic_iconify_id_prefix(scope: &str) -> String {
230 format!("IconifyId{:016x}", stable_hash64(scope))
231}
232
233fn stable_hash64(value: &str) -> u64 {
234 let mut hash = 0xcbf29ce484222325u64;
235 for byte in value.as_bytes() {
236 hash ^= u64::from(*byte);
237 hash = hash.wrapping_mul(0x100000001b3);
238 }
239 hash
240}
241
242fn replace_iconify_id_occurrences(body: &str, id: &str, replacement: &str) -> String {
243 let mut out = String::with_capacity(body.len());
244 let mut last = 0;
245 let mut scan = 0;
246 while let Some(relative) = body[scan..].find(id) {
247 let start = scan + relative;
248 let end = start + id.len();
249 if is_iconify_reference_boundary(body, start, end) {
250 out.push_str(&body[last..start]);
251 out.push_str(replacement);
252 last = end;
253 }
254 scan = end;
255 }
256 out.push_str(&body[last..]);
257 out
258}
259
260fn is_iconify_reference_boundary(body: &str, start: usize, end: usize) -> bool {
261 let Some(prev) = body[..start].chars().next_back() else {
262 return false;
263 };
264 if !matches!(prev, '#' | ';' | '"' | '\'') {
265 return false;
266 }
267
268 let Some(next) = body[end..].chars().next() else {
269 return false;
270 };
271 matches!(next, '"' | '\'' | ')')
272 || (next == '.'
273 && body[end + next.len_utf8()..]
274 .chars()
275 .next()
276 .is_some_and(|ch| ch.is_ascii_lowercase()))
277}
278
279#[derive(Debug, Clone, Copy)]
280struct IconDefaults {
281 left: f64,
282 top: f64,
283 width: f64,
284 height: f64,
285}
286
287fn resolve_alias_icon(
288 alias_name: &str,
289 aliases: &serde_json::Map<String, Value>,
290 icons: &serde_json::Map<String, Value>,
291 defaults: IconDefaults,
292 seen: &mut FxHashSet<String>,
293) -> Option<IconSvg> {
294 if !seen.insert(alias_name.to_string()) {
295 return None;
296 }
297
298 let alias = aliases.get(alias_name)?;
299 let parent = alias.get("parent").and_then(Value::as_str)?;
300 let parent_icon = icons
301 .get(parent)
302 .and_then(|value| icon_from_value(value, defaults))
303 .or_else(|| resolve_alias_icon(parent, aliases, icons, defaults, seen))?;
304
305 Some(merge_icon_overrides(parent_icon, alias, defaults))
306}
307
308fn icon_from_value(value: &Value, defaults: IconDefaults) -> Option<IconSvg> {
309 let body = value.get("body")?.as_str()?.to_string();
310 Some(
311 IconSvg::new(
312 body,
313 number_field(value, "width").unwrap_or(defaults.width),
314 number_field(value, "height").unwrap_or(defaults.height),
315 )
316 .with_viewbox(
317 number_field(value, "left").unwrap_or(defaults.left),
318 number_field(value, "top").unwrap_or(defaults.top),
319 number_field(value, "width").unwrap_or(defaults.width),
320 number_field(value, "height").unwrap_or(defaults.height),
321 ),
322 )
323}
324
325fn merge_icon_overrides(mut icon: IconSvg, value: &Value, defaults: IconDefaults) -> IconSvg {
326 if let Some(body) = value.get("body").and_then(Value::as_str) {
327 icon.body = body.to_string();
328 }
329 icon.left = number_field(value, "left").unwrap_or(icon.left);
330 icon.top = number_field(value, "top").unwrap_or(icon.top);
331 icon.width = number_field(value, "width")
332 .or(Some(icon.width))
333 .unwrap_or(defaults.width)
334 .max(1.0);
335 icon.height = number_field(value, "height")
336 .or(Some(icon.height))
337 .unwrap_or(defaults.height)
338 .max(1.0);
339 icon
340}
341
342fn number_field(value: &Value, key: &str) -> Option<f64> {
343 value.get(key).and_then(number_value)
344}
345
346fn number_value(value: &Value) -> Option<f64> {
347 match value {
348 Value::Number(n) => n.as_f64(),
349 Value::String(s) => s.trim().parse::<f64>().ok(),
350 _ => None,
351 }
352}
353
354fn resolve_icon_key(icon_name: &str, fallback_prefix: Option<&str>) -> Option<String> {
355 let icon_name = icon_name.trim();
356 if icon_name.is_empty() {
357 return None;
358 }
359
360 if let Some(without_provider) = icon_name.strip_prefix('@') {
361 let mut parts = without_provider.split(':');
362 let _provider = parts.next()?;
363 let prefix = parts.next()?;
364 let name = parts.next()?;
365 if parts.next().is_none() {
366 return Some(normalize_icon_key(&format!("{prefix}:{name}")));
367 }
368 return None;
369 }
370
371 let colon_parts = icon_name.split(':').collect::<Vec<_>>();
372 match colon_parts.as_slice() {
373 [prefix, name] if !prefix.is_empty() && !name.is_empty() => {
374 return Some(normalize_icon_key(&format!("{prefix}:{name}")));
375 }
376 [provider, prefix, name]
377 if !provider.is_empty() && !prefix.is_empty() && !name.is_empty() =>
378 {
379 return Some(normalize_icon_key(&format!("{prefix}:{name}")));
380 }
381 _ => {}
382 }
383
384 if let Some(prefix) = fallback_prefix
385 .map(str::trim)
386 .filter(|prefix| !prefix.is_empty())
387 {
388 return Some(normalize_icon_key(&format!("{prefix}:{icon_name}")));
389 }
390
391 let mut parts = icon_name.split('-');
392 let prefix = parts.next()?;
393 let name = parts.collect::<Vec<_>>().join("-");
394 if prefix.is_empty() || name.is_empty() {
395 return None;
396 }
397 Some(normalize_icon_key(&format!("{prefix}:{name}")))
398}
399
400fn normalize_icon_key(raw: &str) -> String {
401 raw.trim().to_ascii_lowercase()
402}
403
404fn escape_xml_attr(value: &str) -> String {
405 value
406 .replace('&', "&")
407 .replace('"', """)
408 .replace('<', "<")
409 .replace('>', ">")
410}
411
412fn fmt(value: f64) -> String {
413 if !value.is_finite() {
414 return "0".to_string();
415 }
416 let mut out = String::new();
417 let _ = write!(&mut out, "{value:.6}");
418 while out.contains('.') && out.ends_with('0') {
419 out.pop();
420 }
421 if out.ends_with('.') {
422 out.pop();
423 }
424 if out == "-0" { "0".to_string() } else { out }
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430
431 #[test]
432 fn registers_iconify_json_icons_and_aliases() {
433 let json = r##"{
434 "prefix": "test",
435 "width": 24,
436 "height": 24,
437 "icons": {
438 "rocket": { "body": "<path id=\"rocket\" d=\"M1 1H23V23H1z\"/>" }
439 },
440 "aliases": {
441 "ship": { "parent": "rocket", "width": 32 }
442 }
443 }"##;
444
445 let mut registry = IconRegistry::new();
446 registry.register_iconify_json_str(json, None).unwrap();
447
448 let rocket = registry
449 .svg_for("test:rocket", 48.0, 48.0, None, None)
450 .unwrap();
451 assert!(rocket.contains(r#"viewBox="0 0 24 24""#));
452 assert!(rocket.contains(r#"id="rocket""#));
453
454 let alias = registry
455 .svg_for("test:ship", 48.0, 48.0, None, None)
456 .unwrap();
457 assert!(alias.contains(r#"viewBox="0 0 32 24""#));
458 }
459
460 #[test]
461 fn scoped_svg_rewrites_internal_ids_and_references() {
462 let icon = IconSvg::new(
463 r##"<defs><clipPath id="clip"><path id='shape' d="M0 0H1V1H0z"/></clipPath></defs><use href="#shape" xlink:href="#shape" clip-path="url(#clip)"/><animate begin="shape.end;shape.click"/>"##,
464 16.0,
465 16.0,
466 );
467
468 let svg = icon.to_svg_with_id_scope(16.0, 16.0, None, Some("diagram-node-a"));
469
470 assert!(!svg.contains(r#"id="clip""#), "{svg}");
471 assert!(!svg.contains(r#"id='shape'"#), "{svg}");
472 assert!(!svg.contains(r##"href="#shape""##), "{svg}");
473 assert!(!svg.contains(r##"xlink:href="#shape""##), "{svg}");
474 assert!(!svg.contains(r#"url(#clip)"#), "{svg}");
475 assert!(!svg.contains("shape.end"), "{svg}");
476 assert!(!svg.contains("shape.click"), "{svg}");
477 let scoped_ids = svg.match_indices(r#"id="IconifyId"#).count()
478 + svg.match_indices(r#"id='IconifyId"#).count();
479 assert_eq!(scoped_ids, 2, "{svg}");
480 }
481
482 #[test]
483 fn scoped_svg_is_deterministic_for_same_scope_and_differs_across_scopes() {
484 let icon = IconSvg::new(
485 r##"<defs><clipPath id="clip"><path d="M0 0H1V1H0z"/></clipPath></defs><path clip-path="url(#clip)"/>"##,
486 16.0,
487 16.0,
488 );
489
490 let a1 = icon.to_svg_with_id_scope(16.0, 16.0, None, Some("diagram-node-a"));
491 let a2 = icon.to_svg_with_id_scope(16.0, 16.0, None, Some("diagram-node-a"));
492 let b = icon.to_svg_with_id_scope(16.0, 16.0, None, Some("diagram-node-b"));
493
494 assert_eq!(a1, a2);
495 assert_ne!(a1, b);
496 }
497
498 #[test]
499 fn resolves_hyphen_icon_names_without_fallback_prefix() {
500 let mut registry = IconRegistry::new();
501 registry.insert("logos:aws-lambda", IconSvg::new("<path/>", 16.0, 16.0));
502
503 assert!(
504 registry
505 .svg_for("logos-aws-lambda", 16.0, 16.0, None, None)
506 .is_some()
507 );
508 }
509}