Skip to main content

pdfboss_style/
theme.rs

1//! Theme: cascade of CSS rules into concrete styles by element type.
2
3use pdfboss_write::Color;
4
5use crate::parse::{parse_sheet, StyleError};
6use crate::style::{Declared, Edges, Element, TextStyle};
7
8pub(crate) const DEFAULT_CSS: &str = "\
9body { font-family: helvetica; font-size: 11pt; line-height: 1.4; color: #000; margin: 72pt; text-align: left; }\n\
10h1 { font-size: 2em; font-weight: bold; margin-top: 18pt; margin-bottom: 9pt; }\n\
11h2 { font-size: 1.6em; font-weight: bold; margin-top: 14pt; margin-bottom: 7pt; }\n\
12h3 { font-size: 1.3em; font-weight: bold; margin-top: 12pt; margin-bottom: 6pt; }\n\
13h4 { font-size: 1.15em; font-weight: bold; margin-top: 11pt; margin-bottom: 6pt; }\n\
14h5 { font-size: 1em; font-weight: bold; margin-top: 11pt; margin-bottom: 6pt; }\n\
15h6 { font-size: 0.9em; font-weight: bold; margin-top: 11pt; margin-bottom: 6pt; }\n\
16p { margin-bottom: 8pt; }\n\
17code { font-family: courier; background-color: #f0f0f0; }\n\
18pre { font-family: courier; font-size: 0.9em; background-color: #f0f0f0; margin-top: 8pt; margin-bottom: 8pt; padding: 8pt; }\n\
19blockquote { margin-left: 24pt; margin-top: 8pt; margin-bottom: 8pt; color: #555; font-style: italic; }\n\
20ul { margin-top: 4pt; margin-bottom: 8pt; }\n\
21ol { margin-top: 4pt; margin-bottom: 8pt; }\n\
22li { margin-bottom: 2pt; }\n\
23table { margin-top: 8pt; margin-bottom: 8pt; }\n\
24th { font-weight: bold; background-color: #e8e8e8; padding: 4pt; }\n\
25td { padding: 4pt; }\n\
26a { color: #0645ad; text-decoration: underline; }\n\
27del { text-decoration: line-through; }\n\
28hr { margin-top: 12pt; margin-bottom: 12pt; color: #999; }\n";
29
30/// A theme: cascade of CSS rules resolved into concrete styles by element.
31#[derive(Debug)]
32pub struct Theme {
33    decls: Vec<Declared>,
34}
35
36impl Theme {
37    /// The default theme: built-in CSS styles.
38    pub fn default_theme() -> Theme {
39        let rules = parse_sheet(DEFAULT_CSS).expect("the built-in default theme parses");
40        Theme::from_rules(Theme::empty(), rules)
41    }
42
43    /// Parse a user stylesheet and overlay it onto the default theme.
44    /// Later rules override earlier rules for the same element.
45    pub fn parse(css: &str) -> Result<Theme, StyleError> {
46        Ok(Theme::from_rules(Theme::default_theme(), parse_sheet(css)?))
47    }
48
49    /// Create an empty theme with no declarations.
50    fn empty() -> Theme {
51        Theme {
52            decls: vec![Declared::default(); Element::ALL.len()],
53        }
54    }
55
56    /// Fold rules into a theme, with later rules overriding earlier ones.
57    fn from_rules(mut theme: Theme, rules: Vec<crate::parse::Rule>) -> Theme {
58        for rule in rules {
59            for element in &rule.elements {
60                theme.decls[*element as usize].merge(&rule.declared);
61            }
62        }
63        theme
64    }
65
66    /// The declared properties for an element, as merged from all matching
67    /// rules.
68    pub fn declared(&self, e: Element) -> &Declared {
69        &self.decls[e as usize]
70    }
71
72    /// The base text style for the theme: hard fallback overlaid by body
73    /// element declarations.
74    pub fn base(&self) -> TextStyle {
75        TextStyle::base().apply(self.declared(Element::Body))
76    }
77
78    /// The margin edges for an element. Unset sides default to 0.0.
79    pub fn margin(&self, e: Element) -> Edges {
80        let d = self.declared(e);
81        Edges {
82            top: d.margin[0].unwrap_or(0.0),
83            right: d.margin[1].unwrap_or(0.0),
84            bottom: d.margin[2].unwrap_or(0.0),
85            left: d.margin[3].unwrap_or(0.0),
86        }
87    }
88
89    /// The padding edges for an element. Unset sides default to 0.0.
90    pub fn padding(&self, e: Element) -> Edges {
91        let d = self.declared(e);
92        Edges {
93            top: d.padding[0].unwrap_or(0.0),
94            right: d.padding[1].unwrap_or(0.0),
95            bottom: d.padding[2].unwrap_or(0.0),
96            left: d.padding[3].unwrap_or(0.0),
97        }
98    }
99
100    /// The background color for an element, if set.
101    pub fn background(&self, e: Element) -> Option<Color> {
102        self.declared(e).background
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109
110    #[test]
111    fn default_theme_parses_and_covers_body() {
112        let theme = Theme::default_theme();
113        let base = theme.base();
114        assert_eq!(base.size, 11.0);
115        assert_eq!(base.family, crate::style::FontFamily::Helvetica);
116        assert_eq!(theme.margin(Element::Body).left, 72.0);
117    }
118
119    #[test]
120    fn user_theme_overlays_defaults() {
121        let theme = Theme::parse("h1 { color: #c00; }").unwrap();
122        let h1 = theme.base().apply(theme.declared(Element::H1));
123        assert_eq!(h1.color, Color::Rgb(0.8, 0.0, 0.0));
124        assert!(
125            h1.bold,
126            "default h1 bold survives an overlay that only sets color"
127        );
128        assert_eq!(h1.size, 22.0, "default h1 2em of body 11pt survives");
129    }
130
131    #[test]
132    fn later_rule_wins() {
133        let theme = Theme::parse("p { color: #111; }\np { color: #222; }").unwrap();
134        let p = theme.base().apply(theme.declared(Element::P));
135        let expected = 0x22 as f32 / 255.0;
136        assert_eq!(p.color, Color::Rgb(expected, expected, expected));
137    }
138
139    #[test]
140    fn parse_error_location_passes_through() {
141        let e = Theme::parse("h1 { font-size: 12px; }").unwrap_err();
142        assert_eq!(e.line, 1);
143    }
144}