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        let xmlns_xlink = if self.body.contains("xlink:") {
43            r#" xmlns:xlink="http://www.w3.org/1999/xlink""#
44        } else {
45            ""
46        };
47        let class_attr = extra_class
48            .map(|class| format!(r#" class="{}""#, escape_xml_attr(class)))
49            .unwrap_or_default();
50        format!(
51            r#"<svg xmlns="http://www.w3.org/2000/svg"{xmlns_xlink}{class_attr} width="{w}" height="{h}" viewBox="{left} {top} {vw} {vh}">{body}</svg>"#,
52            w = fmt(width_px.max(1.0)),
53            h = fmt(height_px.max(1.0)),
54            left = fmt(self.left),
55            top = fmt(self.top),
56            vw = fmt(self.width),
57            vh = fmt(self.height),
58            body = self.body
59        )
60    }
61}
62
63#[derive(Debug, Clone, Default)]
64pub struct IconRegistry {
65    icons: FxHashMap<String, IconSvg>,
66}
67
68impl IconRegistry {
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    pub fn is_empty(&self) -> bool {
74        self.icons.is_empty()
75    }
76
77    pub fn insert(&mut self, name: impl Into<String>, icon: IconSvg) {
78        self.icons
79            .insert(normalize_icon_key(name.into().as_str()), icon);
80    }
81
82    pub fn register_iconify_json_str(
83        &mut self,
84        json: &str,
85        prefix_override: Option<&str>,
86    ) -> Result<(), IconRegistryError> {
87        let value: Value =
88            serde_json::from_str(json).map_err(|err| IconRegistryError::Json(err.to_string()))?;
89        self.register_iconify_json_value(&value, prefix_override)
90    }
91
92    pub fn register_iconify_json_value(
93        &mut self,
94        value: &Value,
95        prefix_override: Option<&str>,
96    ) -> Result<(), IconRegistryError> {
97        let prefix = prefix_override
98            .map(str::trim)
99            .filter(|prefix| !prefix.is_empty())
100            .or_else(|| value.get("prefix").and_then(Value::as_str).map(str::trim))
101            .filter(|prefix| !prefix.is_empty())
102            .ok_or(IconRegistryError::MissingPrefix)?;
103
104        let defaults = IconDefaults {
105            left: number_field(value, "left").unwrap_or(0.0),
106            top: number_field(value, "top").unwrap_or(0.0),
107            width: number_field(value, "width").unwrap_or(16.0),
108            height: number_field(value, "height").unwrap_or(16.0),
109        };
110
111        let icons = value
112            .get("icons")
113            .and_then(Value::as_object)
114            .ok_or_else(|| IconRegistryError::Json("missing `icons` object".to_string()))?;
115
116        for (name, icon_value) in icons {
117            if let Some(icon) = icon_from_value(icon_value, defaults) {
118                self.insert(format!("{prefix}:{name}"), icon);
119            }
120        }
121
122        if let Some(aliases) = value.get("aliases").and_then(Value::as_object) {
123            for alias_name in aliases.keys() {
124                let mut seen = FxHashSet::default();
125                if let Some(icon) =
126                    resolve_alias_icon(alias_name, aliases, icons, defaults, &mut seen)
127                {
128                    self.insert(format!("{prefix}:{alias_name}"), icon);
129                }
130            }
131        }
132
133        Ok(())
134    }
135
136    pub fn svg_for(
137        &self,
138        icon_name: &str,
139        width_px: f64,
140        height_px: f64,
141        fallback_prefix: Option<&str>,
142        extra_class: Option<&str>,
143    ) -> Option<String> {
144        let key = resolve_icon_key(icon_name, fallback_prefix)?;
145        self.icons
146            .get(&key)
147            .map(|icon| icon.to_svg(width_px, height_px, extra_class))
148    }
149}
150
151#[derive(Debug, Clone, Copy)]
152struct IconDefaults {
153    left: f64,
154    top: f64,
155    width: f64,
156    height: f64,
157}
158
159fn resolve_alias_icon(
160    alias_name: &str,
161    aliases: &serde_json::Map<String, Value>,
162    icons: &serde_json::Map<String, Value>,
163    defaults: IconDefaults,
164    seen: &mut FxHashSet<String>,
165) -> Option<IconSvg> {
166    if !seen.insert(alias_name.to_string()) {
167        return None;
168    }
169
170    let alias = aliases.get(alias_name)?;
171    let parent = alias.get("parent").and_then(Value::as_str)?;
172    let parent_icon = icons
173        .get(parent)
174        .and_then(|value| icon_from_value(value, defaults))
175        .or_else(|| resolve_alias_icon(parent, aliases, icons, defaults, seen))?;
176
177    Some(merge_icon_overrides(parent_icon, alias, defaults))
178}
179
180fn icon_from_value(value: &Value, defaults: IconDefaults) -> Option<IconSvg> {
181    let body = value.get("body")?.as_str()?.to_string();
182    Some(
183        IconSvg::new(
184            body,
185            number_field(value, "width").unwrap_or(defaults.width),
186            number_field(value, "height").unwrap_or(defaults.height),
187        )
188        .with_viewbox(
189            number_field(value, "left").unwrap_or(defaults.left),
190            number_field(value, "top").unwrap_or(defaults.top),
191            number_field(value, "width").unwrap_or(defaults.width),
192            number_field(value, "height").unwrap_or(defaults.height),
193        ),
194    )
195}
196
197fn merge_icon_overrides(mut icon: IconSvg, value: &Value, defaults: IconDefaults) -> IconSvg {
198    if let Some(body) = value.get("body").and_then(Value::as_str) {
199        icon.body = body.to_string();
200    }
201    icon.left = number_field(value, "left").unwrap_or(icon.left);
202    icon.top = number_field(value, "top").unwrap_or(icon.top);
203    icon.width = number_field(value, "width")
204        .or(Some(icon.width))
205        .unwrap_or(defaults.width)
206        .max(1.0);
207    icon.height = number_field(value, "height")
208        .or(Some(icon.height))
209        .unwrap_or(defaults.height)
210        .max(1.0);
211    icon
212}
213
214fn number_field(value: &Value, key: &str) -> Option<f64> {
215    value.get(key).and_then(number_value)
216}
217
218fn number_value(value: &Value) -> Option<f64> {
219    match value {
220        Value::Number(n) => n.as_f64(),
221        Value::String(s) => s.trim().parse::<f64>().ok(),
222        _ => None,
223    }
224}
225
226fn resolve_icon_key(icon_name: &str, fallback_prefix: Option<&str>) -> Option<String> {
227    let icon_name = icon_name.trim();
228    if icon_name.is_empty() {
229        return None;
230    }
231
232    if let Some(without_provider) = icon_name.strip_prefix('@') {
233        let mut parts = without_provider.split(':');
234        let _provider = parts.next()?;
235        let prefix = parts.next()?;
236        let name = parts.next()?;
237        if parts.next().is_none() {
238            return Some(normalize_icon_key(&format!("{prefix}:{name}")));
239        }
240        return None;
241    }
242
243    let colon_parts = icon_name.split(':').collect::<Vec<_>>();
244    match colon_parts.as_slice() {
245        [prefix, name] if !prefix.is_empty() && !name.is_empty() => {
246            return Some(normalize_icon_key(&format!("{prefix}:{name}")));
247        }
248        [provider, prefix, name]
249            if !provider.is_empty() && !prefix.is_empty() && !name.is_empty() =>
250        {
251            return Some(normalize_icon_key(&format!("{prefix}:{name}")));
252        }
253        _ => {}
254    }
255
256    if let Some(prefix) = fallback_prefix
257        .map(str::trim)
258        .filter(|prefix| !prefix.is_empty())
259    {
260        return Some(normalize_icon_key(&format!("{prefix}:{icon_name}")));
261    }
262
263    let mut parts = icon_name.split('-');
264    let prefix = parts.next()?;
265    let name = parts.collect::<Vec<_>>().join("-");
266    if prefix.is_empty() || name.is_empty() {
267        return None;
268    }
269    Some(normalize_icon_key(&format!("{prefix}:{name}")))
270}
271
272fn normalize_icon_key(raw: &str) -> String {
273    raw.trim().to_ascii_lowercase()
274}
275
276fn escape_xml_attr(value: &str) -> String {
277    value
278        .replace('&', "&amp;")
279        .replace('"', "&quot;")
280        .replace('<', "&lt;")
281        .replace('>', "&gt;")
282}
283
284fn fmt(value: f64) -> String {
285    if !value.is_finite() {
286        return "0".to_string();
287    }
288    let mut out = String::new();
289    let _ = write!(&mut out, "{value:.6}");
290    while out.contains('.') && out.ends_with('0') {
291        out.pop();
292    }
293    if out.ends_with('.') {
294        out.pop();
295    }
296    if out == "-0" { "0".to_string() } else { out }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::*;
302
303    #[test]
304    fn registers_iconify_json_icons_and_aliases() {
305        let json = r##"{
306            "prefix": "test",
307            "width": 24,
308            "height": 24,
309            "icons": {
310                "rocket": { "body": "<path id=\"rocket\" d=\"M1 1H23V23H1z\"/>" }
311            },
312            "aliases": {
313                "ship": { "parent": "rocket", "width": 32 }
314            }
315        }"##;
316
317        let mut registry = IconRegistry::new();
318        registry.register_iconify_json_str(json, None).unwrap();
319
320        let rocket = registry
321            .svg_for("test:rocket", 48.0, 48.0, None, None)
322            .unwrap();
323        assert!(rocket.contains(r#"viewBox="0 0 24 24""#));
324        assert!(rocket.contains(r#"id="rocket""#));
325
326        let alias = registry
327            .svg_for("test:ship", 48.0, 48.0, None, None)
328            .unwrap();
329        assert!(alias.contains(r#"viewBox="0 0 32 24""#));
330    }
331
332    #[test]
333    fn resolves_hyphen_icon_names_without_fallback_prefix() {
334        let mut registry = IconRegistry::new();
335        registry.insert("logos:aws-lambda", IconSvg::new("<path/>", 16.0, 16.0));
336
337        assert!(
338            registry
339                .svg_for("logos-aws-lambda", 16.0, 16.0, None, None)
340                .is_some()
341        );
342    }
343}