ferroui_pretext_contracts/
pretext_font_descriptor.rs1#[derive(Clone, Debug, PartialEq)]
3pub struct PretextFontDescriptor {
4 pub size: f64,
5 pub primary_family: String,
6 pub weight: i32,
7 pub italic: bool,
8}
9
10impl Default for PretextFontDescriptor {
11 fn default() -> Self {
12 Self::new(16.0, "Arial", 400, false)
13 }
14}
15
16impl PretextFontDescriptor {
17 pub fn new(size: f64, primary_family: impl Into<String>, weight: i32, italic: bool) -> Self {
18 let primary_family = primary_family.into();
19 let primary_family = if primary_family.trim().is_empty() {
20 "Arial".into()
21 } else {
22 primary_family
23 };
24 Self {
25 size,
26 primary_family,
27 weight,
28 italic,
29 }
30 }
31}
32
33pub struct PretextFontParser;
34
35impl PretextFontParser {
36 pub fn parse(font: &str) -> PretextFontDescriptor {
38 let bytes = font.as_bytes();
39 let mut index = 0;
40 while index < bytes.len() {
41 if !bytes[index].is_ascii_digit() {
42 index += 1;
43 continue;
44 }
45 let start = index;
46 while index < bytes.len() && bytes[index].is_ascii_digit() {
47 index += 1;
48 }
49 if bytes.get(index) == Some(&b'.')
50 && bytes.get(index + 1).is_some_and(u8::is_ascii_digit)
51 {
52 index += 1;
53 while index < bytes.len() && bytes[index].is_ascii_digit() {
54 index += 1;
55 }
56 }
57 let number_end = index;
58 while let Some(ch) = font[index..].chars().next() {
59 if !ch.is_whitespace() {
60 break;
61 }
62 index += ch.len_utf8();
63 }
64 if bytes.get(index..index + 2) != Some(b"px") {
65 continue;
66 }
67 let Ok(size) = font[start..number_end].parse::<f64>() else {
68 return PretextFontDescriptor::default();
69 };
70 let mut family = font[index + 2..].trim();
71 if family.starts_with('/') {
72 family = family
73 .find(' ')
74 .map(|space| family[space + 1..].trim())
75 .unwrap_or("");
76 }
77 let mut weight = 400;
78 let mut italic = false;
79 for token in font[..start].split(' ').filter(|token| !token.is_empty()) {
80 if token.eq_ignore_ascii_case("italic") || token.eq_ignore_ascii_case("oblique") {
81 italic = true;
82 } else if token.eq_ignore_ascii_case("bold") {
83 weight = 700;
84 } else if let Ok(value) = token.trim().parse::<i32>() {
85 weight = value;
86 break;
87 }
88 }
89 let mut family = family.split(',').next().unwrap_or("").trim();
90 if family.len() >= 2
91 && ((family.starts_with('"') && family.ends_with('"'))
92 || (family.starts_with('\'') && family.ends_with('\'')))
93 {
94 family = family[1..family.len() - 1].trim();
95 }
96 return PretextFontDescriptor::new(size, family, weight, italic);
97 }
98 PretextFontDescriptor::default()
99 }
100
101 pub fn map_generic_family<'a>(
102 primary_family: &'a str,
103 sans_serif_fallback: &'a str,
104 serif_fallback: &'a str,
105 monospace_fallback: &'a str,
106 ) -> &'a str {
107 if primary_family.trim().is_empty()
108 || primary_family.eq_ignore_ascii_case("sans-serif")
109 || primary_family.eq_ignore_ascii_case("system-ui")
110 {
111 sans_serif_fallback
112 } else if primary_family.eq_ignore_ascii_case("serif") {
113 serif_fallback
114 } else if primary_family.eq_ignore_ascii_case("monospace")
115 || primary_family.eq_ignore_ascii_case("ui-monospace")
116 {
117 monospace_fallback
118 } else {
119 primary_family
120 }
121 }
122}
123
124#[cfg(test)]
125mod tests {
126 use super::*;
127
128 #[test]
129 fn parses_font_subset_and_generic_families() {
130 assert_eq!(
131 PretextFontParser::parse("italic bold 18.5px/1.4 \"IBM Plex Sans\", serif"),
132 PretextFontDescriptor::new(18.5, "IBM Plex Sans", 700, true)
133 );
134 assert_eq!(PretextFontParser::parse("700 18px Inter").weight, 700);
135 assert_eq!(
136 PretextFontParser::parse("missing size"),
137 PretextFontDescriptor::default()
138 );
139 assert_eq!(PretextFontParser::parse("").primary_family, "Arial");
140 assert_eq!(
141 PretextFontParser::map_generic_family("SYSTEM-UI", "sans", "serif", "mono"),
142 "sans"
143 );
144 assert_eq!(
145 PretextFontParser::map_generic_family("monospace", "sans", "serif", "mono"),
146 "mono"
147 );
148 assert_eq!(
149 PretextFontParser::map_generic_family("Inter", "sans", "serif", "mono"),
150 "Inter"
151 );
152 }
153
154 #[test]
155 fn preserves_source_numeric_weight_token_order() {
156 assert!(!PretextFontParser::parse("700 italic 16px Inter").italic);
157 assert!(PretextFontParser::parse("italic 700 16px Inter").italic);
158 }
159
160 #[test]
161 fn preserves_balanced_quotes_and_unicode_size_whitespace() {
162 assert_eq!(
163 PretextFontParser::parse("22\u{a0}px \" Inter \"").primary_family,
164 "Inter"
165 );
166 assert_eq!(PretextFontParser::parse("22\u{a0}px Inter").size, 22.0);
167 assert_eq!(
168 PretextFontParser::parse("16px \"Inter'").primary_family,
169 "\"Inter'"
170 );
171 assert_eq!(PretextFontParser::parse("700\t16px Inter").weight, 700);
172 assert_eq!(
173 PretextFontParser::map_generic_family("ui-monospace", "sans", "serif", "mono"),
174 "mono"
175 );
176 }
177}