1#![no_std]
27#![doc(html_root_url = "https://docs.rs/parse-css-font/0.1.0")]
28
29extern crate alloc;
30
31use alloc::borrow::ToOwned;
32use alloc::format;
33use alloc::string::{String, ToString};
34use alloc::vec::Vec;
35use core::fmt;
36
37#[cfg(doctest)]
39#[doc = include_str!("../README.md")]
40struct ReadmeDoctests;
41
42const SYSTEM_FONTS: [(&str, SystemFont); 6] = [
43 ("caption", SystemFont::Caption),
44 ("icon", SystemFont::Icon),
45 ("menu", SystemFont::Menu),
46 ("message-box", SystemFont::MessageBox),
47 ("small-caption", SystemFont::SmallCaption),
48 ("status-bar", SystemFont::StatusBar),
49];
50
51const STYLE_KEYWORDS: [&str; 3] = ["normal", "italic", "oblique"];
52const WEIGHT_KEYWORDS: [&str; 13] = [
53 "normal", "bold", "bolder", "lighter", "100", "200", "300", "400", "500", "600", "700", "800",
54 "900",
55];
56const STRETCH_KEYWORDS: [&str; 9] = [
57 "normal",
58 "condensed",
59 "semi-condensed",
60 "extra-condensed",
61 "ultra-condensed",
62 "expanded",
63 "semi-expanded",
64 "extra-expanded",
65 "ultra-expanded",
66];
67const SIZE_KEYWORDS: [&str; 9] = [
68 "xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large", "larger", "smaller",
69];
70
71#[derive(Debug, Clone, PartialEq)]
73pub enum Font {
74 System(SystemFont),
76 Shorthand(Shorthand),
78}
79
80#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
82pub enum SystemFont {
83 Caption,
85 Icon,
87 Menu,
89 MessageBox,
91 SmallCaption,
93 StatusBar,
95}
96
97impl SystemFont {
98 #[must_use]
100 pub const fn as_str(self) -> &'static str {
101 match self {
102 SystemFont::Caption => "caption",
103 SystemFont::Icon => "icon",
104 SystemFont::Menu => "menu",
105 SystemFont::MessageBox => "message-box",
106 SystemFont::SmallCaption => "small-caption",
107 SystemFont::StatusBar => "status-bar",
108 }
109 }
110}
111
112impl fmt::Display for SystemFont {
113 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114 f.write_str(self.as_str())
115 }
116}
117
118#[derive(Debug, Clone, PartialEq)]
121pub struct Shorthand {
122 pub style: String,
124 pub variant: String,
126 pub weight: String,
128 pub stretch: String,
130 pub size: String,
132 pub line_height: LineHeight,
134 pub family: Vec<String>,
136}
137
138#[derive(Debug, Clone, PartialEq)]
140pub enum LineHeight {
141 Normal,
143 Number(f64),
145 Other(String),
147}
148
149impl fmt::Display for LineHeight {
150 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151 match self {
152 LineHeight::Normal => f.write_str("normal"),
153 LineHeight::Number(n) => write!(f, "{n}"),
154 LineHeight::Other(s) => f.write_str(s),
155 }
156 }
157}
158
159#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum ParseError {
162 EmptyString,
164 MissingFontSize,
166 MissingFontFamily,
168 DuplicateStyle,
170 DuplicateWeight,
172 DuplicateStretch,
174}
175
176impl fmt::Display for ParseError {
177 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178 f.write_str(match self {
179 ParseError::EmptyString => "cannot parse an empty string",
180 ParseError::MissingFontSize => "missing required font-size",
181 ParseError::MissingFontFamily => "missing required font-family",
182 ParseError::DuplicateStyle => "font-style already defined",
183 ParseError::DuplicateWeight => "font-weight already defined",
184 ParseError::DuplicateStretch => "font-stretch already defined",
185 })
186 }
187}
188
189impl core::error::Error for ParseError {}
190
191pub fn parse(value: &str) -> Result<Font, ParseError> {
204 if value.is_empty() {
205 return Err(ParseError::EmptyString);
206 }
207 if let Some(system) = system_font(value) {
208 return Ok(Font::System(system));
209 }
210
211 let mut style = String::new();
212 let mut variant = String::new();
213 let mut weight = String::new();
214 let mut stretch = String::new();
215 let mut line_height = LineHeight::Normal;
216
217 let tokens = split_by_spaces(value);
218 let mut idx = 0;
219 while idx < tokens.len() {
220 let token = tokens[idx].as_str();
221 idx += 1;
222
223 if token == "normal" {
224 continue;
225 }
226 if STYLE_KEYWORDS.contains(&token) {
227 if !style.is_empty() {
228 return Err(ParseError::DuplicateStyle);
229 }
230 token.clone_into(&mut style);
231 continue;
232 }
233 if WEIGHT_KEYWORDS.contains(&token) {
234 if !weight.is_empty() {
235 return Err(ParseError::DuplicateWeight);
236 }
237 token.clone_into(&mut weight);
238 continue;
239 }
240 if STRETCH_KEYWORDS.contains(&token) {
241 if !stretch.is_empty() {
242 return Err(ParseError::DuplicateStretch);
243 }
244 token.clone_into(&mut stretch);
245 continue;
246 }
247 if !is_size(token) {
248 variant = if variant.is_empty() {
250 token.to_owned()
251 } else {
252 format!("{variant} {token}")
253 };
254 continue;
255 }
256
257 let parts = split_on_slash(token);
259 let size = parts[0].clone();
260 if parts.len() > 1 && !parts[1].is_empty() {
261 line_height = parse_line_height(&parts[1]);
262 } else if tokens.get(idx).map(String::as_str) == Some("/") {
263 idx += 1; if let Some(lh) = tokens.get(idx) {
265 line_height = parse_line_height(lh);
266 idx += 1;
267 }
268 }
269
270 if idx >= tokens.len() {
271 return Err(ParseError::MissingFontFamily);
272 }
273 let family = split_by_commas(&tokens[idx..].join(" "))
274 .iter()
275 .map(|f| unquote(f))
276 .collect();
277
278 return Ok(Font::Shorthand(Shorthand {
279 style: or_normal(style),
280 variant: or_normal(variant),
281 weight: or_normal(weight),
282 stretch: or_normal(stretch),
283 size,
284 line_height,
285 family,
286 }));
287 }
288
289 Err(ParseError::MissingFontSize)
290}
291
292fn or_normal(value: String) -> String {
293 if value.is_empty() {
294 "normal".to_string()
295 } else {
296 value
297 }
298}
299
300fn system_font(value: &str) -> Option<SystemFont> {
302 SYSTEM_FONTS
303 .iter()
304 .find(|(kw, _)| *kw == value)
305 .map(|(_, font)| *font)
306}
307
308fn is_size(token: &str) -> bool {
311 token
312 .chars()
313 .next()
314 .is_some_and(|c| c.is_ascii_digit() || c == '.')
315 || token.contains('/')
316 || SIZE_KEYWORDS.contains(&token)
317}
318
319fn parse_line_height(value: &str) -> LineHeight {
323 if value == "normal" {
324 return LineHeight::Normal;
325 }
326 if value.starts_with(|c: char| c.is_ascii_digit() || c == '+' || c == '-' || c == '.') {
327 if let Ok(n) = value.parse::<f64>() {
328 if n.is_finite() && format!("{n}") == value {
329 return LineHeight::Number(n);
330 }
331 }
332 }
333 LineHeight::Other(value.to_owned())
334}
335
336fn unquote(value: &str) -> String {
340 let mut s = value;
341 if s.starts_with(['"', '\'']) {
342 s = &s[1..];
343 }
344 if s.ends_with(['"', '\'']) {
345 s = &s[..s.len() - 1];
346 }
347 s.to_owned()
348}
349
350fn split_by_spaces(s: &str) -> Vec<String> {
357 split(s, |c| c == ' ' || c == '\n' || c == '\t', false)
358}
359
360fn split_by_commas(s: &str) -> Vec<String> {
362 split(s, |c| c == ',', true)
363}
364
365fn split_on_slash(s: &str) -> Vec<String> {
367 split(s, |c| c == '/', false)
368}
369
370fn split(value: &str, is_sep: impl Fn(char) -> bool, last: bool) -> Vec<String> {
374 let mut parts = Vec::new();
375 let mut current = String::new();
376 let mut func: i32 = 0;
377 let mut quote: Option<char> = None;
378 let mut escape = false;
379
380 for c in value.chars() {
381 let mut split_here = false;
382 if let Some(q) = quote {
383 if escape {
384 escape = false;
385 } else if c == '\\' {
386 escape = true;
387 } else if c == q {
388 quote = None;
389 }
390 } else if c == '"' || c == '\'' {
391 quote = Some(c);
392 } else if c == '(' {
393 func += 1;
394 } else if c == ')' {
395 if func > 0 {
396 func -= 1;
397 }
398 } else if func == 0 && is_sep(c) {
399 split_here = true;
400 }
401
402 if split_here {
403 if !current.is_empty() {
404 parts.push(current.trim().to_owned());
405 }
406 current.clear();
407 } else {
408 current.push(c);
409 }
410 }
411 if last || !current.is_empty() {
412 parts.push(current.trim().to_owned());
413 }
414 parts
415}