oxipdf-theme 0.1.0

Semantic role → resolved style theme system for the oxipdf PDF engine
Documentation
//! Core `Theme` struct and `ThemedBuilder` convenience wrapper.

use std::collections::BTreeMap;
use std::path::Path;

use oxipdf_ir::node::{ContentVariant, NodeId};
use oxipdf_ir::semantic::SemanticRole;
use oxipdf_ir::style::ResolvedStyle;
use oxipdf_ir::tree::StyledTreeBuilder;

use crate::toml_loader::ThemeLoadError;

/// A mapping from `SemanticRole` to `ResolvedStyle`.
///
/// Themes provide a reusable appearance layer: the consumer assigns semantic
/// roles, and the theme determines what each role looks like. This keeps
/// style definitions shared across consumers instead of duplicated.
///
/// Missing roles fall back to `ResolvedStyle::default()`.
#[derive(Debug, Clone)]
pub struct Theme {
    /// Human-readable theme name.
    name: String,
    /// Style overrides per semantic role.
    styles: BTreeMap<SemanticRole, ResolvedStyle>,
    /// Base font families applied to roles that don't specify their own.
    base_font_families: Vec<String>,
    /// Monospace font families for code blocks and inline code.
    mono_font_families: Vec<String>,
}

impl Theme {
    /// Create a theme with the given name and empty style map.
    #[must_use]
    pub fn new(name: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            styles: BTreeMap::new(),
            base_font_families: Vec::new(),
            mono_font_families: Vec::new(),
        }
    }

    /// Theme name.
    #[must_use]
    pub fn name(&self) -> &str {
        &self.name
    }

    /// Set the base (body) font families.
    pub fn set_base_fonts(&mut self, families: Vec<String>) {
        self.base_font_families = families;
    }

    /// Set the monospace font families.
    pub fn set_mono_fonts(&mut self, families: Vec<String>) {
        self.mono_font_families = families;
    }

    /// Base font families.
    #[must_use]
    pub fn base_fonts(&self) -> &[String] {
        &self.base_font_families
    }

    /// Monospace font families.
    #[must_use]
    pub fn mono_fonts(&self) -> &[String] {
        &self.mono_font_families
    }

    /// Register a style for a semantic role.
    pub fn set_style(&mut self, role: SemanticRole, style: ResolvedStyle) {
        self.styles.insert(role, style);
    }

    /// Get the style for a semantic role, falling back to `ResolvedStyle::default()`.
    #[must_use]
    pub fn style_for(&self, role: SemanticRole) -> ResolvedStyle {
        self.styles.get(&role).cloned().unwrap_or_default()
    }

    /// Check whether a style is registered for the given role.
    #[must_use]
    pub fn has_style(&self, role: SemanticRole) -> bool {
        self.styles.contains_key(&role)
    }

    /// Number of registered role styles.
    #[must_use]
    pub fn len(&self) -> usize {
        self.styles.len()
    }

    /// Whether no role styles are registered.
    #[must_use]
    pub fn is_empty(&self) -> bool {
        self.styles.is_empty()
    }

    /// Iterate all registered `(role, style)` pairs.
    pub fn iter(&self) -> impl Iterator<Item = (&SemanticRole, &ResolvedStyle)> {
        self.styles.iter()
    }

    /// Merge another theme on top of this one.
    ///
    /// Styles from `overlay` replace styles in `self` for the same role.
    /// Styles in `self` that are not in `overlay` are preserved.
    /// Font families from `overlay` replace those in `self` if non-empty.
    pub fn merge(&mut self, overlay: &Theme) {
        for (role, style) in &overlay.styles {
            self.styles.insert(*role, style.clone());
        }
        if !overlay.base_font_families.is_empty() {
            self.base_font_families = overlay.base_font_families.clone();
        }
        if !overlay.mono_font_families.is_empty() {
            self.mono_font_families = overlay.mono_font_families.clone();
        }
    }

    /// Create the academic theme: serif-based document.
    #[must_use]
    pub fn academic() -> Self {
        crate::builtin::academic_theme()
    }

    /// Create the technical theme: monospace-heavy document.
    #[must_use]
    pub fn technical() -> Self {
        crate::builtin::technical_theme()
    }

    /// Load a theme from a TOML file.
    ///
    /// The loaded theme is merged on top of the default theme, so only
    /// overridden roles need to be specified.
    pub fn load(path: impl AsRef<Path>) -> Result<Self, ThemeLoadError> {
        crate::toml_loader::load_from_file(path)
    }

    /// Parse a theme from a TOML string.
    ///
    /// The parsed theme is merged on top of the default theme, so only
    /// overridden roles need to be specified.
    pub fn from_toml(toml_str: &str) -> Result<Self, ThemeLoadError> {
        crate::toml_loader::parse_toml(toml_str)
    }
}

impl Default for Theme {
    /// Create the default theme: clean sans-serif document (Noto Sans).
    fn default() -> Self {
        crate::builtin::default_theme()
    }
}

/// Convenience wrapper around `StyledTreeBuilder` that auto-applies theme styles.
///
/// ```ignore
/// let theme = Theme::default();
/// let mut builder = StyledTreeBuilder::new(IrVersion::new(1, 0));
/// let mut tb = ThemedBuilder::new(&mut builder, &theme);
/// let root = tb.add_node(ContentVariant::Container, SemanticRole::Document, None);
/// tb.add_child(root, ContentVariant::Text(TextContent::new("Hello")), SemanticRole::Paragraph, None);
/// ```
pub struct ThemedBuilder<'a> {
    inner: &'a mut StyledTreeBuilder,
    theme: &'a Theme,
}

impl<'a> ThemedBuilder<'a> {
    /// Create a themed builder wrapping an existing `StyledTreeBuilder`.
    pub fn new(builder: &'a mut StyledTreeBuilder, theme: &'a Theme) -> Self {
        Self {
            inner: builder,
            theme,
        }
    }

    /// Add a root node with the theme style for the given role.
    pub fn add_node(
        &mut self,
        content: ContentVariant,
        role: SemanticRole,
        element_id: Option<String>,
    ) -> NodeId {
        let style = self.theme.style_for(role);
        self.inner.add_node(content, style, Some(role), element_id)
    }

    /// Add a child node with the theme style for the given role.
    pub fn add_child(
        &mut self,
        parent: NodeId,
        content: ContentVariant,
        role: SemanticRole,
        element_id: Option<String>,
    ) -> NodeId {
        let style = self.theme.style_for(role);
        self.inner
            .add_child(parent, content, style, Some(role), element_id)
    }

    /// Add a child with a custom style override (ignoring the theme for this node).
    pub fn add_child_with_style(
        &mut self,
        parent: NodeId,
        content: ContentVariant,
        style: ResolvedStyle,
        role: Option<SemanticRole>,
        element_id: Option<String>,
    ) -> NodeId {
        self.inner
            .add_child(parent, content, style, role, element_id)
    }

    /// Access the underlying builder for operations the themed wrapper doesn't cover.
    pub fn inner(&mut self) -> &mut StyledTreeBuilder {
        self.inner
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use oxipdf_ir::IrVersion;

    #[test]
    fn default_theme_has_all_core_roles() {
        let theme = Theme::default();
        assert!(theme.has_style(SemanticRole::Document));
        assert!(theme.has_style(SemanticRole::Paragraph));
        assert!(theme.has_style(SemanticRole::Heading { level: 1 }));
        assert!(theme.has_style(SemanticRole::Heading { level: 6 }));
        assert!(theme.has_style(SemanticRole::CodeBlock));
        assert!(theme.has_style(SemanticRole::BlockQuote));
        assert!(theme.has_style(SemanticRole::List));
        assert!(theme.has_style(SemanticRole::ListItem));
        assert!(theme.has_style(SemanticRole::Table));
        assert!(theme.has_style(SemanticRole::TableCell));
        assert!(theme.has_style(SemanticRole::Figure));
        assert!(theme.has_style(SemanticRole::Caption));
    }

    #[test]
    fn academic_theme_has_justified_paragraphs() {
        let theme = Theme::academic();
        let p = theme.style_for(SemanticRole::Paragraph);
        assert_eq!(
            p.typography.text_align,
            oxipdf_ir::style::typography::TextAlign::Justify
        );
        assert!(p.typography.text_indent.get() > 0.0);
    }

    #[test]
    fn technical_theme_has_dark_code_blocks() {
        let theme = Theme::technical();
        let cb = theme.style_for(SemanticRole::CodeBlock);
        let bg = cb.visual.background_color.unwrap();
        match bg {
            oxipdf_ir::color::Color::Srgb { r, .. } => {
                assert!(r < 0.3, "dark theme code block bg should be dark");
            }
            _ => panic!("expected Srgb"),
        }
    }

    #[test]
    fn heading_sizes_decrease() {
        let theme = Theme::default();
        let sizes: Vec<f64> = (1..=6)
            .map(|l| {
                theme
                    .style_for(SemanticRole::Heading { level: l })
                    .typography
                    .font_size
                    .get()
            })
            .collect();
        for i in 0..sizes.len() - 1 {
            assert!(
                sizes[i] >= sizes[i + 1],
                "H{} ({}pt) should be >= H{} ({}pt)",
                i + 1,
                sizes[i],
                i + 2,
                sizes[i + 1]
            );
        }
    }

    #[test]
    fn merge_overlays_styles() {
        let mut base = Theme::default();
        let mut overlay = Theme::new("Overlay");
        let mut custom_p = ResolvedStyle::default();
        custom_p.typography.font_size = oxipdf_ir::units::Pt::new(42.0);
        overlay.set_style(SemanticRole::Paragraph, custom_p);

        base.merge(&overlay);
        let p = base.style_for(SemanticRole::Paragraph);
        assert!((p.typography.font_size.get() - 42.0).abs() < 0.01);

        // Other roles preserved from base.
        assert!(base.has_style(SemanticRole::Heading { level: 1 }));
    }

    #[test]
    fn unknown_role_returns_default() {
        let theme = Theme::new("Empty");
        let s = theme.style_for(SemanticRole::Navigation);
        assert_eq!(s, ResolvedStyle::default());
    }

    #[test]
    fn themed_builder_applies_styles() {
        let theme = Theme::default();
        let mut builder = StyledTreeBuilder::new(IrVersion::new(1, 0));
        let mut tb = ThemedBuilder::new(&mut builder, &theme);

        let root = tb.add_node(ContentVariant::Container, SemanticRole::Document, None);
        let _child = tb.add_child(
            root,
            ContentVariant::Text(oxipdf_ir::TextContent::new("Hello")),
            SemanticRole::Paragraph,
            None,
        );

        let tree = builder.build().unwrap();
        let p_node = tree.node(oxipdf_ir::node::NodeId::from_raw(1));
        assert!(p_node.style.typography.font_size.get() > 0.0);
        assert!(p_node.semantic_role == Some(SemanticRole::Paragraph));
    }

    #[test]
    fn base_and_mono_fonts() {
        let theme = Theme::default();
        assert!(!theme.base_fonts().is_empty());
        assert!(!theme.mono_fonts().is_empty());
        assert_ne!(theme.base_fonts(), theme.mono_fonts());
    }
}