use std::path::Path;
use serde::Deserialize;
use oxipdf_ir::Dimension;
use oxipdf_ir::color::Color;
use oxipdf_ir::semantic::SemanticRole;
use oxipdf_ir::style::ResolvedStyle;
use oxipdf_ir::style::typography::{FontStyle, LineHeight, TextAlign, WhiteSpace};
use oxipdf_ir::units::Pt;
use crate::Theme;
#[derive(Debug)]
pub enum ThemeLoadError {
Io(std::io::Error),
Parse(toml::de::Error),
InvalidColor(String),
}
impl std::fmt::Display for ThemeLoadError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "theme file I/O error: {e}"),
Self::Parse(e) => write!(f, "theme TOML parse error: {e}"),
Self::InvalidColor(s) => write!(f, "invalid color: {s}"),
}
}
}
impl std::error::Error for ThemeLoadError {}
#[derive(Deserialize, Default)]
struct TomlTheme {
theme: Option<ThemeMeta>,
document: Option<RoleStyle>,
section: Option<RoleStyle>,
heading_1: Option<RoleStyle>,
heading_2: Option<RoleStyle>,
heading_3: Option<RoleStyle>,
heading_4: Option<RoleStyle>,
heading_5: Option<RoleStyle>,
heading_6: Option<RoleStyle>,
paragraph: Option<RoleStyle>,
list: Option<RoleStyle>,
list_item: Option<RoleStyle>,
table: Option<RoleStyle>,
table_header: Option<RoleStyle>,
table_body: Option<RoleStyle>,
table_row: Option<RoleStyle>,
table_cell: Option<RoleStyle>,
figure: Option<RoleStyle>,
caption: Option<RoleStyle>,
block_quote: Option<RoleStyle>,
code_block: Option<RoleStyle>,
navigation: Option<RoleStyle>,
footnote: Option<RoleStyle>,
page_decoration: Option<RoleStyle>,
}
#[derive(Deserialize, Default)]
struct ThemeMeta {
name: Option<String>,
base_fonts: Option<Vec<String>>,
mono_fonts: Option<Vec<String>>,
}
#[derive(Deserialize, Default, Clone)]
struct RoleStyle {
font_size: Option<f64>,
font_weight: Option<u16>,
font_style: Option<String>,
font_families: Option<Vec<String>>,
color: Option<String>,
background_color: Option<String>,
line_height_multiplier: Option<f64>,
line_height_pt: Option<f64>,
text_align: Option<String>,
white_space: Option<String>,
margin_top: Option<f64>,
margin_right: Option<f64>,
margin_bottom: Option<f64>,
margin_left: Option<f64>,
padding: Option<f64>,
padding_top: Option<f64>,
padding_right: Option<f64>,
padding_bottom: Option<f64>,
padding_left: Option<f64>,
border_radius: Option<f64>,
border_left_width: Option<f64>,
border_left_color: Option<String>,
}
pub(crate) fn load_from_file(path: impl AsRef<Path>) -> Result<Theme, ThemeLoadError> {
let contents = std::fs::read_to_string(path).map_err(ThemeLoadError::Io)?;
parse_toml(&contents)
}
pub(crate) fn parse_toml(toml_str: &str) -> Result<Theme, ThemeLoadError> {
let doc: TomlTheme = toml::from_str(toml_str).map_err(ThemeLoadError::Parse)?;
let mut theme = Theme::default();
if let Some(meta) = &doc.theme {
if let Some(name) = &meta.name {
theme = Theme::new(name.clone());
let default = Theme::default();
theme.merge(&default);
}
if let Some(fonts) = &meta.base_fonts {
theme.set_base_fonts(fonts.clone());
}
if let Some(fonts) = &meta.mono_fonts {
theme.set_mono_fonts(fonts.clone());
}
}
let role_map: Vec<(SemanticRole, &Option<RoleStyle>)> = vec![
(SemanticRole::Document, &doc.document),
(SemanticRole::Section, &doc.section),
(SemanticRole::Heading { level: 1 }, &doc.heading_1),
(SemanticRole::Heading { level: 2 }, &doc.heading_2),
(SemanticRole::Heading { level: 3 }, &doc.heading_3),
(SemanticRole::Heading { level: 4 }, &doc.heading_4),
(SemanticRole::Heading { level: 5 }, &doc.heading_5),
(SemanticRole::Heading { level: 6 }, &doc.heading_6),
(SemanticRole::Paragraph, &doc.paragraph),
(SemanticRole::List, &doc.list),
(SemanticRole::ListItem, &doc.list_item),
(SemanticRole::Table, &doc.table),
(SemanticRole::TableHeader, &doc.table_header),
(SemanticRole::TableBody, &doc.table_body),
(SemanticRole::TableRow, &doc.table_row),
(SemanticRole::TableCell, &doc.table_cell),
(SemanticRole::Figure, &doc.figure),
(SemanticRole::Caption, &doc.caption),
(SemanticRole::BlockQuote, &doc.block_quote),
(SemanticRole::CodeBlock, &doc.code_block),
(SemanticRole::Navigation, &doc.navigation),
(SemanticRole::Footnote, &doc.footnote),
(SemanticRole::PageDecoration, &doc.page_decoration),
];
for (role, opt_style) in &role_map {
if let Some(rs) = opt_style {
let base = theme.style_for(*role);
let merged = apply_role_style(base, rs)?;
theme.set_style(*role, merged);
}
}
Ok(theme)
}
fn apply_role_style(mut s: ResolvedStyle, rs: &RoleStyle) -> Result<ResolvedStyle, ThemeLoadError> {
if let Some(size) = rs.font_size {
s.typography.font_size = Pt::new(size);
}
if let Some(weight) = rs.font_weight {
s.typography.font_weight = weight;
}
if let Some(ref style_str) = rs.font_style {
s.typography.font_style = parse_font_style(style_str);
}
if let Some(ref families) = rs.font_families {
s.typography.font_families = families.clone();
}
if let Some(ref c) = rs.color {
s.typography.color = parse_color(c)?;
}
if let Some(ref c) = rs.background_color {
s.visual.background_color = Some(parse_color(c)?);
}
if let Some(m) = rs.line_height_multiplier {
s.typography.line_height = LineHeight::Number(m);
}
if let Some(pt) = rs.line_height_pt {
s.typography.line_height = LineHeight::Length(Pt::new(pt));
}
if let Some(ref a) = rs.text_align {
s.typography.text_align = parse_text_align(a);
}
if let Some(ref ws) = rs.white_space {
s.typography.white_space = parse_white_space(ws);
}
if let Some(v) = rs.margin_top {
s.layout.margin_top = Dimension::Length(Pt::new(v));
}
if let Some(v) = rs.margin_right {
s.layout.margin_right = Dimension::Length(Pt::new(v));
}
if let Some(v) = rs.margin_bottom {
s.layout.margin_bottom = Dimension::Length(Pt::new(v));
}
if let Some(v) = rs.margin_left {
s.layout.margin_left = Dimension::Length(Pt::new(v));
}
if let Some(v) = rs.padding {
let lp = oxipdf_ir::LengthPercentage::Length(Pt::new(v));
s.layout.padding_top = lp;
s.layout.padding_right = lp;
s.layout.padding_bottom = lp;
s.layout.padding_left = lp;
}
if let Some(v) = rs.padding_top {
s.layout.padding_top = oxipdf_ir::LengthPercentage::Length(Pt::new(v));
}
if let Some(v) = rs.padding_right {
s.layout.padding_right = oxipdf_ir::LengthPercentage::Length(Pt::new(v));
}
if let Some(v) = rs.padding_bottom {
s.layout.padding_bottom = oxipdf_ir::LengthPercentage::Length(Pt::new(v));
}
if let Some(v) = rs.padding_left {
s.layout.padding_left = oxipdf_ir::LengthPercentage::Length(Pt::new(v));
}
if let Some(v) = rs.border_radius {
let r = Pt::new(v);
s.visual.border_radius_top_left = r;
s.visual.border_radius_top_right = r;
s.visual.border_radius_bottom_right = r;
s.visual.border_radius_bottom_left = r;
}
if let Some(w) = rs.border_left_width {
s.visual.border_left.width = Pt::new(w);
s.visual.border_left.style = oxipdf_ir::style::visual::BorderStyle::Solid;
}
if let Some(ref c) = rs.border_left_color {
s.visual.border_left.color = parse_color(c)?;
}
Ok(s)
}
fn parse_color(s: &str) -> Result<Color, ThemeLoadError> {
let hex = s.strip_prefix('#').unwrap_or(s);
if hex.len() != 6 && hex.len() != 8 {
return Err(ThemeLoadError::InvalidColor(s.to_string()));
}
let bytes: Vec<u8> = (0..hex.len())
.step_by(2)
.filter_map(|i| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok())
.collect();
match bytes.len() {
3 => Ok(Color::rgb(
bytes[0] as f32 / 255.0,
bytes[1] as f32 / 255.0,
bytes[2] as f32 / 255.0,
)),
4 => Ok(Color::rgba(
bytes[0] as f32 / 255.0,
bytes[1] as f32 / 255.0,
bytes[2] as f32 / 255.0,
bytes[3] as f32 / 255.0,
)),
_ => Err(ThemeLoadError::InvalidColor(s.to_string())),
}
}
fn parse_font_style(s: &str) -> FontStyle {
match s.to_lowercase().as_str() {
"italic" => FontStyle::Italic,
"oblique" => FontStyle::Oblique,
_ => FontStyle::Normal,
}
}
fn parse_text_align(s: &str) -> TextAlign {
match s.to_lowercase().as_str() {
"left" => TextAlign::Left,
"right" => TextAlign::Right,
"center" => TextAlign::Center,
"justify" => TextAlign::Justify,
"end" => TextAlign::End,
_ => TextAlign::Start,
}
}
fn parse_white_space(s: &str) -> WhiteSpace {
match s.to_lowercase().as_str() {
"pre" => WhiteSpace::Pre,
"nowrap" => WhiteSpace::NoWrap,
"pre-wrap" | "prewrap" => WhiteSpace::PreWrap,
"pre-line" | "preline" => WhiteSpace::PreLine,
_ => WhiteSpace::Normal,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_hex_color_rgb() {
let c = parse_color("#FF8800").unwrap();
match c {
Color::Srgb { r, g, b, a } => {
assert!((r - 1.0).abs() < 0.01);
assert!((g - 0.533).abs() < 0.01);
assert!((b - 0.0).abs() < 0.01);
assert!((a - 1.0).abs() < 0.01);
}
_ => panic!("expected Srgb"),
}
}
#[test]
fn parse_hex_color_rgba() {
let c = parse_color("#FF880080").unwrap();
match c {
Color::Srgb { r, a, .. } => {
assert!((r - 1.0).abs() < 0.01);
assert!((a - 0.502).abs() < 0.01);
}
_ => panic!("expected Srgb"),
}
}
#[test]
fn parse_invalid_color() {
assert!(parse_color("not-a-color").is_err());
}
#[test]
fn parse_color_rejects_odd_length_hex() {
assert!(parse_color("#FF88001").is_err()); assert!(parse_color("#FFF").is_err()); assert!(parse_color("#FFFFF").is_err()); }
#[test]
fn parse_minimal_toml() {
let toml = r#"
[paragraph]
font_size = 14.0
margin_bottom = 8.0
"#;
let theme = parse_toml(toml).unwrap();
let p = theme.style_for(SemanticRole::Paragraph);
assert!((p.typography.font_size.get() - 14.0).abs() < 0.01);
}
#[test]
fn parse_full_toml() {
let toml = r##"
[theme]
name = "Test"
base_fonts = ["Arial"]
mono_fonts = ["Courier"]
[heading_1]
font_size = 30.0
font_weight = 800
margin_top = 40.0
color = "#112233"
[code_block]
font_size = 9.0
background_color = "#f0f0f0"
padding = 12.0
white_space = "pre"
border_radius = 5.0
"##;
let theme = parse_toml(toml).unwrap();
assert_eq!(theme.name(), "Test");
assert_eq!(theme.base_fonts(), &["Arial"]);
let h1 = theme.style_for(SemanticRole::Heading { level: 1 });
assert!((h1.typography.font_size.get() - 30.0).abs() < 0.01);
assert_eq!(h1.typography.font_weight, 800);
let cb = theme.style_for(SemanticRole::CodeBlock);
assert!((cb.typography.font_size.get() - 9.0).abs() < 0.01);
assert!(cb.visual.background_color.is_some());
assert!((cb.visual.border_radius_top_left.get() - 5.0).abs() < 0.01);
}
#[test]
fn partial_theme_preserves_defaults() {
let toml = r#"
[paragraph]
font_size = 14.0
"#;
let theme = parse_toml(toml).unwrap();
let p = theme.style_for(SemanticRole::Paragraph);
assert!((p.typography.font_size.get() - 14.0).abs() < 0.01);
let h1 = theme.style_for(SemanticRole::Heading { level: 1 });
assert!(h1.typography.font_weight >= 700);
}
#[test]
fn empty_toml_returns_default() {
let theme = parse_toml("").unwrap();
assert!(!theme.is_empty());
}
}