Skip to main content

icon_to_image/
css_parser.rs

1//! CSS parser for Font Awesome icon mappings.
2//!
3//! Extracts icon name to Unicode codepoint mappings from Font Awesome CSS files.
4
5use crate::error::{IconFontError, Result};
6use rustc_hash::FxHashMap;
7use std::collections::HashSet;
8
9/// Represents the font style/weight for an icon.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
11pub enum FontStyle {
12    #[default]
13    Solid,
14    Regular,
15    Brands,
16}
17
18/// Icon mapping containing the Unicode codepoint and font style.
19#[derive(Debug, Clone)]
20pub struct IconMapping {
21    pub codepoint: char,
22    pub style: FontStyle,
23}
24
25/// Parser for Font Awesome CSS files.
26#[derive(Debug)]
27pub struct CssParser {
28    icons: FxHashMap<String, IconMapping>,
29    icon_names: Vec<String>,
30}
31
32const ICON_RULE_MARKER: &str = "{--fa:\"";
33
34impl CssParser {
35    /// Parse a CSS string and extract icon mappings.
36    ///
37    /// # Errors
38    ///
39    /// Returns `CssParseError` if no icons found.
40    pub fn parse(css_content: &str) -> Result<Self> {
41        let icon_rule_count = css_content.matches(ICON_RULE_MARKER).count();
42        let mut icons = FxHashMap::with_capacity_and_hasher(
43            icon_rule_count.saturating_mul(2),
44            Default::default(),
45        );
46        let mut brand_icons = HashSet::with_capacity(icon_rule_count / 4);
47
48        // First pass: identify brand icons from the brands CSS section
49        let brand_section_start = css_content.find(".fa-brands,.fa-classic.fa-brands,.fab{");
50        let brand_section_end = css_content
51            .find(":host,:root{--fa-font-regular")
52            .or_else(|| css_content.find(".far{"));
53
54        if let (Some(start), Some(end)) = (brand_section_start, brand_section_end) {
55            let brand_section = &css_content[start..end];
56            scan_icon_rules(brand_section, |selectors, _value| {
57                extract_fa_names(selectors, |name| {
58                    if !is_utility_class(name) {
59                        brand_icons.insert(normalize_ascii_lower(name));
60                    }
61                });
62            });
63        }
64
65        // Second pass: parse all icon definitions
66        scan_icon_rules(css_content, |selectors, value| {
67            if let Some(codepoint) = parse_codepoint(value) {
68                extract_fa_names(selectors, |name| {
69                    if is_utility_class(name) {
70                        return;
71                    }
72
73                    let name_lower = normalize_ascii_lower(name);
74                    let style = if brand_icons.contains(&name_lower) {
75                        FontStyle::Brands
76                    } else {
77                        FontStyle::Solid
78                    };
79
80                    icons.insert(name_lower, IconMapping { codepoint, style });
81                });
82            }
83        });
84
85        if icons.is_empty() {
86            return Err(IconFontError::CssParseError(
87                "No icon mappings found in CSS".to_string(),
88            ));
89        }
90
91        let icon_names = icons.keys().cloned().collect();
92
93        Ok(Self { icons, icon_names })
94    }
95
96    /// Look up an icon by name (with or without "fa-" prefix).
97    #[inline]
98    pub fn get_icon(&self, name: &str) -> Option<&IconMapping> {
99        let name = name.strip_prefix("fa-").unwrap_or(name);
100        if name.bytes().all(|byte| !byte.is_ascii_uppercase()) {
101            self.icons.get(name)
102        } else {
103            let lower = name.to_ascii_lowercase();
104            self.icons.get(lower.as_str())
105        }
106    }
107
108    /// Look up an icon with explicit style override.
109    pub fn get_icon_with_style(&self, name: &str, style: FontStyle) -> Option<IconMapping> {
110        self.get_icon(name).map(|mapping| IconMapping {
111            codepoint: mapping.codepoint,
112            style,
113        })
114    }
115
116    pub fn icon_count(&self) -> usize {
117        self.icons.len()
118    }
119
120    pub fn has_icon(&self, name: &str) -> bool {
121        self.get_icon(name).is_some()
122    }
123
124    pub fn list_icons(&self) -> Vec<&str> {
125        self.icon_names.iter().map(String::as_str).collect()
126    }
127}
128
129/// Scan icon rules matching `.fa-name{--fa:"value"}` and call `on_rule(selectors, value)`.
130fn scan_icon_rules(css: &str, mut on_rule: impl FnMut(&str, &str)) {
131    let mut scan_start = 0usize;
132    let marker_len = ICON_RULE_MARKER.len();
133
134    while let Some(marker_rel) = css[scan_start..].find(ICON_RULE_MARKER) {
135        let marker = scan_start + marker_rel;
136        let selector_start = css[scan_start..marker]
137            .rfind('}')
138            .map(|idx| scan_start + idx + 1)
139            .unwrap_or(scan_start);
140        let selectors = css[selector_start..marker].trim();
141
142        let value_start = marker + marker_len;
143        let Some(quote_rel) = css[value_start..].find('"') else {
144            break;
145        };
146        let value_end = value_start + quote_rel;
147        let value = &css[value_start..value_end];
148
149        on_rule(selectors, value);
150
151        let close_rel = css[value_end..].find('}').unwrap_or(0);
152        scan_start = value_end + close_rel + 1;
153    }
154}
155
156/// Extract `.fa-*` selector names from a selector list.
157fn extract_fa_names(selectors: &str, mut on_name: impl FnMut(&str)) {
158    let bytes = selectors.as_bytes();
159    let mut idx = 0usize;
160
161    while let Some(rel) = selectors[idx..].find(".fa-") {
162        idx += rel + 4;
163        let start = idx;
164        while idx < bytes.len() {
165            let byte = bytes[idx];
166            if byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-' {
167                idx += 1;
168            } else {
169                break;
170            }
171        }
172
173        if idx > start {
174            on_name(&selectors[start..idx]);
175        }
176    }
177}
178
179#[inline]
180fn normalize_ascii_lower(name: &str) -> String {
181    if name.bytes().any(|byte| byte.is_ascii_uppercase()) {
182        name.to_ascii_lowercase()
183    } else {
184        name.to_string()
185    }
186}
187
188/// Filter out Font Awesome utility classes (sizes, animations, transforms).
189fn is_utility_class(name: &str) -> bool {
190    matches!(
191        name,
192        // Size multiplier classes (1x through 10x)
193        "1x" | "2x" | "3x" | "4x" | "5x" | "6x" | "7x" | "8x" | "9x" | "10x"
194            // Relative size classes
195            | "2xs"
196            | "xs"
197            | "sm"
198            | "lg"
199            | "xl"
200            | "2xl"
201            | "fw"
202            | "ul"
203            | "li"
204            | "border"
205            | "inverse"
206            | "pull-left"
207            | "pull-right"
208            | "pull-start"
209            | "pull-end"
210            | "beat"
211            | "bounce"
212            | "fade"
213            | "beat-fade"
214            | "flip"
215            | "shake"
216            | "spin"
217            | "spin-pulse"
218            | "pulse"
219            | "spin-reverse"
220            | "rotate-90"
221            | "rotate-180"
222            | "rotate-270"
223            | "rotate-by"
224            | "flip-horizontal"
225            | "flip-vertical"
226            | "flip-both"
227            | "stack"
228            | "stack-1x"
229            | "stack-2x"
230            | "width-auto"
231            | "width-fixed"
232            | "brands"
233            | "classic"
234            | "regular"
235            | "solid"
236    )
237}
238
239/// Parse a Unicode codepoint from a CSS value (hex escape, escaped literal, or literal).
240fn parse_codepoint(value: &str) -> Option<char> {
241    let value = value.trim();
242    if value.is_empty() {
243        return None;
244    }
245
246    if let Some(hex_part) = value.strip_prefix('\\') {
247        if hex_part.len() >= 2 && hex_part.chars().all(|c| c.is_ascii_hexdigit() || c == ' ') {
248            let hex_clean = hex_part.trim();
249            if let Ok(code) = u32::from_str_radix(hex_clean, 16) {
250                return char::from_u32(code);
251            }
252        }
253        return hex_part.chars().next();
254    }
255
256    value.chars().next()
257}
258
259#[cfg(test)]
260mod tests {
261    use super::*;
262
263    #[test]
264    fn test_parse_codepoint() {
265        assert_eq!(parse_codepoint("\\f004"), Some('\u{f004}'));
266        assert_eq!(parse_codepoint("\\e005"), Some('\u{e005}'));
267        assert_eq!(parse_codepoint("A"), Some('A'));
268        assert_eq!(parse_codepoint("\\!"), Some('!'));
269        assert_eq!(parse_codepoint("\\30 "), Some('0')); // "0" character
270    }
271
272    #[test]
273    fn test_is_utility_class() {
274        assert!(is_utility_class("1x"));
275        assert!(is_utility_class("2xl"));
276        assert!(is_utility_class("spin"));
277        assert!(is_utility_class("brands"));
278        assert!(!is_utility_class("heart"));
279        assert!(!is_utility_class("github"));
280    }
281
282    #[test]
283    fn test_numeric_icons_not_utility_classes() {
284        // Single digit icons 0-9 are valid Font Awesome icons, not utility classes
285        for digit in ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9"] {
286            assert!(
287                !is_utility_class(digit),
288                "Digit '{}' should NOT be a utility class",
289                digit
290            );
291        }
292        // But size classes like 1x, 2x, 10x ARE utility classes
293        assert!(is_utility_class("1x"));
294        assert!(is_utility_class("10x"));
295
296        // Brand icons ending in 'x' that start with digits should NOT be filtered
297        assert!(
298            !is_utility_class("500px"),
299            "500px brand icon should NOT be a utility class"
300        );
301    }
302
303    #[test]
304    fn test_parse_500px_brand_icon() {
305        // 500px is a brand icon that starts with digit and ends with 'x'
306        let css = r#".fa-500px{--fa:"\f26e"}"#;
307        let parser = CssParser::parse(css).unwrap();
308
309        assert!(parser.has_icon("500px"), "Icon '500px' should be parsed");
310        assert_eq!(parser.get_icon("500px").unwrap().codepoint, '\u{f26e}');
311    }
312
313    #[test]
314    fn test_parse_numeric_icons() {
315        // Test that numeric icons 1-9 are correctly parsed
316        let css = r#".fa-1{--fa:"\31 "}.fa-2{--fa:"\32 "}.fa-9{--fa:"\39 "}"#;
317        let parser = CssParser::parse(css).unwrap();
318
319        assert!(parser.has_icon("1"), "Icon '1' should be parsed");
320        assert!(parser.has_icon("2"), "Icon '2' should be parsed");
321        assert!(parser.has_icon("9"), "Icon '9' should be parsed");
322
323        // Verify correct codepoints (ASCII digits)
324        assert_eq!(parser.get_icon("1").unwrap().codepoint, '1');
325        assert_eq!(parser.get_icon("2").unwrap().codepoint, '2');
326        assert_eq!(parser.get_icon("9").unwrap().codepoint, '9');
327    }
328
329    #[test]
330    fn test_parse_icon_aliases() {
331        // Test CSS with aliased icon names (multiple selectors pointing to same codepoint)
332        let css = r#".fa-circle-xmark,.fa-times-circle,.fa-xmark-circle{--fa:"\f057"}"#;
333        let parser = CssParser::parse(css).unwrap();
334
335        // All three aliases should resolve to the same codepoint
336        assert!(parser.has_icon("circle-xmark"));
337        assert!(parser.has_icon("times-circle"));
338        assert!(parser.has_icon("xmark-circle"));
339
340        let mapping1 = parser.get_icon("circle-xmark").unwrap();
341        let mapping2 = parser.get_icon("times-circle").unwrap();
342        let mapping3 = parser.get_icon("xmark-circle").unwrap();
343
344        assert_eq!(mapping1.codepoint, '\u{f057}');
345        assert_eq!(mapping2.codepoint, '\u{f057}');
346        assert_eq!(mapping3.codepoint, '\u{f057}');
347    }
348}