1 | // Copyright 2024 the SVG Types Authors |
2 | // SPDX-License-Identifier: Apache-2.0 OR MIT |
3 | |
4 | use crate::stream::{ByteExt, Stream}; |
5 | use crate::Error; |
6 | use std::fmt::Display; |
7 | |
8 | /// Parses a list of font families and generic families from a string. |
9 | pub fn parse_font_families(text: &str) -> Result<Vec<FontFamily>, Error> { |
10 | let mut s: Stream<'_> = Stream::from(text); |
11 | let font_families: Vec = 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)] |
23 | pub 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 | |
38 | impl Display for FontFamily { |
39 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
40 | let str: String = 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: &String) => format!(" \"{}\"" , s), |
47 | }; |
48 | write!(f, " {}" , str) |
49 | } |
50 | } |
51 | |
52 | impl 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)] |
115 | pub 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 | |
130 | impl<'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)] |
232 | mod 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 | } |
347 | |