1use std::collections::BTreeMap;
4use std::path::Path;
5
6use oxipdf_ir::node::{ContentVariant, NodeId};
7use oxipdf_ir::semantic::SemanticRole;
8use oxipdf_ir::style::ResolvedStyle;
9use oxipdf_ir::tree::StyledTreeBuilder;
10
11use crate::toml_loader::ThemeLoadError;
12
13#[derive(Debug, Clone)]
21pub struct Theme {
22 name: String,
24 styles: BTreeMap<SemanticRole, ResolvedStyle>,
26 base_font_families: Vec<String>,
28 mono_font_families: Vec<String>,
30}
31
32impl Theme {
33 #[must_use]
35 pub fn new(name: impl Into<String>) -> Self {
36 Self {
37 name: name.into(),
38 styles: BTreeMap::new(),
39 base_font_families: Vec::new(),
40 mono_font_families: Vec::new(),
41 }
42 }
43
44 #[must_use]
46 pub fn name(&self) -> &str {
47 &self.name
48 }
49
50 pub fn set_base_fonts(&mut self, families: Vec<String>) {
52 self.base_font_families = families;
53 }
54
55 pub fn set_mono_fonts(&mut self, families: Vec<String>) {
57 self.mono_font_families = families;
58 }
59
60 #[must_use]
62 pub fn base_fonts(&self) -> &[String] {
63 &self.base_font_families
64 }
65
66 #[must_use]
68 pub fn mono_fonts(&self) -> &[String] {
69 &self.mono_font_families
70 }
71
72 pub fn set_style(&mut self, role: SemanticRole, style: ResolvedStyle) {
74 self.styles.insert(role, style);
75 }
76
77 #[must_use]
79 pub fn style_for(&self, role: SemanticRole) -> ResolvedStyle {
80 self.styles.get(&role).cloned().unwrap_or_default()
81 }
82
83 #[must_use]
85 pub fn has_style(&self, role: SemanticRole) -> bool {
86 self.styles.contains_key(&role)
87 }
88
89 #[must_use]
91 pub fn len(&self) -> usize {
92 self.styles.len()
93 }
94
95 #[must_use]
97 pub fn is_empty(&self) -> bool {
98 self.styles.is_empty()
99 }
100
101 pub fn iter(&self) -> impl Iterator<Item = (&SemanticRole, &ResolvedStyle)> {
103 self.styles.iter()
104 }
105
106 pub fn merge(&mut self, overlay: &Theme) {
112 for (role, style) in &overlay.styles {
113 self.styles.insert(*role, style.clone());
114 }
115 if !overlay.base_font_families.is_empty() {
116 self.base_font_families = overlay.base_font_families.clone();
117 }
118 if !overlay.mono_font_families.is_empty() {
119 self.mono_font_families = overlay.mono_font_families.clone();
120 }
121 }
122
123 #[must_use]
125 pub fn academic() -> Self {
126 crate::builtin::academic_theme()
127 }
128
129 #[must_use]
131 pub fn technical() -> Self {
132 crate::builtin::technical_theme()
133 }
134
135 pub fn load(path: impl AsRef<Path>) -> Result<Self, ThemeLoadError> {
140 crate::toml_loader::load_from_file(path)
141 }
142
143 pub fn from_toml(toml_str: &str) -> Result<Self, ThemeLoadError> {
148 crate::toml_loader::parse_toml(toml_str)
149 }
150}
151
152impl Default for Theme {
153 fn default() -> Self {
155 crate::builtin::default_theme()
156 }
157}
158
159pub struct ThemedBuilder<'a> {
169 inner: &'a mut StyledTreeBuilder,
170 theme: &'a Theme,
171}
172
173impl<'a> ThemedBuilder<'a> {
174 pub fn new(builder: &'a mut StyledTreeBuilder, theme: &'a Theme) -> Self {
176 Self {
177 inner: builder,
178 theme,
179 }
180 }
181
182 pub fn add_node(
184 &mut self,
185 content: ContentVariant,
186 role: SemanticRole,
187 element_id: Option<String>,
188 ) -> NodeId {
189 let style = self.theme.style_for(role);
190 self.inner.add_node(content, style, Some(role), element_id)
191 }
192
193 pub fn add_child(
195 &mut self,
196 parent: NodeId,
197 content: ContentVariant,
198 role: SemanticRole,
199 element_id: Option<String>,
200 ) -> NodeId {
201 let style = self.theme.style_for(role);
202 self.inner
203 .add_child(parent, content, style, Some(role), element_id)
204 }
205
206 pub fn add_child_with_style(
208 &mut self,
209 parent: NodeId,
210 content: ContentVariant,
211 style: ResolvedStyle,
212 role: Option<SemanticRole>,
213 element_id: Option<String>,
214 ) -> NodeId {
215 self.inner
216 .add_child(parent, content, style, role, element_id)
217 }
218
219 pub fn inner(&mut self) -> &mut StyledTreeBuilder {
221 self.inner
222 }
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228 use oxipdf_ir::IrVersion;
229
230 #[test]
231 fn default_theme_has_all_core_roles() {
232 let theme = Theme::default();
233 assert!(theme.has_style(SemanticRole::Document));
234 assert!(theme.has_style(SemanticRole::Paragraph));
235 assert!(theme.has_style(SemanticRole::Heading { level: 1 }));
236 assert!(theme.has_style(SemanticRole::Heading { level: 6 }));
237 assert!(theme.has_style(SemanticRole::CodeBlock));
238 assert!(theme.has_style(SemanticRole::BlockQuote));
239 assert!(theme.has_style(SemanticRole::List));
240 assert!(theme.has_style(SemanticRole::ListItem));
241 assert!(theme.has_style(SemanticRole::Table));
242 assert!(theme.has_style(SemanticRole::TableCell));
243 assert!(theme.has_style(SemanticRole::Figure));
244 assert!(theme.has_style(SemanticRole::Caption));
245 }
246
247 #[test]
248 fn academic_theme_has_justified_paragraphs() {
249 let theme = Theme::academic();
250 let p = theme.style_for(SemanticRole::Paragraph);
251 assert_eq!(
252 p.typography.text_align,
253 oxipdf_ir::style::typography::TextAlign::Justify
254 );
255 assert!(p.typography.text_indent.get() > 0.0);
256 }
257
258 #[test]
259 fn technical_theme_has_dark_code_blocks() {
260 let theme = Theme::technical();
261 let cb = theme.style_for(SemanticRole::CodeBlock);
262 let bg = cb.visual.background_color.unwrap();
263 match bg {
264 oxipdf_ir::color::Color::Srgb { r, .. } => {
265 assert!(r < 0.3, "dark theme code block bg should be dark");
266 }
267 _ => panic!("expected Srgb"),
268 }
269 }
270
271 #[test]
272 fn heading_sizes_decrease() {
273 let theme = Theme::default();
274 let sizes: Vec<f64> = (1..=6)
275 .map(|l| {
276 theme
277 .style_for(SemanticRole::Heading { level: l })
278 .typography
279 .font_size
280 .get()
281 })
282 .collect();
283 for i in 0..sizes.len() - 1 {
284 assert!(
285 sizes[i] >= sizes[i + 1],
286 "H{} ({}pt) should be >= H{} ({}pt)",
287 i + 1,
288 sizes[i],
289 i + 2,
290 sizes[i + 1]
291 );
292 }
293 }
294
295 #[test]
296 fn merge_overlays_styles() {
297 let mut base = Theme::default();
298 let mut overlay = Theme::new("Overlay");
299 let mut custom_p = ResolvedStyle::default();
300 custom_p.typography.font_size = oxipdf_ir::units::Pt::new(42.0);
301 overlay.set_style(SemanticRole::Paragraph, custom_p);
302
303 base.merge(&overlay);
304 let p = base.style_for(SemanticRole::Paragraph);
305 assert!((p.typography.font_size.get() - 42.0).abs() < 0.01);
306
307 assert!(base.has_style(SemanticRole::Heading { level: 1 }));
309 }
310
311 #[test]
312 fn unknown_role_returns_default() {
313 let theme = Theme::new("Empty");
314 let s = theme.style_for(SemanticRole::Navigation);
315 assert_eq!(s, ResolvedStyle::default());
316 }
317
318 #[test]
319 fn themed_builder_applies_styles() {
320 let theme = Theme::default();
321 let mut builder = StyledTreeBuilder::new(IrVersion::new(1, 0));
322 let mut tb = ThemedBuilder::new(&mut builder, &theme);
323
324 let root = tb.add_node(ContentVariant::Container, SemanticRole::Document, None);
325 let _child = tb.add_child(
326 root,
327 ContentVariant::Text(oxipdf_ir::TextContent::new("Hello")),
328 SemanticRole::Paragraph,
329 None,
330 );
331
332 let tree = builder.build().unwrap();
333 let p_node = tree.node(oxipdf_ir::node::NodeId::from_raw(1));
334 assert!(p_node.style.typography.font_size.get() > 0.0);
335 assert!(p_node.semantic_role == Some(SemanticRole::Paragraph));
336 }
337
338 #[test]
339 fn base_and_mono_fonts() {
340 let theme = Theme::default();
341 assert!(!theme.base_fonts().is_empty());
342 assert!(!theme.mono_fonts().is_empty());
343 assert_ne!(theme.base_fonts(), theme.mono_fonts());
344 }
345}