lightweight_pdf_core/
theme.rs1use 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#[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
108pub(crate) fn apply_theme(element: &mut Element, theme: &Theme) {
118 match element {
119 Element::Text(text) => {
120 if let Some(role) = text.role {
121 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 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}