Skip to main content

svgtypes/
font.rs

1// Copyright 2024 the SVG Types Authors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4use crate::Error;
5use crate::stream::{ByteExt, Stream};
6use alloc::format;
7use alloc::string::{String, ToString};
8use alloc::vec;
9use alloc::vec::Vec;
10use core::fmt::Display;
11
12/// Parses a list of font families and generic families from a string.
13pub fn parse_font_families(text: &str) -> Result<Vec<FontFamily>, Error> {
14    let mut s = Stream::from(text);
15    let font_families = s.parse_font_families()?;
16
17    s.skip_spaces();
18    if !s.at_end() {
19        return Err(Error::UnexpectedData(s.calc_char_pos()));
20    }
21
22    Ok(font_families)
23}
24
25/// A type of font family.
26#[derive(Clone, PartialEq, Eq, Debug, Hash)]
27pub enum FontFamily {
28    /// A serif font.
29    Serif,
30    /// A sans-serif font.
31    SansSerif,
32    /// A cursive font.
33    Cursive,
34    /// A fantasy font.
35    Fantasy,
36    /// A monospace font.
37    Monospace,
38    /// A custom named font.
39    Named(String),
40}
41
42impl Display for FontFamily {
43    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
44        let str = match self {
45            Self::Monospace => "monospace".to_string(),
46            Self::Serif => "serif".to_string(),
47            Self::SansSerif => "sans-serif".to_string(),
48            Self::Cursive => "cursive".to_string(),
49            Self::Fantasy => "fantasy".to_string(),
50            Self::Named(s) => format!("\"{s}\""),
51        };
52        write!(f, "{str}")
53    }
54}
55
56impl Stream<'_> {
57    pub fn parse_font_families(&mut self) -> Result<Vec<FontFamily>, Error> {
58        let mut families = vec![];
59
60        while !self.at_end() {
61            self.skip_spaces();
62
63            let family = {
64                let ch = self.curr_byte()?;
65                if ch == b'\'' || ch == b'\"' {
66                    let res = self.parse_quoted_string()?;
67                    FontFamily::Named(res.to_string())
68                } else {
69                    let mut idents = vec![];
70
71                    while let Some(c) = self.chars().next() {
72                        if c != ',' {
73                            idents.push(self.parse_ident()?.to_string());
74                            self.skip_spaces();
75                        } else {
76                            break;
77                        }
78                    }
79
80                    let joined = idents.join(" ");
81
82                    // TODO: No CSS keyword must be matched as a family name...
83                    match joined.as_str() {
84                        "serif" => FontFamily::Serif,
85                        "sans-serif" => FontFamily::SansSerif,
86                        "cursive" => FontFamily::Cursive,
87                        "fantasy" => FontFamily::Fantasy,
88                        "monospace" => FontFamily::Monospace,
89                        _ => FontFamily::Named(joined),
90                    }
91                }
92            };
93
94            families.push(family);
95
96            if let Ok(b) = self.curr_byte() {
97                if b == b',' {
98                    self.advance(1);
99                } else {
100                    break;
101                }
102            }
103        }
104
105        let families = families
106            .into_iter()
107            .filter(|f| match f {
108                FontFamily::Named(s) => !s.is_empty(),
109                _ => true,
110            })
111            .collect();
112
113        Ok(families)
114    }
115}
116
117/// The values of a [`font` shorthand](https://www.w3.org/TR/css-fonts-3/#font-prop).
118#[derive(Clone, PartialEq, Eq, Debug, Hash)]
119pub struct FontShorthand<'a> {
120    /// The font style.
121    pub font_style: Option<&'a str>,
122    /// The font variant.
123    pub font_variant: Option<&'a str>,
124    /// The font weight.
125    pub font_weight: Option<&'a str>,
126    /// The font stretch.
127    pub font_stretch: Option<&'a str>,
128    /// The font size.
129    pub font_size: &'a str,
130    /// The font family.
131    pub font_family: &'a str,
132}
133
134impl<'a> FontShorthand<'a> {
135    /// Parses the `font` shorthand from a string.
136    ///
137    /// We can't use the `FromStr` trait because it requires
138    /// an owned value as a return type.
139    ///
140    /// [font]: https://www.w3.org/TR/css-fonts-3/#font-prop
141    #[allow(clippy::should_implement_trait)] // We aren't changing public API yet.
142    pub fn from_str(text: &'a str) -> Result<Self, Error> {
143        let mut stream = Stream::from(text);
144        stream.skip_spaces();
145
146        let mut prev_pos = stream.pos();
147
148        let mut font_style = None;
149        let mut font_variant = None;
150        let mut font_weight = None;
151        let mut font_stretch = None;
152
153        for _ in 0..4 {
154            let ident = stream.consume_ascii_ident();
155
156            match ident {
157                // TODO: Reuse actual parsers to prevent duplication.
158                // We ignore normal because it's ambiguous to which it belongs and all
159                // other attributes need to be reset anyway.
160                "normal" => {}
161                "small-caps" => font_variant = Some(ident),
162                "italic" | "oblique" => font_style = Some(ident),
163                "bold" | "bolder" | "lighter" | "100" | "200" | "300" | "400" | "500" | "600"
164                | "700" | "800" | "900" => font_weight = Some(ident),
165                "ultra-condensed" | "extra-condensed" | "condensed" | "semi-condensed"
166                | "semi-expanded" | "expanded" | "extra-expanded" | "ultra-expanded" => {
167                    font_stretch = Some(ident);
168                }
169                _ => {
170                    // Not one of the 4 properties, so we backtrack and then start
171                    // passing font size and family.
172                    stream = Stream::from(text);
173                    stream.advance(prev_pos);
174                    break;
175                }
176            }
177
178            stream.skip_spaces();
179            prev_pos = stream.pos();
180        }
181
182        prev_pos = stream.pos();
183        if stream.curr_byte()?.is_digit() {
184            // A font size such as '15pt'.
185            let _ = stream.parse_length()?;
186        } else {
187            // A font size like 'xx-large'.
188            let size = stream.consume_ascii_ident();
189
190            if !matches!(
191                size,
192                "xx-small"
193                    | "x-small"
194                    | "small"
195                    | "medium"
196                    | "large"
197                    | "x-large"
198                    | "xx-large"
199                    | "larger"
200                    | "smaller"
201            ) {
202                return Err(Error::UnexpectedData(prev_pos));
203            }
204        }
205
206        let font_size = stream.slice_back(prev_pos);
207        stream.skip_spaces();
208
209        if stream.curr_byte()? == b'/' {
210            // We should ignore line height since it has no effect in SVG.
211            stream.advance(1);
212            stream.skip_spaces();
213            let _ = stream.parse_length()?;
214            stream.skip_spaces();
215        }
216
217        if stream.at_end() {
218            return Err(Error::UnexpectedEndOfStream);
219        }
220
221        let font_family = stream.slice_tail();
222
223        Ok(Self {
224            font_style,
225            font_variant,
226            font_weight,
227            font_stretch,
228            font_size,
229            font_family,
230        })
231    }
232}
233
234#[rustfmt::skip]
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    macro_rules! font_family {
240        ($name:ident, $text:expr, $result:expr) => (
241            #[test]
242            fn $name() {
243                assert_eq!(parse_font_families($text).unwrap(), $result);
244            }
245        )
246    }
247
248    macro_rules! named {
249        ($text:expr) => (
250            FontFamily::Named($text.to_string())
251        )
252    }
253
254    const SERIF: FontFamily = FontFamily::Serif;
255    const SANS_SERIF: FontFamily = FontFamily::SansSerif;
256    const FANTASY: FontFamily = FontFamily::Fantasy;
257    const MONOSPACE: FontFamily = FontFamily::Monospace;
258    const CURSIVE: FontFamily = FontFamily::Cursive;
259
260    font_family!(font_family_1, "Times New Roman", vec![named!("Times New Roman")]);
261    font_family!(font_family_2, "serif", vec![SERIF]);
262    font_family!(font_family_3, "sans-serif", vec![SANS_SERIF]);
263    font_family!(font_family_4, "cursive", vec![CURSIVE]);
264    font_family!(font_family_5, "fantasy", vec![FANTASY]);
265    font_family!(font_family_6, "monospace", vec![MONOSPACE]);
266    font_family!(font_family_7, "'Times New Roman'", vec![named!("Times New Roman")]);
267    font_family!(font_family_8, "'Times New Roman', sans-serif", vec![named!("Times New Roman"), SANS_SERIF]);
268    font_family!(font_family_9, "'Times New Roman', sans-serif", vec![named!("Times New Roman"), SANS_SERIF]);
269    font_family!(font_family_10, "Arial, sans-serif, 'fantasy'", vec![named!("Arial"), SANS_SERIF, named!("fantasy")]);
270    font_family!(font_family_11, "    Arial  , monospace  , 'fantasy'", vec![named!("Arial"), MONOSPACE, named!("fantasy")]);
271    font_family!(font_family_12, "Times    New Roman", vec![named!("Times New Roman")]);
272    font_family!(font_family_13, "\"Times New Roman\", sans-serif, sans-serif, \"Arial\"",
273        vec![named!("Times New Roman"), SANS_SERIF, SANS_SERIF, named!("Arial")]
274    );
275    font_family!(font_family_14, "Times New Roman,,,Arial", vec![named!("Times New Roman"), named!("Arial")]);
276    font_family!(font_family_15, "简体中文,sans-serif  , ,\"日本語フォント\",Arial",
277        vec![named!("简体中文"), SANS_SERIF, named!("日本語フォント"), named!("Arial")]);
278
279    font_family!(font_family_16, "", vec![]);
280
281    macro_rules! font_family_err {
282        ($name:ident, $text:expr, $result:expr) => (
283            #[test]
284            fn $name() {
285                assert_eq!(parse_font_families($text).unwrap_err().to_string(), $result);
286            }
287        )
288    }
289    font_family_err!(font_family_err_1, "Red/Black, sans-serif", "invalid ident");
290    font_family_err!(font_family_err_2, "\"Lucida\" Grande, sans-serif", "unexpected data at position 10");
291    font_family_err!(font_family_err_3, "Ahem!, sans-serif", "invalid ident");
292    font_family_err!(font_family_err_4, "test@foo, sans-serif", "invalid ident");
293    font_family_err!(font_family_err_5, "#POUND, sans-serif", "invalid ident");
294    font_family_err!(font_family_err_6, "Hawaii 5-0, sans-serif", "invalid ident");
295
296    impl<'a> FontShorthand<'a> {
297        fn new(font_style: Option<&'a str>, font_variant: Option<&'a str>, font_weight: Option<&'a str>,
298                   font_stretch: Option<&'a str>, font_size: &'a str, font_family: &'a str) -> Self {
299            Self {
300                font_style, font_variant, font_weight, font_stretch, font_size, font_family
301            }
302        }
303    }
304
305    macro_rules! font_shorthand {
306        ($name:ident, $text:expr, $result:expr) => (
307            #[test]
308            fn $name() {
309                assert_eq!(FontShorthand::from_str($text).unwrap(), $result);
310            }
311        )
312    }
313
314    font_shorthand!(font_shorthand_1, "12pt/14pt sans-serif",
315        FontShorthand::new(None, None, None, None, "12pt", "sans-serif"));
316    font_shorthand!(font_shorthand_2, "80% sans-serif",
317        FontShorthand::new(None, None, None, None, "80%", "sans-serif"));
318    font_shorthand!(font_shorthand_3, "bold italic large Palatino, serif",
319        FontShorthand::new(Some("italic"), None, Some("bold"), None, "large", "Palatino, serif"));
320    font_shorthand!(font_shorthand_4, "x-large/110% \"new century schoolbook\", serif",
321        FontShorthand::new(None, None, None, None, "x-large", "\"new century schoolbook\", serif"));
322    font_shorthand!(font_shorthand_5, "normal small-caps 120%/120% fantasy",
323        FontShorthand::new(None, Some("small-caps"), None, None, "120%", "fantasy"));
324    font_shorthand!(font_shorthand_6, "condensed oblique 12pt \"Helvetica Neue\", serif",
325        FontShorthand::new(Some("oblique"), None, None, Some("condensed"), "12pt", "\"Helvetica Neue\", serif"));
326    font_shorthand!(font_shorthand_7, "italic 500 2em sans-serif, 'Noto Sans'",
327        FontShorthand::new(Some("italic"), None, Some("500"), None, "2em", "sans-serif, 'Noto Sans'"));
328    font_shorthand!(font_shorthand_8, "xx-large 'Noto Sans'",
329        FontShorthand::new(None, None, None, None, "xx-large", "'Noto Sans'"));
330    font_shorthand!(font_shorthand_9, "small-caps normal normal italic xx-small Times",
331        FontShorthand::new(Some("italic"), Some("small-caps"), None, None, "xx-small", "Times"));
332
333
334    macro_rules! font_shorthand_err {
335        ($name:ident, $text:expr, $result:expr) => (
336            #[test]
337            fn $name() {
338                assert_eq!(FontShorthand::from_str($text).unwrap_err(), $result);
339            }
340        )
341    }
342
343    font_shorthand_err!(font_shorthand_err_1, "", Error::UnexpectedEndOfStream);
344    font_shorthand_err!(font_shorthand_err_2, "Noto Sans", Error::UnexpectedData(0));
345    font_shorthand_err!(font_shorthand_err_3, "12pt  ", Error::UnexpectedEndOfStream);
346    font_shorthand_err!(font_shorthand_err_4, "something 12pt 'Noto Sans'", Error::UnexpectedData(0));
347    font_shorthand_err!(font_shorthand_err_5, "'Noto Sans' 13pt", Error::UnexpectedData(0));
348    font_shorthand_err!(font_shorthand_err_6,
349        "small-caps normal normal normal italic xx-large Times", Error::UnexpectedData(32));
350}