Skip to main content

lightweight_pdf_core/
theme.rs

1//! Named style roles (`Document::theme(..)`), resolved exactly once —
2//! when an element is added via `Document::add()`, walking the whole
3//! subtree that was just built. Not a cascade: no parent-to-child
4//! inheritance, no re-evaluation at layout/render time, resolution reads
5//! from exactly one source (the `Theme` on the `Document` the element is
6//! being added to).
7//!
8//! `Text::role` is what makes an element theme-eligible: `Text::new()`
9//! defaults it to `Some(ThemeRole::Body)`, and every style-mutating
10//! builder method (`.size()`, `.bold()`, `.color()`, ...) clears it back
11//! to `None` — the caller just took over styling manually, so the theme
12//! must leave it alone. The `.heading1()`/`.heading2()`/`.heading3()`
13//! presets (and the new `.caption()`/`.muted()`/`.table_header()` ones)
14//! re-set a specific role *after* their own style-mutating calls, which
15//! is why they stay theme-eligible despite calling `.size()`/`.bold()`
16//! internally.
17//!
18//! A `Document` with no `.theme(..)` call resolves nothing — `apply_theme`
19//! is only ever invoked when `self.theme` is `Some`, so unthemed output is
20//! byte-for-byte what it was before `Theme` existed.
21
22use crate::element::Element;
23use crate::style::{Color, FontKey, TextStyle};
24
25#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize), serde(rename_all = "snake_case"))]
26#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
27#[derive(Clone, Copy, PartialEq, Eq, Debug)]
28pub enum ThemeRole {
29    Body,
30    Caption,
31    Heading1,
32    Heading2,
33    Heading3,
34    TableHeader,
35    Muted,
36}
37
38/// Named `TextStyle` roles. `Theme::default()` reproduces exactly the
39/// hardcoded values `TextStyle::default()`/`.heading1()`/`.heading2()`/
40/// `.heading3()` already used before `Theme` existed (`caption`/
41/// `table_header`/`muted` are new roles with no prior hardcoded
42/// equivalent, so they can't break existing output either way).
43#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize), serde(deny_unknown_fields))]
44#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
45#[derive(Clone, Debug, PartialEq)]
46pub struct Theme {
47    pub body: TextStyle,
48    pub caption: TextStyle,
49    pub heading1: TextStyle,
50    pub heading2: TextStyle,
51    pub heading3: TextStyle,
52    pub table_header: TextStyle,
53    pub muted: TextStyle,
54}
55
56impl Theme {
57    pub fn role(&self, role: ThemeRole) -> TextStyle {
58        match role {
59            ThemeRole::Body => self.body,
60            ThemeRole::Caption => self.caption,
61            ThemeRole::Heading1 => self.heading1,
62            ThemeRole::Heading2 => self.heading2,
63            ThemeRole::Heading3 => self.heading3,
64            ThemeRole::TableHeader => self.table_header,
65            ThemeRole::Muted => self.muted,
66        }
67    }
68}
69
70impl Default for Theme {
71    fn default() -> Self {
72        let body = TextStyle::default();
73        let muted_color = Color::rgb(0x66, 0x66, 0x66);
74        Theme {
75            body,
76            caption: TextStyle {
77                size: 9.0,
78                color: muted_color,
79                ..body
80            },
81            heading1: TextStyle {
82                size: 24.0,
83                font: FontKey::SANS_BOLD,
84                ..body
85            },
86            heading2: TextStyle {
87                size: 18.0,
88                font: FontKey::SANS_BOLD,
89                ..body
90            },
91            heading3: TextStyle {
92                size: 14.0,
93                font: FontKey::SANS_BOLD,
94                ..body
95            },
96            table_header: TextStyle {
97                font: FontKey::SANS_BOLD,
98                ..body
99            },
100            muted: TextStyle {
101                color: muted_color,
102                ..body
103            },
104        }
105    }
106}
107
108/// Recursively resolves every theme-eligible `Text::role` in `element`
109/// (and, for containers, its whole subtree — already fully built by the
110/// time `Document::add()` receives it, so one pass is enough) to its
111/// `theme` role's style. Table header cells that are still at the
112/// default `Body` role (i.e. never explicitly re-tagged) are upgraded to
113/// `TableHeader` first — plain `&str` header cells (`Table::header(["A",
114/// "B"])`) have no other way to signal "this is a header," and "look
115/// like a table header automatically" is the whole point of the role
116/// existing.
117pub(crate) fn apply_theme(element: &mut Element, theme: &Theme) {
118    match element {
119        Element::Text(text) => {
120            if let Some(role) = text.role {
121                // `align` is always independently whatever `.align()` set
122                // (or the `TextStyle::default()`/preset value if never
123                // called) — not part of role resolution, see `Text::align`.
124                let align = text.style.align;
125                text.style = TextStyle { align, ..theme.role(role) };
126            }
127        }
128        Element::Row(row) => {
129            for child in &mut row.children {
130                apply_theme(child, theme);
131            }
132        }
133        Element::Column(col) => {
134            for child in &mut col.children {
135                apply_theme(child, theme);
136            }
137        }
138        Element::Table(table) => {
139            if let Some(header) = &mut table.header {
140                for cell in header {
141                    if let Element::Text(text) = &mut cell.element {
142                        if text.role == Some(ThemeRole::Body) {
143                            text.role = Some(ThemeRole::TableHeader);
144                        }
145                    }
146                    apply_theme(&mut cell.element, theme);
147                }
148            }
149            for row in &mut table.rows {
150                for cell in row {
151                    apply_theme(&mut cell.element, theme);
152                }
153            }
154        }
155        Element::List(list) => {
156            for item in &mut list.items {
157                apply_theme(&mut item.content, theme);
158            }
159        }
160        Element::Spacer(_) | Element::Line(_) | Element::Rect(_) | Element::Image(_) | Element::TableOfContents(_) | Element::PageBreak => {
161        }
162    }
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168    use crate::document::{Document, PageFormat};
169    use crate::element::Text;
170    use crate::table::{Table, TableColumn};
171
172    fn test_theme() -> Theme {
173        let mut theme = Theme::default();
174        theme.body.size = 30.0;
175        theme.heading1.size = 99.0;
176        theme.table_header.size = 55.0;
177        theme
178    }
179
180    #[test]
181    fn plain_text_resolves_from_the_theme() {
182        let mut doc = Document::new(PageFormat::A4).theme(test_theme());
183        doc.add(Text::new("hello"));
184        let Element::Text(t) = &doc.children[0] else {
185            panic!("expected Text")
186        };
187        assert_eq!(t.style.size, 30.0, "untouched Text should pick up theme.body");
188    }
189
190    #[test]
191    fn explicitly_styled_text_is_not_overridden() {
192        let mut doc = Document::new(PageFormat::A4).theme(test_theme());
193        doc.add(Text::new("hello").size(12.5));
194        let Element::Text(t) = &doc.children[0] else {
195            panic!("expected Text")
196        };
197        assert_eq!(t.style.size, 12.5, "explicit .size() must survive theming");
198    }
199
200    #[test]
201    fn heading_preset_resolves_from_its_own_theme_role() {
202        let mut doc = Document::new(PageFormat::A4).theme(test_theme());
203        doc.add(Text::new("Chapter").heading1());
204        let Element::Text(t) = &doc.children[0] else {
205            panic!("expected Text")
206        };
207        assert_eq!(t.style.size, 99.0, ".heading1() should pick up theme.heading1, not theme.body");
208    }
209
210    #[test]
211    fn no_theme_leaves_text_at_its_own_defaults() {
212        let mut doc = Document::new(PageFormat::A4);
213        doc.add(Text::new("hello"));
214        let Element::Text(t) = &doc.children[0] else {
215            panic!("expected Text")
216        };
217        assert_eq!(t.style, TextStyle::default(), "no .theme(..) call must mean unchanged output");
218    }
219
220    #[test]
221    fn align_after_a_preset_survives_theming_without_losing_the_role() {
222        // A common real pattern (see examples/report.rs): centering a
223        // heading. `.align()` must neither lose the Heading1 role nor
224        // have its own value clobbered by the theme's role resolution.
225        let mut doc = Document::new(PageFormat::A4).theme(test_theme());
226        doc.add(Text::new("Chapter").heading1().align(crate::style::Align::Center));
227        let Element::Text(t) = &doc.children[0] else {
228            panic!("expected Text")
229        };
230        assert_eq!(t.style.size, 99.0, "still themed as Heading1 despite the trailing .align() call");
231        assert_eq!(
232            t.style.align,
233            crate::style::Align::Center,
234            ".align() must not be overwritten by the theme's role"
235        );
236    }
237
238    #[test]
239    fn plain_string_table_header_cells_theme_as_table_header_automatically() {
240        let mut doc = Document::new(PageFormat::A4).theme(test_theme());
241        doc.add(Table::new().columns([TableColumn::fixed(50.0)]).header(["Spalte"]));
242        let Element::Table(table) = &doc.children[0] else {
243            panic!("expected Table")
244        };
245        let header = table.header.as_ref().unwrap();
246        let Element::Text(t) = &header[0].element else {
247            panic!("expected Text")
248        };
249        assert_eq!(
250            t.style.size, 55.0,
251            "a plain-string table header cell should theme as TableHeader, not Body"
252        );
253    }
254}