Skip to main content

pdfboss_style/
parse.rs

1//! Strict CSS-subset parser: element-type selectors and the fixed
2//! property set from `style.rs`, driven directly off `cssparser` tokens
3//! so every error carries a 1-indexed source location.
4
5use std::fmt;
6
7use cssparser::{ParseError, ParseErrorKind, Parser, ParserInput, SourceLocation, Token};
8use pdfboss_write::Color;
9
10use crate::style::{Align, Declared, Decoration, Element, FontFamily, FontSize};
11
12/// A located parse failure. `line` and `column` are 1-indexed.
13#[derive(Clone, Debug, PartialEq)]
14pub struct StyleError {
15    /// Line the failing token starts on, counted from 1.
16    pub line: u32,
17    /// Column the failing token starts on, counted from 1.
18    pub column: u32,
19    /// What went wrong, naming the offending selector, property or value.
20    pub message: String,
21}
22
23impl fmt::Display for StyleError {
24    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
25        write!(f, "line {}:{}: {}", self.line, self.column, self.message)
26    }
27}
28
29impl std::error::Error for StyleError {}
30
31/// One `selector, selector { declarations }` rule.
32#[derive(Clone, Debug)]
33pub(crate) struct Rule {
34    pub elements: Vec<Element>,
35    pub declared: Declared,
36}
37
38type Failure<'i> = ParseError<'i, String>;
39
40/// Parses a stylesheet into its rules, in source order. Comments and
41/// whitespace are ignored; anything outside the supported selector,
42/// property, value and unit vocabulary is a located error.
43pub(crate) fn parse_sheet(css: &str) -> Result<Vec<Rule>, StyleError> {
44    let mut input = ParserInput::new(css);
45    let mut parser = Parser::new(&mut input);
46    let mut rules = Vec::new();
47    loop {
48        match next_rule(&mut parser) {
49            Ok(Some(rule)) => rules.push(rule),
50            Ok(None) => return Ok(rules),
51            Err(e) => return Err(style_error(e)),
52        }
53    }
54}
55
56fn style_error(e: Failure<'_>) -> StyleError {
57    let message = match e.kind {
58        ParseErrorKind::Custom(message) => message,
59        ParseErrorKind::Basic(basic) => basic.to_string(),
60    };
61    StyleError {
62        line: e.location.line + 1,
63        column: e.location.column,
64        message,
65    }
66}
67
68fn next_rule<'i>(parser: &mut Parser<'i, '_>) -> Result<Option<Rule>, Failure<'i>> {
69    let mut elements = Vec::new();
70    loop {
71        let location = parser.current_source_location();
72        let token = match parser.next() {
73            Ok(token) => token.clone(),
74            Err(_) => {
75                if elements.is_empty() {
76                    return Ok(None);
77                }
78                return Err(
79                    location.new_custom_error("selector list without a { } block".to_string())
80                );
81            }
82        };
83        match token {
84            Token::Ident(name) => {
85                let element = Element::from_name(&name.to_ascii_lowercase()).ok_or_else(|| {
86                    location.new_custom_error(format!(
87                        "unsupported selector {name:?}: only element type selectors are supported"
88                    ))
89                })?;
90                elements.push(element);
91            }
92            Token::Comma => {}
93            Token::CurlyBracketBlock => {
94                if elements.is_empty() {
95                    return Err(location.new_custom_error("rule has no selector".to_string()));
96                }
97                let declared = parser.parse_nested_block(|block| declarations(block))?;
98                return Ok(Some(Rule { elements, declared }));
99            }
100            other => {
101                return Err(location.new_custom_error(format!(
102                    "unsupported selector token {other:?}: only element type selectors are supported"
103                )));
104            }
105        }
106    }
107}
108
109fn declarations<'i>(parser: &mut Parser<'i, '_>) -> Result<Declared, Failure<'i>> {
110    let mut declared = Declared::default();
111    loop {
112        let location = parser.current_source_location();
113        let token = match parser.next() {
114            Ok(token) => token.clone(),
115            Err(_) => return Ok(declared),
116        };
117        let name = match token {
118            Token::Semicolon => continue,
119            Token::Ident(name) => name.to_ascii_lowercase(),
120            other => {
121                return Err(
122                    location.new_custom_error(format!("expected a property name, found {other:?}"))
123                )
124            }
125        };
126        parser.expect_colon()?;
127        declaration(parser, &name, &mut declared, location)?;
128    }
129}
130
131fn declaration<'i>(
132    parser: &mut Parser<'i, '_>,
133    name: &str,
134    declared: &mut Declared,
135    location: SourceLocation,
136) -> Result<(), Failure<'i>> {
137    match name {
138        "font-family" => declared.family = Some(font_family(parser)?),
139        "font-size" => declared.size = Some(font_size(parser)?),
140        "font-weight" => declared.bold = Some(font_weight(parser)?),
141        "font-style" => declared.italic = Some(font_style(parser)?),
142        "color" => declared.color = Some(color(parser)?),
143        "background-color" => declared.background = Some(color(parser)?),
144        "margin" => declared.margin = edges(parser)?,
145        "padding" => declared.padding = edges(parser)?,
146        "margin-top" => declared.margin[0] = Some(length(parser)?),
147        "margin-right" => declared.margin[1] = Some(length(parser)?),
148        "margin-bottom" => declared.margin[2] = Some(length(parser)?),
149        "margin-left" => declared.margin[3] = Some(length(parser)?),
150        "padding-top" => declared.padding[0] = Some(length(parser)?),
151        "padding-right" => declared.padding[1] = Some(length(parser)?),
152        "padding-bottom" => declared.padding[2] = Some(length(parser)?),
153        "padding-left" => declared.padding[3] = Some(length(parser)?),
154        "line-height" => declared.line_height = Some(line_height(parser)?),
155        "text-align" => declared.align = Some(text_align(parser)?),
156        "text-decoration" => declared.decoration = Some(text_decoration(parser)?),
157        other => return Err(location.new_custom_error(format!("unsupported property {other:?}"))),
158    }
159    finish(parser)
160}
161
162fn finish<'i>(parser: &mut Parser<'i, '_>) -> Result<(), Failure<'i>> {
163    let location = parser.current_source_location();
164    match parser.next() {
165        Err(_) => Ok(()),
166        Ok(&Token::Semicolon) => Ok(()),
167        Ok(other) => Err(location.new_custom_error(format!(
168            "unexpected {} after the value",
169            render_token(other)
170        ))),
171    }
172}
173
174/// Renders a token the way it appeared in the source, for use in error
175/// messages that must name the offending value.
176fn render_token(token: &Token) -> String {
177    match token {
178        Token::Ident(name) | Token::AtKeyword(name) => name.to_string(),
179        Token::Hash(name) | Token::IDHash(name) => format!("#{name}"),
180        Token::QuotedString(value) => format!("\"{value}\""),
181        Token::UnquotedUrl(value) => format!("url({value})"),
182        Token::Delim(delim) => delim.to_string(),
183        Token::Number { value, .. } => value.to_string(),
184        Token::Percentage { unit_value, .. } => format!("{}%", unit_value * 100.0),
185        Token::Dimension { value, unit, .. } => format!("{value}{unit}"),
186        Token::Function(name) => format!("{name}("),
187        Token::Colon => ":".to_string(),
188        Token::Semicolon => ";".to_string(),
189        Token::Comma => ",".to_string(),
190        other => format!("{other:?}"),
191    }
192}
193
194fn length<'i>(parser: &mut Parser<'i, '_>) -> Result<f32, Failure<'i>> {
195    let location = parser.current_source_location();
196    let token = parser.next()?.clone();
197    match &token {
198        Token::Dimension { value, unit, .. } if unit.eq_ignore_ascii_case("pt") => Ok(*value),
199        Token::Dimension { value, unit, .. } if unit.eq_ignore_ascii_case("mm") => {
200            Ok(72.0 / 25.4 * value)
201        }
202        Token::Dimension { value, unit, .. } if unit.eq_ignore_ascii_case("cm") => {
203            Ok(72.0 / 2.54 * value)
204        }
205        Token::Dimension { value, unit, .. } if unit.eq_ignore_ascii_case("in") => Ok(72.0 * value),
206        Token::Number { value, .. } if *value == 0.0 => Ok(0.0),
207        _ => Err(location.new_custom_error(format!(
208            "length takes pt, mm, cm or in, found {}",
209            render_token(&token)
210        ))),
211    }
212}
213
214fn font_size<'i>(parser: &mut Parser<'i, '_>) -> Result<FontSize, Failure<'i>> {
215    let location = parser.current_source_location();
216    let token = parser.next()?.clone();
217    match &token {
218        Token::Dimension { value, unit, .. } if unit.eq_ignore_ascii_case("pt") => {
219            Ok(FontSize::Pt(*value))
220        }
221        Token::Dimension { value, unit, .. } if unit.eq_ignore_ascii_case("em") => {
222            Ok(FontSize::Em(*value))
223        }
224        _ => Err(location.new_custom_error(format!(
225            "font-size takes pt or em, found {}",
226            render_token(&token)
227        ))),
228    }
229}
230
231fn edges<'i>(parser: &mut Parser<'i, '_>) -> Result<[Option<f32>; 4], Failure<'i>> {
232    let mut values: Vec<f32> = Vec::new();
233    while values.len() < 4 {
234        match parser.try_parse(length) {
235            Ok(value) => values.push(value),
236            Err(_) => break,
237        }
238    }
239    let edges = match values.as_slice() {
240        [a] => [*a, *a, *a, *a],
241        [v, h] => [*v, *h, *v, *h],
242        [t, h, b] => [*t, *h, *b, *h],
243        [t, r, b, l] => [*t, *r, *b, *l],
244        _ => return Err(shorthand_error(parser)),
245    };
246    Ok(edges.map(Some))
247}
248
249fn shorthand_error<'i>(parser: &mut Parser<'i, '_>) -> Failure<'i> {
250    let location = parser.current_source_location();
251    match parser.next() {
252        Ok(token) => location.new_custom_error(format!(
253            "margin/padding shorthand takes 1 to 4 lengths, found {}",
254            render_token(token)
255        )),
256        Err(_) => location.new_custom_error(
257            "margin/padding shorthand takes 1 to 4 lengths, found nothing".to_string(),
258        ),
259    }
260}
261
262fn color<'i>(parser: &mut Parser<'i, '_>) -> Result<Color, Failure<'i>> {
263    let location = parser.current_source_location();
264    let token = parser.next()?.clone();
265    match &token {
266        Token::Hash(hex) | Token::IDHash(hex) => hex_color(location, hex),
267        Token::Function(name) if name.eq_ignore_ascii_case("rgb") => {
268            parser.parse_nested_block(|block| rgb_components(block))
269        }
270        Token::Ident(name) => named_color(location, name),
271        _ => Err(location.new_custom_error(format!(
272            "color takes a hex value, rgb() or a named color, found {}",
273            render_token(&token)
274        ))),
275    }
276}
277
278fn hex_color<'i>(location: SourceLocation, hex: &str) -> Result<Color, Failure<'i>> {
279    let expanded = match hex.len() {
280        3 => hex.chars().flat_map(|digit| [digit, digit]).collect(),
281        6 => hex.to_string(),
282        _ => {
283            return Err(
284                location.new_custom_error(format!("hex colors take 3 or 6 digits, found #{hex}"))
285            )
286        }
287    };
288    let channel = |slice: &str| -> Result<f32, Failure<'i>> {
289        u8::from_str_radix(slice, 16)
290            .map(|byte| byte as f32 / 255.0)
291            .map_err(|_| location.new_custom_error(format!("invalid hex color, found #{hex}")))
292    };
293    Ok(Color::Rgb(
294        channel(&expanded[0..2])?,
295        channel(&expanded[2..4])?,
296        channel(&expanded[4..6])?,
297    ))
298}
299
300fn named_color<'i>(location: SourceLocation, name: &str) -> Result<Color, Failure<'i>> {
301    let rgb = match name.to_ascii_lowercase().as_str() {
302        "black" => (0, 0, 0),
303        "white" => (255, 255, 255),
304        "red" => (255, 0, 0),
305        "green" => (0, 128, 0),
306        "blue" => (0, 0, 255),
307        "navy" => (0, 0, 128),
308        "teal" => (0, 128, 128),
309        "purple" => (128, 0, 128),
310        "orange" => (255, 165, 0),
311        "yellow" => (255, 255, 0),
312        "gray" | "grey" => (128, 128, 128),
313        "silver" => (192, 192, 192),
314        "maroon" => (128, 0, 0),
315        "aqua" | "cyan" => (0, 255, 255),
316        "fuchsia" | "magenta" => (255, 0, 255),
317        "lime" => (0, 255, 0),
318        "olive" => (128, 128, 0),
319        _ => return Err(location.new_custom_error(format!("unknown color name {name}"))),
320    };
321    let (r, g, b) = rgb;
322    Ok(Color::Rgb(
323        r as f32 / 255.0,
324        g as f32 / 255.0,
325        b as f32 / 255.0,
326    ))
327}
328
329fn rgb_components<'i>(parser: &mut Parser<'i, '_>) -> Result<Color, Failure<'i>> {
330    let r = rgb_component(parser)?;
331    parser.try_parse(Parser::expect_comma).ok();
332    let g = rgb_component(parser)?;
333    parser.try_parse(Parser::expect_comma).ok();
334    let b = rgb_component(parser)?;
335    Ok(Color::Rgb(r, g, b))
336}
337
338fn rgb_component<'i>(parser: &mut Parser<'i, '_>) -> Result<f32, Failure<'i>> {
339    let location = parser.current_source_location();
340    let token = parser.next()?.clone();
341    match &token {
342        Token::Number { value, .. } if (0.0..=255.0).contains(value) => Ok(value / 255.0),
343        _ => Err(location.new_custom_error(format!(
344            "rgb() takes three numbers 0-255, found {}",
345            render_token(&token)
346        ))),
347    }
348}
349
350fn font_family<'i>(parser: &mut Parser<'i, '_>) -> Result<FontFamily, Failure<'i>> {
351    let location = parser.current_source_location();
352    let token = parser.next()?.clone();
353    let name = match &token {
354        Token::Ident(name) => name.to_ascii_lowercase(),
355        _ => {
356            return Err(location.new_custom_error(format!(
357                "font-family takes a family name, found {}",
358                render_token(&token)
359            )))
360        }
361    };
362    match name.as_str() {
363        "helvetica" | "sans-serif" => Ok(FontFamily::Helvetica),
364        "times" | "serif" => Ok(FontFamily::Times),
365        "courier" | "monospace" => Ok(FontFamily::Courier),
366        _ => Err(location.new_custom_error(format!(
367            "unknown font family {name:?}: helvetica, times and courier are available until font embedding lands"
368        ))),
369    }
370}
371
372fn font_weight<'i>(parser: &mut Parser<'i, '_>) -> Result<bool, Failure<'i>> {
373    let location = parser.current_source_location();
374    let token = parser.next()?.clone();
375    match &token {
376        Token::Ident(name) if name.eq_ignore_ascii_case("normal") => Ok(false),
377        Token::Ident(name) if name.eq_ignore_ascii_case("bold") => Ok(true),
378        Token::Number { value, .. } if *value == 400.0 => Ok(false),
379        Token::Number { value, .. } if *value == 700.0 => Ok(true),
380        _ => Err(location.new_custom_error(format!(
381            "font-weight takes normal, bold, 400 or 700, found {}",
382            render_token(&token)
383        ))),
384    }
385}
386
387fn font_style<'i>(parser: &mut Parser<'i, '_>) -> Result<bool, Failure<'i>> {
388    let location = parser.current_source_location();
389    let token = parser.next()?.clone();
390    match &token {
391        Token::Ident(name) if name.eq_ignore_ascii_case("normal") => Ok(false),
392        Token::Ident(name) if name.eq_ignore_ascii_case("italic") => Ok(true),
393        _ => Err(location.new_custom_error(format!(
394            "font-style takes normal or italic, found {}",
395            render_token(&token)
396        ))),
397    }
398}
399
400fn line_height<'i>(parser: &mut Parser<'i, '_>) -> Result<f32, Failure<'i>> {
401    let location = parser.current_source_location();
402    let token = parser.next()?.clone();
403    match &token {
404        Token::Number { value, .. } if *value > 0.0 => Ok(*value),
405        _ => Err(location.new_custom_error(format!(
406            "line-height takes a positive number, found {}",
407            render_token(&token)
408        ))),
409    }
410}
411
412fn text_align<'i>(parser: &mut Parser<'i, '_>) -> Result<Align, Failure<'i>> {
413    let location = parser.current_source_location();
414    let token = parser.next()?.clone();
415    match &token {
416        Token::Ident(name) if name.eq_ignore_ascii_case("left") => Ok(Align::Left),
417        Token::Ident(name) if name.eq_ignore_ascii_case("center") => Ok(Align::Center),
418        Token::Ident(name) if name.eq_ignore_ascii_case("right") => Ok(Align::Right),
419        Token::Ident(name) if name.eq_ignore_ascii_case("justify") => {
420            Err(location.new_custom_error("justify is not supported".to_string()))
421        }
422        _ => Err(location.new_custom_error(format!(
423            "text-align takes left, center or right, found {}",
424            render_token(&token)
425        ))),
426    }
427}
428
429fn text_decoration<'i>(parser: &mut Parser<'i, '_>) -> Result<Decoration, Failure<'i>> {
430    let location = parser.current_source_location();
431    let token = parser.next()?.clone();
432    match &token {
433        Token::Ident(name) if name.eq_ignore_ascii_case("none") => Ok(Decoration::None),
434        Token::Ident(name) if name.eq_ignore_ascii_case("underline") => Ok(Decoration::Underline),
435        Token::Ident(name) if name.eq_ignore_ascii_case("line-through") => {
436            Ok(Decoration::LineThrough)
437        }
438        _ => Err(location.new_custom_error(format!(
439            "text-decoration takes none, underline or line-through, found {}",
440            render_token(&token)
441        ))),
442    }
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    fn sheet(css: &str) -> Vec<Rule> {
450        parse_sheet(css).unwrap()
451    }
452
453    fn error(css: &str) -> StyleError {
454        parse_sheet(css).unwrap_err()
455    }
456
457    #[test]
458    fn parses_grouped_selectors_and_declarations() {
459        let rules = sheet("h1, h2 { font-size: 24pt; color: #c00; }");
460        assert_eq!(rules.len(), 1);
461        assert_eq!(rules[0].elements, vec![Element::H1, Element::H2]);
462        assert_eq!(rules[0].declared.size, Some(FontSize::Pt(24.0)));
463        assert_eq!(rules[0].declared.color, Some(Color::Rgb(0.8, 0.0, 0.0)));
464    }
465
466    #[test]
467    fn parses_units_and_shorthand() {
468        let rules = sheet("body { margin: 1in 2cm; font-size: 1.5em; line-height: 1.2; }");
469        let d = &rules[0].declared;
470        assert_eq!(
471            d.margin,
472            [
473                Some(72.0),
474                Some(72.0 / 2.54 * 2.0),
475                Some(72.0),
476                Some(72.0 / 2.54 * 2.0)
477            ]
478        );
479        assert_eq!(d.size, Some(FontSize::Em(1.5)));
480        assert_eq!(d.line_height, Some(1.2));
481    }
482
483    #[test]
484    fn parses_colors() {
485        let rules = sheet("p { color: rgb(255, 128, 0); background-color: navy; }");
486        let d = &rules[0].declared;
487        assert_eq!(d.color, Some(Color::Rgb(1.0, 128.0 / 255.0, 0.0)));
488        assert_eq!(d.background, Some(Color::Rgb(0.0, 0.0, 128.0 / 255.0)));
489    }
490
491    #[test]
492    fn parses_keyword_properties() {
493        let rules = sheet(
494            "a { font-family: monospace; font-weight: 700; font-style: italic; \
495             text-align: center; text-decoration: underline; }",
496        );
497        let d = &rules[0].declared;
498        assert_eq!(d.family, Some(FontFamily::Courier));
499        assert_eq!(d.bold, Some(true));
500        assert_eq!(d.italic, Some(true));
501        assert_eq!(d.align, Some(Align::Center));
502        assert_eq!(d.decoration, Some(Decoration::Underline));
503    }
504
505    #[test]
506    fn rejects_unknown_selector_property_value_and_unit() {
507        assert!(error(".card { color: #000; }").message.contains("selector"));
508        assert!(error("p { display: flex; }").message.contains("display"));
509        assert!(error("p { text-align: justify; }")
510            .message
511            .contains("justify"));
512        assert!(error("p { font-size: 12px; }").message.contains("px"));
513    }
514
515    #[test]
516    fn errors_carry_the_location() {
517        let e = error("p { color: #000; }\nh1 { volume: 11; }");
518        assert_eq!(e.line, 2);
519        assert!(e.message.contains("volume"));
520    }
521
522    #[test]
523    fn comments_and_whitespace_are_ignored() {
524        let rules = sheet("/* heading */\nh1 { /* big */ font-size: 20pt; }");
525        assert_eq!(rules[0].declared.size, Some(FontSize::Pt(20.0)));
526    }
527
528    #[test]
529    fn selectors_match_case_insensitively() {
530        let rules = sheet("H1, P { font-size: 20pt; }");
531        assert_eq!(rules[0].elements, vec![Element::H1, Element::P]);
532    }
533
534    #[test]
535    fn rejects_rgb_components_outside_0_255() {
536        let e = error("p { color: rgb(500, -10, 0); }");
537        assert!(e.message.contains("500"));
538    }
539}