Skip to main content

pdfboss_style/
style.rs

1//! Element-type selectors and the concrete text/box styles they resolve
2//! to: the vocabulary later tasks parse a CSS subset into and cascade
3//! through a theme.
4
5use pdfboss_write::{Color, Standard14};
6
7/// The twenty element types a theme rule can select on.
8#[derive(Clone, Copy, Debug, PartialEq, Eq)]
9pub enum Element {
10    /// The document body, the root of the cascade.
11    Body,
12    /// `<h1>` heading.
13    H1,
14    /// `<h2>` heading.
15    H2,
16    /// `<h3>` heading.
17    H3,
18    /// `<h4>` heading.
19    H4,
20    /// `<h5>` heading.
21    H5,
22    /// `<h6>` heading.
23    H6,
24    /// Paragraph.
25    P,
26    /// Inline code span.
27    Code,
28    /// Preformatted code block.
29    Pre,
30    /// Blockquote.
31    Blockquote,
32    /// Unordered list.
33    Ul,
34    /// Ordered list.
35    Ol,
36    /// List item.
37    Li,
38    /// Table.
39    Table,
40    /// Table header cell.
41    Th,
42    /// Table data cell.
43    Td,
44    /// Link.
45    A,
46    /// Struck-through text.
47    Del,
48    /// Horizontal rule.
49    Hr,
50}
51
52impl Element {
53    /// All twenty variants, in declaration order, so `element as usize`
54    /// indexes a `[Declared; 20]` keyed by this order.
55    pub const ALL: [Element; 20] = [
56        Element::Body,
57        Element::H1,
58        Element::H2,
59        Element::H3,
60        Element::H4,
61        Element::H5,
62        Element::H6,
63        Element::P,
64        Element::Code,
65        Element::Pre,
66        Element::Blockquote,
67        Element::Ul,
68        Element::Ol,
69        Element::Li,
70        Element::Table,
71        Element::Th,
72        Element::Td,
73        Element::A,
74        Element::Del,
75        Element::Hr,
76    ];
77
78    /// The lowercase selector spelling, e.g. `"blockquote"`.
79    pub fn name(self) -> &'static str {
80        match self {
81            Element::Body => "body",
82            Element::H1 => "h1",
83            Element::H2 => "h2",
84            Element::H3 => "h3",
85            Element::H4 => "h4",
86            Element::H5 => "h5",
87            Element::H6 => "h6",
88            Element::P => "p",
89            Element::Code => "code",
90            Element::Pre => "pre",
91            Element::Blockquote => "blockquote",
92            Element::Ul => "ul",
93            Element::Ol => "ol",
94            Element::Li => "li",
95            Element::Table => "table",
96            Element::Th => "th",
97            Element::Td => "td",
98            Element::A => "a",
99            Element::Del => "del",
100            Element::Hr => "hr",
101        }
102    }
103
104    /// Parses a selector spelling back to the element, `None` for anything
105    /// outside the twenty supported selectors.
106    pub fn from_name(name: &str) -> Option<Element> {
107        Element::ALL
108            .into_iter()
109            .find(|element| element.name() == name)
110    }
111}
112
113/// A font family resolved to one of the three Standard-14 type families.
114#[derive(Clone, Copy, Debug, PartialEq, Eq)]
115pub enum FontFamily {
116    /// Helvetica (sans-serif).
117    Helvetica,
118    /// Times (serif).
119    Times,
120    /// Courier (monospace).
121    Courier,
122}
123
124/// Horizontal text alignment.
125#[derive(Clone, Copy, Debug, PartialEq)]
126pub enum Align {
127    /// Flush left.
128    Left,
129    /// Centered.
130    Center,
131    /// Flush right.
132    Right,
133}
134
135/// Text decoration.
136#[derive(Clone, Copy, Debug, PartialEq)]
137pub enum Decoration {
138    /// No decoration.
139    None,
140    /// Underlined.
141    Underline,
142    /// Struck through.
143    LineThrough,
144}
145
146/// A font size, either absolute or relative to the inherited size.
147#[derive(Clone, Copy, Debug, PartialEq)]
148pub enum FontSize {
149    /// An absolute size in points.
150    Pt(f32),
151    /// A multiple of the inherited size.
152    Em(f32),
153}
154
155/// The four sides of a box edge, in declaration order top/right/bottom/left.
156#[derive(Clone, Copy, Debug, Default, PartialEq)]
157pub struct Edges {
158    /// Top edge.
159    pub top: f32,
160    /// Right edge.
161    pub right: f32,
162    /// Bottom edge.
163    pub bottom: f32,
164    /// Left edge.
165    pub left: f32,
166}
167
168/// One theme rule's declared properties, every field optional so a rule
169/// only overrides what it sets. Margin and padding arrays are ordered
170/// top, right, bottom, left.
171#[derive(Clone, Debug, Default, PartialEq)]
172pub struct Declared {
173    /// Font family.
174    pub family: Option<FontFamily>,
175    /// Font size.
176    pub size: Option<FontSize>,
177    /// Bold weight.
178    pub bold: Option<bool>,
179    /// Italic style.
180    pub italic: Option<bool>,
181    /// Text color.
182    pub color: Option<Color>,
183    /// Background color.
184    pub background: Option<Color>,
185    /// Margin, top/right/bottom/left.
186    pub margin: [Option<f32>; 4],
187    /// Padding, top/right/bottom/left.
188    pub padding: [Option<f32>; 4],
189    /// Line height, as a multiple of font size.
190    pub line_height: Option<f32>,
191    /// Text alignment.
192    pub align: Option<Align>,
193    /// Text decoration.
194    pub decoration: Option<Decoration>,
195}
196
197impl Declared {
198    /// Overlays `other`'s set fields onto `self`, leaving fields `other`
199    /// leaves unset untouched. Used to fold cascade rules together in
200    /// specificity order, most specific last.
201    pub fn merge(&mut self, other: &Declared) {
202        if other.family.is_some() {
203            self.family = other.family;
204        }
205        if other.size.is_some() {
206            self.size = other.size;
207        }
208        if other.bold.is_some() {
209            self.bold = other.bold;
210        }
211        if other.italic.is_some() {
212            self.italic = other.italic;
213        }
214        if other.color.is_some() {
215            self.color = other.color;
216        }
217        if other.background.is_some() {
218            self.background = other.background;
219        }
220        for side in 0..4 {
221            if other.margin[side].is_some() {
222                self.margin[side] = other.margin[side];
223            }
224            if other.padding[side].is_some() {
225                self.padding[side] = other.padding[side];
226            }
227        }
228        if other.line_height.is_some() {
229            self.line_height = other.line_height;
230        }
231        if other.align.is_some() {
232            self.align = other.align;
233        }
234        if other.decoration.is_some() {
235            self.decoration = other.decoration;
236        }
237    }
238}
239
240/// A fully resolved text style: every field concrete, no inheritance left
241/// to chase.
242#[derive(Clone, Copy, Debug, PartialEq)]
243pub struct TextStyle {
244    /// Font family.
245    pub family: FontFamily,
246    /// Font size in points.
247    pub size: f32,
248    /// Bold weight.
249    pub bold: bool,
250    /// Italic style.
251    pub italic: bool,
252    /// Text color.
253    pub color: Color,
254    /// Line height, as a multiple of font size.
255    pub line_height: f32,
256    /// Text alignment.
257    pub align: Align,
258    /// Text decoration.
259    pub decoration: Decoration,
260}
261
262impl TextStyle {
263    /// The hard fallback beneath the default theme: 11pt black Helvetica,
264    /// left-aligned, no decoration.
265    pub fn base() -> TextStyle {
266        TextStyle {
267            family: FontFamily::Helvetica,
268            size: 11.0,
269            bold: false,
270            italic: false,
271            color: Color::BLACK,
272            line_height: 1.4,
273            align: Align::Left,
274            decoration: Decoration::None,
275        }
276    }
277
278    /// Overlays a rule's declared fields onto this style, resolving `Em`
279    /// sizes against the inherited size and leaving unset fields
280    /// inherited unchanged.
281    pub fn apply(&self, d: &Declared) -> TextStyle {
282        TextStyle {
283            family: d.family.unwrap_or(self.family),
284            size: match d.size {
285                Some(FontSize::Pt(pt)) => pt,
286                Some(FontSize::Em(em)) => em * self.size,
287                None => self.size,
288            },
289            bold: d.bold.unwrap_or(self.bold),
290            italic: d.italic.unwrap_or(self.italic),
291            color: d.color.unwrap_or(self.color),
292            line_height: d.line_height.unwrap_or(self.line_height),
293            align: d.align.unwrap_or(self.align),
294            decoration: d.decoration.unwrap_or(self.decoration),
295        }
296    }
297
298    /// The Standard-14 face this style resolves to, by family, weight and
299    /// slant.
300    pub fn font(&self) -> Standard14 {
301        match (self.family, self.bold, self.italic) {
302            (FontFamily::Helvetica, false, false) => Standard14::Helvetica,
303            (FontFamily::Helvetica, true, false) => Standard14::HelveticaBold,
304            (FontFamily::Helvetica, false, true) => Standard14::HelveticaOblique,
305            (FontFamily::Helvetica, true, true) => Standard14::HelveticaBoldOblique,
306            (FontFamily::Times, false, false) => Standard14::TimesRoman,
307            (FontFamily::Times, true, false) => Standard14::TimesBold,
308            (FontFamily::Times, false, true) => Standard14::TimesItalic,
309            (FontFamily::Times, true, true) => Standard14::TimesBoldItalic,
310            (FontFamily::Courier, false, false) => Standard14::Courier,
311            (FontFamily::Courier, true, false) => Standard14::CourierBold,
312            (FontFamily::Courier, false, true) => Standard14::CourierOblique,
313            (FontFamily::Courier, true, true) => Standard14::CourierBoldOblique,
314        }
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321
322    #[test]
323    fn element_from_name_maps_every_selector() {
324        assert_eq!(Element::from_name("h1"), Some(Element::H1));
325        assert_eq!(Element::from_name("blockquote"), Some(Element::Blockquote));
326        assert_eq!(Element::from_name("div"), None);
327        for element in Element::ALL {
328            assert!(Element::from_name(element.name()).is_some());
329        }
330    }
331
332    #[test]
333    fn font_resolution_covers_all_twelve_faces() {
334        let style = TextStyle {
335            family: FontFamily::Times,
336            bold: true,
337            italic: true,
338            ..TextStyle::base()
339        };
340        assert_eq!(style.font(), Standard14::TimesBoldItalic);
341        let style = TextStyle {
342            family: FontFamily::Courier,
343            ..TextStyle::base()
344        };
345        assert_eq!(style.font(), Standard14::Courier);
346    }
347
348    #[test]
349    fn apply_overlays_only_declared_fields() {
350        let declared = Declared {
351            size: Some(FontSize::Em(2.0)),
352            bold: Some(true),
353            ..Declared::default()
354        };
355        let applied = TextStyle::base().apply(&declared);
356        assert_eq!(applied.size, 22.0);
357        assert!(applied.bold);
358        assert_eq!(applied.family, FontFamily::Helvetica);
359    }
360
361    #[test]
362    fn merge_keeps_earlier_fields_the_later_rule_leaves_unset() {
363        let mut first = Declared {
364            bold: Some(true),
365            ..Declared::default()
366        };
367        let second = Declared {
368            italic: Some(true),
369            ..Declared::default()
370        };
371        first.merge(&second);
372        assert_eq!(first.bold, Some(true));
373        assert_eq!(first.italic, Some(true));
374    }
375}