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;
#[derive(Debug, Clone)]
pub struct Theme {
name: String,
styles: BTreeMap<SemanticRole, ResolvedStyle>,
base_font_families: Vec<String>,
mono_font_families: Vec<String>,
}
impl Theme {
#[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(),
}
}
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
pub fn set_base_fonts(&mut self, families: Vec<String>) {
self.base_font_families = families;
}
pub fn set_mono_fonts(&mut self, families: Vec<String>) {
self.mono_font_families = families;
}
#[must_use]
pub fn base_fonts(&self) -> &[String] {
&self.base_font_families
}
#[must_use]
pub fn mono_fonts(&self) -> &[String] {
&self.mono_font_families
}
pub fn set_style(&mut self, role: SemanticRole, style: ResolvedStyle) {
self.styles.insert(role, style);
}
#[must_use]
pub fn style_for(&self, role: SemanticRole) -> ResolvedStyle {
self.styles.get(&role).cloned().unwrap_or_default()
}
#[must_use]
pub fn has_style(&self, role: SemanticRole) -> bool {
self.styles.contains_key(&role)
}
#[must_use]
pub fn len(&self) -> usize {
self.styles.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.styles.is_empty()
}
pub fn iter(&self) -> impl Iterator<Item = (&SemanticRole, &ResolvedStyle)> {
self.styles.iter()
}
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();
}
}
#[must_use]
pub fn academic() -> Self {
crate::builtin::academic_theme()
}
#[must_use]
pub fn technical() -> Self {
crate::builtin::technical_theme()
}
pub fn load(path: impl AsRef<Path>) -> Result<Self, ThemeLoadError> {
crate::toml_loader::load_from_file(path)
}
pub fn from_toml(toml_str: &str) -> Result<Self, ThemeLoadError> {
crate::toml_loader::parse_toml(toml_str)
}
}
impl Default for Theme {
fn default() -> Self {
crate::builtin::default_theme()
}
}
pub struct ThemedBuilder<'a> {
inner: &'a mut StyledTreeBuilder,
theme: &'a Theme,
}
impl<'a> ThemedBuilder<'a> {
pub fn new(builder: &'a mut StyledTreeBuilder, theme: &'a Theme) -> Self {
Self {
inner: builder,
theme,
}
}
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)
}
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)
}
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)
}
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);
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());
}
}