svgtypes/
font.rs

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