Skip to main content

oxipdf_theme/
theme.rs

1//! Core `Theme` struct and `ThemedBuilder` convenience wrapper.
2
3use 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/// A mapping from `SemanticRole` to `ResolvedStyle`.
14///
15/// Themes provide a reusable appearance layer: the consumer assigns semantic
16/// roles, and the theme determines what each role looks like. This keeps
17/// style definitions shared across consumers instead of duplicated.
18///
19/// Missing roles fall back to `ResolvedStyle::default()`.
20#[derive(Debug, Clone)]
21pub struct Theme {
22    /// Human-readable theme name.
23    name: String,
24    /// Style overrides per semantic role.
25    styles: BTreeMap<SemanticRole, ResolvedStyle>,
26    /// Base font families applied to roles that don't specify their own.
27    base_font_families: Vec<String>,
28    /// Monospace font families for code blocks and inline code.
29    mono_font_families: Vec<String>,
30}
31
32impl Theme {
33    /// Create a theme with the given name and empty style map.
34    #[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    /// Theme name.
45    #[must_use]
46    pub fn name(&self) -> &str {
47        &self.name
48    }
49
50    /// Set the base (body) font families.
51    pub fn set_base_fonts(&mut self, families: Vec<String>) {
52        self.base_font_families = families;
53    }
54
55    /// Set the monospace font families.
56    pub fn set_mono_fonts(&mut self, families: Vec<String>) {
57        self.mono_font_families = families;
58    }
59
60    /// Base font families.
61    #[must_use]
62    pub fn base_fonts(&self) -> &[String] {
63        &self.base_font_families
64    }
65
66    /// Monospace font families.
67    #[must_use]
68    pub fn mono_fonts(&self) -> &[String] {
69        &self.mono_font_families
70    }
71
72    /// Register a style for a semantic role.
73    pub fn set_style(&mut self, role: SemanticRole, style: ResolvedStyle) {
74        self.styles.insert(role, style);
75    }
76
77    /// Get the style for a semantic role, falling back to `ResolvedStyle::default()`.
78    #[must_use]
79    pub fn style_for(&self, role: SemanticRole) -> ResolvedStyle {
80        self.styles.get(&role).cloned().unwrap_or_default()
81    }
82
83    /// Check whether a style is registered for the given role.
84    #[must_use]
85    pub fn has_style(&self, role: SemanticRole) -> bool {
86        self.styles.contains_key(&role)
87    }
88
89    /// Number of registered role styles.
90    #[must_use]
91    pub fn len(&self) -> usize {
92        self.styles.len()
93    }
94
95    /// Whether no role styles are registered.
96    #[must_use]
97    pub fn is_empty(&self) -> bool {
98        self.styles.is_empty()
99    }
100
101    /// Iterate all registered `(role, style)` pairs.
102    pub fn iter(&self) -> impl Iterator<Item = (&SemanticRole, &ResolvedStyle)> {
103        self.styles.iter()
104    }
105
106    /// Merge another theme on top of this one.
107    ///
108    /// Styles from `overlay` replace styles in `self` for the same role.
109    /// Styles in `self` that are not in `overlay` are preserved.
110    /// Font families from `overlay` replace those in `self` if non-empty.
111    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    /// Create the academic theme: serif-based document.
124    #[must_use]
125    pub fn academic() -> Self {
126        crate::builtin::academic_theme()
127    }
128
129    /// Create the technical theme: monospace-heavy document.
130    #[must_use]
131    pub fn technical() -> Self {
132        crate::builtin::technical_theme()
133    }
134
135    /// Load a theme from a TOML file.
136    ///
137    /// The loaded theme is merged on top of the default theme, so only
138    /// overridden roles need to be specified.
139    pub fn load(path: impl AsRef<Path>) -> Result<Self, ThemeLoadError> {
140        crate::toml_loader::load_from_file(path)
141    }
142
143    /// Parse a theme from a TOML string.
144    ///
145    /// The parsed theme is merged on top of the default theme, so only
146    /// overridden roles need to be specified.
147    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    /// Create the default theme: clean sans-serif document (Noto Sans).
154    fn default() -> Self {
155        crate::builtin::default_theme()
156    }
157}
158
159/// Convenience wrapper around `StyledTreeBuilder` that auto-applies theme styles.
160///
161/// ```ignore
162/// let theme = Theme::default();
163/// let mut builder = StyledTreeBuilder::new(IrVersion::new(1, 0));
164/// let mut tb = ThemedBuilder::new(&mut builder, &theme);
165/// let root = tb.add_node(ContentVariant::Container, SemanticRole::Document, None);
166/// tb.add_child(root, ContentVariant::Text(TextContent::new("Hello")), SemanticRole::Paragraph, None);
167/// ```
168pub struct ThemedBuilder<'a> {
169    inner: &'a mut StyledTreeBuilder,
170    theme: &'a Theme,
171}
172
173impl<'a> ThemedBuilder<'a> {
174    /// Create a themed builder wrapping an existing `StyledTreeBuilder`.
175    pub fn new(builder: &'a mut StyledTreeBuilder, theme: &'a Theme) -> Self {
176        Self {
177            inner: builder,
178            theme,
179        }
180    }
181
182    /// Add a root node with the theme style for the given role.
183    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    /// Add a child node with the theme style for the given role.
194    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    /// Add a child with a custom style override (ignoring the theme for this node).
207    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    /// Access the underlying builder for operations the themed wrapper doesn't cover.
220    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        // Other roles preserved from base.
308        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}