Skip to main content

merman_render/svg/
icon_registry.rs

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('&', "&amp;")
407        .replace('"', "&quot;")
408        .replace('<', "&lt;")
409        .replace('>', "&gt;")
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}