Skip to main content

parse_css_font/
lib.rs

1//! # parse-css-font — parse the CSS `font` shorthand
2//!
3//! Split a CSS [`font`](https://developer.mozilla.org/en-US/docs/Web/CSS/font)
4//! shorthand value into its parts — style, variant, weight, stretch, size,
5//! line-height and the font-family list — or recognise a system-font keyword. A
6//! faithful Rust port of the [`parse-css-font`](https://www.npmjs.com/package/parse-css-font)
7//! npm package (handy for canvas text, PDF generation, and layout tooling). Zero
8//! dependencies and `#![no_std]`.
9//!
10//! ```
11//! use parse_css_font::{parse, Font, LineHeight};
12//!
13//! let Font::Shorthand(f) = parse("italic bold 12px/1.5 Arial, sans-serif").unwrap() else {
14//!     panic!("expected a shorthand");
15//! };
16//! assert_eq!(f.style, "italic");
17//! assert_eq!(f.weight, "bold");
18//! assert_eq!(f.size, "12px");
19//! assert_eq!(f.line_height, LineHeight::Number(1.5));
20//! assert_eq!(f.family, ["Arial", "sans-serif"]);
21//!
22//! // System fonts are returned as-is.
23//! assert!(matches!(parse("caption"), Ok(Font::System(_))));
24//! ```
25
26#![no_std]
27#![doc(html_root_url = "https://docs.rs/parse-css-font/0.1.0")]
28
29extern crate alloc;
30
31use alloc::borrow::ToOwned;
32use alloc::format;
33use alloc::string::{String, ToString};
34use alloc::vec::Vec;
35use core::fmt;
36
37// Compile-test the README's examples as part of `cargo test`.
38#[cfg(doctest)]
39#[doc = include_str!("../README.md")]
40struct ReadmeDoctests;
41
42const SYSTEM_FONTS: [(&str, SystemFont); 6] = [
43    ("caption", SystemFont::Caption),
44    ("icon", SystemFont::Icon),
45    ("menu", SystemFont::Menu),
46    ("message-box", SystemFont::MessageBox),
47    ("small-caption", SystemFont::SmallCaption),
48    ("status-bar", SystemFont::StatusBar),
49];
50
51const STYLE_KEYWORDS: [&str; 3] = ["normal", "italic", "oblique"];
52const WEIGHT_KEYWORDS: [&str; 13] = [
53    "normal", "bold", "bolder", "lighter", "100", "200", "300", "400", "500", "600", "700", "800",
54    "900",
55];
56const STRETCH_KEYWORDS: [&str; 9] = [
57    "normal",
58    "condensed",
59    "semi-condensed",
60    "extra-condensed",
61    "ultra-condensed",
62    "expanded",
63    "semi-expanded",
64    "extra-expanded",
65    "ultra-expanded",
66];
67const SIZE_KEYWORDS: [&str; 9] = [
68    "xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large", "larger", "smaller",
69];
70
71/// A parsed CSS `font` value: either a system-font keyword or the shorthand parts.
72#[derive(Debug, Clone, PartialEq)]
73pub enum Font {
74    /// A system-font keyword such as `caption` or `menu`.
75    System(SystemFont),
76    /// The parsed `font` shorthand components.
77    Shorthand(Shorthand),
78}
79
80/// One of the six CSS system-font keywords.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
82pub enum SystemFont {
83    /// `caption`
84    Caption,
85    /// `icon`
86    Icon,
87    /// `menu`
88    Menu,
89    /// `message-box`
90    MessageBox,
91    /// `small-caption`
92    SmallCaption,
93    /// `status-bar`
94    StatusBar,
95}
96
97impl SystemFont {
98    /// The keyword as it appears in CSS.
99    #[must_use]
100    pub const fn as_str(self) -> &'static str {
101        match self {
102            SystemFont::Caption => "caption",
103            SystemFont::Icon => "icon",
104            SystemFont::Menu => "menu",
105            SystemFont::MessageBox => "message-box",
106            SystemFont::SmallCaption => "small-caption",
107            SystemFont::StatusBar => "status-bar",
108        }
109    }
110}
111
112impl fmt::Display for SystemFont {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        f.write_str(self.as_str())
115    }
116}
117
118/// The components of a parsed `font` shorthand. Each property defaults to `"normal"`
119/// when not specified, matching the CSS `font` shorthand.
120#[derive(Debug, Clone, PartialEq)]
121pub struct Shorthand {
122    /// `font-style`, e.g. `"normal"`, `"italic"`, `"oblique"`.
123    pub style: String,
124    /// `font-variant`, e.g. `"normal"`, `"small-caps"`.
125    pub variant: String,
126    /// `font-weight`, e.g. `"normal"`, `"bold"`, `"400"`.
127    pub weight: String,
128    /// `font-stretch`, e.g. `"normal"`, `"condensed"`.
129    pub stretch: String,
130    /// `font-size`, e.g. `"12px"`, `"1em"`, `"x-large"`.
131    pub size: String,
132    /// `line-height`.
133    pub line_height: LineHeight,
134    /// The `font-family` list, in order, with surrounding quotes removed.
135    pub family: Vec<String>,
136}
137
138/// A `line-height` value.
139#[derive(Debug, Clone, PartialEq)]
140pub enum LineHeight {
141    /// `normal` (the default).
142    Normal,
143    /// A unitless multiplier, e.g. `1.5`.
144    Number(f64),
145    /// A value with a unit or other form, e.g. `"14px"`.
146    Other(String),
147}
148
149impl fmt::Display for LineHeight {
150    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151        match self {
152            LineHeight::Normal => f.write_str("normal"),
153            LineHeight::Number(n) => write!(f, "{n}"),
154            LineHeight::Other(s) => f.write_str(s),
155        }
156    }
157}
158
159/// An error returned when a `font` value cannot be parsed.
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum ParseError {
162    /// The input was an empty string.
163    EmptyString,
164    /// No `font-size` was found.
165    MissingFontSize,
166    /// No `font-family` was found.
167    MissingFontFamily,
168    /// `font-style` was specified more than once.
169    DuplicateStyle,
170    /// `font-weight` was specified more than once.
171    DuplicateWeight,
172    /// `font-stretch` was specified more than once.
173    DuplicateStretch,
174}
175
176impl fmt::Display for ParseError {
177    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
178        f.write_str(match self {
179            ParseError::EmptyString => "cannot parse an empty string",
180            ParseError::MissingFontSize => "missing required font-size",
181            ParseError::MissingFontFamily => "missing required font-family",
182            ParseError::DuplicateStyle => "font-style already defined",
183            ParseError::DuplicateWeight => "font-weight already defined",
184            ParseError::DuplicateStretch => "font-stretch already defined",
185        })
186    }
187}
188
189impl core::error::Error for ParseError {}
190
191/// Parse a CSS `font` shorthand value.
192///
193/// # Errors
194///
195/// Returns [`ParseError`] for an empty string, a value missing the required
196/// `font-size` or `font-family`, or a property specified more than once.
197///
198/// ```
199/// use parse_css_font::{parse, Font};
200/// assert!(matches!(parse("12px serif"), Ok(Font::Shorthand(_))));
201/// assert!(parse("12px").is_err()); // no family
202/// ```
203pub fn parse(value: &str) -> Result<Font, ParseError> {
204    if value.is_empty() {
205        return Err(ParseError::EmptyString);
206    }
207    if let Some(system) = system_font(value) {
208        return Ok(Font::System(system));
209    }
210
211    let mut style = String::new();
212    let mut variant = String::new();
213    let mut weight = String::new();
214    let mut stretch = String::new();
215    let mut line_height = LineHeight::Normal;
216
217    let tokens = split_by_spaces(value);
218    let mut idx = 0;
219    while idx < tokens.len() {
220        let token = tokens[idx].as_str();
221        idx += 1;
222
223        if token == "normal" {
224            continue;
225        }
226        if STYLE_KEYWORDS.contains(&token) {
227            if !style.is_empty() {
228                return Err(ParseError::DuplicateStyle);
229            }
230            token.clone_into(&mut style);
231            continue;
232        }
233        if WEIGHT_KEYWORDS.contains(&token) {
234            if !weight.is_empty() {
235                return Err(ParseError::DuplicateWeight);
236            }
237            token.clone_into(&mut weight);
238            continue;
239        }
240        if STRETCH_KEYWORDS.contains(&token) {
241            if !stretch.is_empty() {
242                return Err(ParseError::DuplicateStretch);
243            }
244            token.clone_into(&mut stretch);
245            continue;
246        }
247        if !is_size(token) {
248            // The variant slot is the catch-all for any remaining non-size token.
249            variant = if variant.is_empty() {
250                token.to_owned()
251            } else {
252                format!("{variant} {token}")
253            };
254            continue;
255        }
256
257        // This token is the font-size (optionally `size/line-height`).
258        let parts = split_on_slash(token);
259        let size = parts[0].clone();
260        if parts.len() > 1 && !parts[1].is_empty() {
261            line_height = parse_line_height(&parts[1]);
262        } else if tokens.get(idx).map(String::as_str) == Some("/") {
263            idx += 1; // consume the standalone "/"
264            if let Some(lh) = tokens.get(idx) {
265                line_height = parse_line_height(lh);
266                idx += 1;
267            }
268        }
269
270        if idx >= tokens.len() {
271            return Err(ParseError::MissingFontFamily);
272        }
273        let family = split_by_commas(&tokens[idx..].join(" "))
274            .iter()
275            .map(|f| unquote(f))
276            .collect();
277
278        return Ok(Font::Shorthand(Shorthand {
279            style: or_normal(style),
280            variant: or_normal(variant),
281            weight: or_normal(weight),
282            stretch: or_normal(stretch),
283            size,
284            line_height,
285            family,
286        }));
287    }
288
289    Err(ParseError::MissingFontSize)
290}
291
292fn or_normal(value: String) -> String {
293    if value.is_empty() {
294        "normal".to_string()
295    } else {
296        value
297    }
298}
299
300/// Match the whole `value` against a system-font keyword (case-sensitive).
301fn system_font(value: &str) -> Option<SystemFont> {
302    SYSTEM_FONTS
303        .iter()
304        .find(|(kw, _)| *kw == value)
305        .map(|(_, font)| *font)
306}
307
308/// Whether a token is a font-size: it starts with a digit or `.`, contains `/`, or
309/// is a font-size keyword (mirrors css-font's `isSize`).
310fn is_size(token: &str) -> bool {
311    token
312        .chars()
313        .next()
314        .is_some_and(|c| c.is_ascii_digit() || c == '.')
315        || token.contains('/')
316        || SIZE_KEYWORDS.contains(&token)
317}
318
319/// Classify a line-height value the way `parse-css-font` does: a bare number becomes
320/// [`LineHeight::Number`]; `normal` is [`LineHeight::Normal`]; anything else is
321/// [`LineHeight::Other`].
322fn parse_line_height(value: &str) -> LineHeight {
323    if value == "normal" {
324        return LineHeight::Normal;
325    }
326    if value.starts_with(|c: char| c.is_ascii_digit() || c == '+' || c == '-' || c == '.') {
327        if let Ok(n) = value.parse::<f64>() {
328            if n.is_finite() && format!("{n}") == value {
329                return LineHeight::Number(n);
330            }
331        }
332    }
333    LineHeight::Other(value.to_owned())
334}
335
336/// Strip one leading and one trailing quote character (either `"` or `'`, and they
337/// need not match), like the `unquote` npm package. Nothing is trimmed — the split
338/// helpers already trim each part.
339fn unquote(value: &str) -> String {
340    let mut s = value;
341    if s.starts_with(['"', '\'']) {
342        s = &s[1..];
343    }
344    if s.ends_with(['"', '\'']) {
345        s = &s[..s.len() - 1];
346    }
347    s.to_owned()
348}
349
350// ---------------------------------------------------------------------------
351// Splitting helpers — a faithful port of css-list-helpers `split`: nests on `()`
352// only, honors quoted strings with `\` escapes, and trims each emitted part.
353// ---------------------------------------------------------------------------
354
355/// Split on top-level whitespace (` `, `\n`, `\t`), dropping empty parts.
356fn split_by_spaces(s: &str) -> Vec<String> {
357    split(s, |c| c == ' ' || c == '\n' || c == '\t', false)
358}
359
360/// Split on top-level commas; the final (possibly empty) part is always kept.
361fn split_by_commas(s: &str) -> Vec<String> {
362    split(s, |c| c == ',', true)
363}
364
365/// Split a single size token on `/`.
366fn split_on_slash(s: &str) -> Vec<String> {
367    split(s, |c| c == '/', false)
368}
369
370/// Split `value` on `is_sep` at the top level: separators inside `(…)` or a quoted
371/// string (honoring `\` escapes) do not split. Each emitted part is trimmed; when
372/// `last` is set, the final part is emitted even if empty.
373fn split(value: &str, is_sep: impl Fn(char) -> bool, last: bool) -> Vec<String> {
374    let mut parts = Vec::new();
375    let mut current = String::new();
376    let mut func: i32 = 0;
377    let mut quote: Option<char> = None;
378    let mut escape = false;
379
380    for c in value.chars() {
381        let mut split_here = false;
382        if let Some(q) = quote {
383            if escape {
384                escape = false;
385            } else if c == '\\' {
386                escape = true;
387            } else if c == q {
388                quote = None;
389            }
390        } else if c == '"' || c == '\'' {
391            quote = Some(c);
392        } else if c == '(' {
393            func += 1;
394        } else if c == ')' {
395            if func > 0 {
396                func -= 1;
397            }
398        } else if func == 0 && is_sep(c) {
399            split_here = true;
400        }
401
402        if split_here {
403            if !current.is_empty() {
404                parts.push(current.trim().to_owned());
405            }
406            current.clear();
407        } else {
408            current.push(c);
409        }
410    }
411    if last || !current.is_empty() {
412        parts.push(current.trim().to_owned());
413    }
414    parts
415}