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('&', "&")
279 .replace('"', """)
280 .replace('<', "<")
281 .replace('>', ">")
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}