| 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 | |