#![no_std]
#![doc(html_root_url = "https://docs.rs/parse-css-font/0.1.0")]
extern crate alloc;
use alloc::borrow::ToOwned;
use alloc::format;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use core::fmt;
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;
const SYSTEM_FONTS: [(&str, SystemFont); 6] = [
("caption", SystemFont::Caption),
("icon", SystemFont::Icon),
("menu", SystemFont::Menu),
("message-box", SystemFont::MessageBox),
("small-caption", SystemFont::SmallCaption),
("status-bar", SystemFont::StatusBar),
];
const STYLE_KEYWORDS: [&str; 3] = ["normal", "italic", "oblique"];
const WEIGHT_KEYWORDS: [&str; 13] = [
"normal", "bold", "bolder", "lighter", "100", "200", "300", "400", "500", "600", "700", "800",
"900",
];
const STRETCH_KEYWORDS: [&str; 9] = [
"normal",
"condensed",
"semi-condensed",
"extra-condensed",
"ultra-condensed",
"expanded",
"semi-expanded",
"extra-expanded",
"ultra-expanded",
];
const SIZE_KEYWORDS: [&str; 9] = [
"xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large", "larger", "smaller",
];
#[derive(Debug, Clone, PartialEq)]
pub enum Font {
System(SystemFont),
Shorthand(Shorthand),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SystemFont {
Caption,
Icon,
Menu,
MessageBox,
SmallCaption,
StatusBar,
}
impl SystemFont {
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
SystemFont::Caption => "caption",
SystemFont::Icon => "icon",
SystemFont::Menu => "menu",
SystemFont::MessageBox => "message-box",
SystemFont::SmallCaption => "small-caption",
SystemFont::StatusBar => "status-bar",
}
}
}
impl fmt::Display for SystemFont {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Shorthand {
pub style: String,
pub variant: String,
pub weight: String,
pub stretch: String,
pub size: String,
pub line_height: LineHeight,
pub family: Vec<String>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum LineHeight {
Normal,
Number(f64),
Other(String),
}
impl fmt::Display for LineHeight {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LineHeight::Normal => f.write_str("normal"),
LineHeight::Number(n) => write!(f, "{n}"),
LineHeight::Other(s) => f.write_str(s),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParseError {
EmptyString,
MissingFontSize,
MissingFontFamily,
DuplicateStyle,
DuplicateWeight,
DuplicateStretch,
}
impl fmt::Display for ParseError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
ParseError::EmptyString => "cannot parse an empty string",
ParseError::MissingFontSize => "missing required font-size",
ParseError::MissingFontFamily => "missing required font-family",
ParseError::DuplicateStyle => "font-style already defined",
ParseError::DuplicateWeight => "font-weight already defined",
ParseError::DuplicateStretch => "font-stretch already defined",
})
}
}
impl core::error::Error for ParseError {}
pub fn parse(value: &str) -> Result<Font, ParseError> {
if value.is_empty() {
return Err(ParseError::EmptyString);
}
if let Some(system) = system_font(value) {
return Ok(Font::System(system));
}
let mut style = String::new();
let mut variant = String::new();
let mut weight = String::new();
let mut stretch = String::new();
let mut line_height = LineHeight::Normal;
let tokens = split_by_spaces(value);
let mut idx = 0;
while idx < tokens.len() {
let token = tokens[idx].as_str();
idx += 1;
if token == "normal" {
continue;
}
if STYLE_KEYWORDS.contains(&token) {
if !style.is_empty() {
return Err(ParseError::DuplicateStyle);
}
token.clone_into(&mut style);
continue;
}
if WEIGHT_KEYWORDS.contains(&token) {
if !weight.is_empty() {
return Err(ParseError::DuplicateWeight);
}
token.clone_into(&mut weight);
continue;
}
if STRETCH_KEYWORDS.contains(&token) {
if !stretch.is_empty() {
return Err(ParseError::DuplicateStretch);
}
token.clone_into(&mut stretch);
continue;
}
if !is_size(token) {
variant = if variant.is_empty() {
token.to_owned()
} else {
format!("{variant} {token}")
};
continue;
}
let parts = split_on_slash(token);
let size = parts[0].clone();
if parts.len() > 1 && !parts[1].is_empty() {
line_height = parse_line_height(&parts[1]);
} else if tokens.get(idx).map(String::as_str) == Some("/") {
idx += 1; if let Some(lh) = tokens.get(idx) {
line_height = parse_line_height(lh);
idx += 1;
}
}
if idx >= tokens.len() {
return Err(ParseError::MissingFontFamily);
}
let family = split_by_commas(&tokens[idx..].join(" "))
.iter()
.map(|f| unquote(f))
.collect();
return Ok(Font::Shorthand(Shorthand {
style: or_normal(style),
variant: or_normal(variant),
weight: or_normal(weight),
stretch: or_normal(stretch),
size,
line_height,
family,
}));
}
Err(ParseError::MissingFontSize)
}
fn or_normal(value: String) -> String {
if value.is_empty() {
"normal".to_string()
} else {
value
}
}
fn system_font(value: &str) -> Option<SystemFont> {
SYSTEM_FONTS
.iter()
.find(|(kw, _)| *kw == value)
.map(|(_, font)| *font)
}
fn is_size(token: &str) -> bool {
token
.chars()
.next()
.is_some_and(|c| c.is_ascii_digit() || c == '.')
|| token.contains('/')
|| SIZE_KEYWORDS.contains(&token)
}
fn parse_line_height(value: &str) -> LineHeight {
if value == "normal" {
return LineHeight::Normal;
}
if value.starts_with(|c: char| c.is_ascii_digit() || c == '+' || c == '-' || c == '.') {
if let Ok(n) = value.parse::<f64>() {
if n.is_finite() && format!("{n}") == value {
return LineHeight::Number(n);
}
}
}
LineHeight::Other(value.to_owned())
}
fn unquote(value: &str) -> String {
let mut s = value;
if s.starts_with(['"', '\'']) {
s = &s[1..];
}
if s.ends_with(['"', '\'']) {
s = &s[..s.len() - 1];
}
s.to_owned()
}
fn split_by_spaces(s: &str) -> Vec<String> {
split(s, |c| c == ' ' || c == '\n' || c == '\t', false)
}
fn split_by_commas(s: &str) -> Vec<String> {
split(s, |c| c == ',', true)
}
fn split_on_slash(s: &str) -> Vec<String> {
split(s, |c| c == '/', false)
}
fn split(value: &str, is_sep: impl Fn(char) -> bool, last: bool) -> Vec<String> {
let mut parts = Vec::new();
let mut current = String::new();
let mut func: i32 = 0;
let mut quote: Option<char> = None;
let mut escape = false;
for c in value.chars() {
let mut split_here = false;
if let Some(q) = quote {
if escape {
escape = false;
} else if c == '\\' {
escape = true;
} else if c == q {
quote = None;
}
} else if c == '"' || c == '\'' {
quote = Some(c);
} else if c == '(' {
func += 1;
} else if c == ')' {
if func > 0 {
func -= 1;
}
} else if func == 0 && is_sep(c) {
split_here = true;
}
if split_here {
if !current.is_empty() {
parts.push(current.trim().to_owned());
}
current.clear();
} else {
current.push(c);
}
}
if last || !current.is_empty() {
parts.push(current.trim().to_owned());
}
parts
}