icon_to_image/
css_parser.rs1use crate::error::{IconFontError, Result};
6use rustc_hash::FxHashMap;
7use std::collections::HashSet;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
11pub enum FontStyle {
12 #[default]
13 Solid,
14 Regular,
15 Brands,
16}
17
18#[derive(Debug, Clone)]
20pub struct IconMapping {
21 pub codepoint: char,
22 pub style: FontStyle,
23}
24
25#[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 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 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 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 #[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 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
129fn 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
156fn 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
188fn is_utility_class(name: &str) -> bool {
190 matches!(
191 name,
192 "1x" | "2x" | "3x" | "4x" | "5x" | "6x" | "7x" | "8x" | "9x" | "10x"
194 | "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
239fn 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')); }
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 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 assert!(is_utility_class("1x"));
294 assert!(is_utility_class("10x"));
295
296 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 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 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 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 let css = r#".fa-circle-xmark,.fa-times-circle,.fa-xmark-circle{--fa:"\f057"}"#;
333 let parser = CssParser::parse(css).unwrap();
334
335 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}