Skip to main content

ferroui_pretext_contracts/
pretext_font_descriptor.rs

1/// The CSS-like font subset accepted by text preparation backends.
2#[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    /// Parses the source's permissive CSS subset; absent pixel sizes use defaults.
37    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}