Skip to main content

oxipdf_theme/
toml_loader.rs

1//! TOML theme file loading and parsing.
2//!
3//! Parses a TOML file into a `Theme`, merging on top of the default theme
4//! so only overridden roles need to be specified.
5//!
6//! # TOML Schema
7//!
8//! ```toml
9//! [theme]
10//! name = "Custom"
11//! base_fonts = ["Noto Sans", "DejaVu Sans"]
12//! mono_fonts = ["Noto Sans Mono"]
13//!
14//! [paragraph]
15//! font_size = 11.0
16//! font_weight = 400
17//! line_height_multiplier = 1.4
18//! margin_bottom = 6.0
19//! color = "#333333"
20//!
21//! [heading_1]
22//! font_size = 24.0
23//! font_weight = 700
24//! margin_top = 24.0
25//! margin_bottom = 12.0
26//!
27//! [code_block]
28//! font_size = 10.0
29//! background_color = "#f5f5f5"
30//! padding = 8.0
31//! white_space = "pre"
32//! ```
33
34use std::path::Path;
35
36use serde::Deserialize;
37
38use oxipdf_ir::Dimension;
39use oxipdf_ir::color::Color;
40use oxipdf_ir::semantic::SemanticRole;
41use oxipdf_ir::style::ResolvedStyle;
42use oxipdf_ir::style::typography::{FontStyle, LineHeight, TextAlign, WhiteSpace};
43use oxipdf_ir::units::Pt;
44
45use crate::Theme;
46
47/// Error loading or parsing a theme file.
48#[derive(Debug)]
49pub enum ThemeLoadError {
50    /// File I/O error.
51    Io(std::io::Error),
52    /// TOML parse error.
53    Parse(toml::de::Error),
54    /// Invalid color string (expected `#RRGGBB` or `#RRGGBBAA`).
55    InvalidColor(String),
56}
57
58impl std::fmt::Display for ThemeLoadError {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        match self {
61            Self::Io(e) => write!(f, "theme file I/O error: {e}"),
62            Self::Parse(e) => write!(f, "theme TOML parse error: {e}"),
63            Self::InvalidColor(s) => write!(f, "invalid color: {s}"),
64        }
65    }
66}
67
68impl std::error::Error for ThemeLoadError {}
69
70// ---------------------------------------------------------------------------
71// TOML schema types
72// ---------------------------------------------------------------------------
73
74#[derive(Deserialize, Default)]
75struct TomlTheme {
76    theme: Option<ThemeMeta>,
77
78    document: Option<RoleStyle>,
79    section: Option<RoleStyle>,
80    heading_1: Option<RoleStyle>,
81    heading_2: Option<RoleStyle>,
82    heading_3: Option<RoleStyle>,
83    heading_4: Option<RoleStyle>,
84    heading_5: Option<RoleStyle>,
85    heading_6: Option<RoleStyle>,
86    paragraph: Option<RoleStyle>,
87    list: Option<RoleStyle>,
88    list_item: Option<RoleStyle>,
89    table: Option<RoleStyle>,
90    table_header: Option<RoleStyle>,
91    table_body: Option<RoleStyle>,
92    table_row: Option<RoleStyle>,
93    table_cell: Option<RoleStyle>,
94    figure: Option<RoleStyle>,
95    caption: Option<RoleStyle>,
96    block_quote: Option<RoleStyle>,
97    code_block: Option<RoleStyle>,
98    navigation: Option<RoleStyle>,
99    footnote: Option<RoleStyle>,
100    page_decoration: Option<RoleStyle>,
101}
102
103#[derive(Deserialize, Default)]
104struct ThemeMeta {
105    name: Option<String>,
106    base_fonts: Option<Vec<String>>,
107    mono_fonts: Option<Vec<String>>,
108}
109
110#[derive(Deserialize, Default, Clone)]
111struct RoleStyle {
112    font_size: Option<f64>,
113    font_weight: Option<u16>,
114    font_style: Option<String>,
115    font_families: Option<Vec<String>>,
116
117    color: Option<String>,
118    background_color: Option<String>,
119
120    line_height_multiplier: Option<f64>,
121    line_height_pt: Option<f64>,
122    text_align: Option<String>,
123    white_space: Option<String>,
124
125    margin_top: Option<f64>,
126    margin_right: Option<f64>,
127    margin_bottom: Option<f64>,
128    margin_left: Option<f64>,
129
130    padding: Option<f64>,
131    padding_top: Option<f64>,
132    padding_right: Option<f64>,
133    padding_bottom: Option<f64>,
134    padding_left: Option<f64>,
135
136    border_radius: Option<f64>,
137
138    border_left_width: Option<f64>,
139    border_left_color: Option<String>,
140}
141
142// ---------------------------------------------------------------------------
143// Public API
144// ---------------------------------------------------------------------------
145
146/// Load a theme from a TOML file, merged on top of the default theme.
147pub(crate) fn load_from_file(path: impl AsRef<Path>) -> Result<Theme, ThemeLoadError> {
148    let contents = std::fs::read_to_string(path).map_err(ThemeLoadError::Io)?;
149    parse_toml(&contents)
150}
151
152/// Parse a TOML string into a theme, merged on top of the default theme.
153pub(crate) fn parse_toml(toml_str: &str) -> Result<Theme, ThemeLoadError> {
154    let doc: TomlTheme = toml::from_str(toml_str).map_err(ThemeLoadError::Parse)?;
155
156    let mut theme = Theme::default();
157
158    if let Some(meta) = &doc.theme {
159        if let Some(name) = &meta.name {
160            theme = Theme::new(name.clone());
161            // Re-merge default styles since we created a fresh theme.
162            let default = Theme::default();
163            theme.merge(&default);
164        }
165        if let Some(fonts) = &meta.base_fonts {
166            theme.set_base_fonts(fonts.clone());
167        }
168        if let Some(fonts) = &meta.mono_fonts {
169            theme.set_mono_fonts(fonts.clone());
170        }
171    }
172
173    let role_map: Vec<(SemanticRole, &Option<RoleStyle>)> = vec![
174        (SemanticRole::Document, &doc.document),
175        (SemanticRole::Section, &doc.section),
176        (SemanticRole::Heading { level: 1 }, &doc.heading_1),
177        (SemanticRole::Heading { level: 2 }, &doc.heading_2),
178        (SemanticRole::Heading { level: 3 }, &doc.heading_3),
179        (SemanticRole::Heading { level: 4 }, &doc.heading_4),
180        (SemanticRole::Heading { level: 5 }, &doc.heading_5),
181        (SemanticRole::Heading { level: 6 }, &doc.heading_6),
182        (SemanticRole::Paragraph, &doc.paragraph),
183        (SemanticRole::List, &doc.list),
184        (SemanticRole::ListItem, &doc.list_item),
185        (SemanticRole::Table, &doc.table),
186        (SemanticRole::TableHeader, &doc.table_header),
187        (SemanticRole::TableBody, &doc.table_body),
188        (SemanticRole::TableRow, &doc.table_row),
189        (SemanticRole::TableCell, &doc.table_cell),
190        (SemanticRole::Figure, &doc.figure),
191        (SemanticRole::Caption, &doc.caption),
192        (SemanticRole::BlockQuote, &doc.block_quote),
193        (SemanticRole::CodeBlock, &doc.code_block),
194        (SemanticRole::Navigation, &doc.navigation),
195        (SemanticRole::Footnote, &doc.footnote),
196        (SemanticRole::PageDecoration, &doc.page_decoration),
197    ];
198
199    for (role, opt_style) in &role_map {
200        if let Some(rs) = opt_style {
201            let base = theme.style_for(*role);
202            let merged = apply_role_style(base, rs)?;
203            theme.set_style(*role, merged);
204        }
205    }
206
207    Ok(theme)
208}
209
210// ---------------------------------------------------------------------------
211// Style application
212// ---------------------------------------------------------------------------
213
214fn apply_role_style(mut s: ResolvedStyle, rs: &RoleStyle) -> Result<ResolvedStyle, ThemeLoadError> {
215    if let Some(size) = rs.font_size {
216        s.typography.font_size = Pt::new(size);
217    }
218    if let Some(weight) = rs.font_weight {
219        s.typography.font_weight = weight;
220    }
221    if let Some(ref style_str) = rs.font_style {
222        s.typography.font_style = parse_font_style(style_str);
223    }
224    if let Some(ref families) = rs.font_families {
225        s.typography.font_families = families.clone();
226    }
227    if let Some(ref c) = rs.color {
228        s.typography.color = parse_color(c)?;
229    }
230    if let Some(ref c) = rs.background_color {
231        s.visual.background_color = Some(parse_color(c)?);
232    }
233    if let Some(m) = rs.line_height_multiplier {
234        s.typography.line_height = LineHeight::Number(m);
235    }
236    if let Some(pt) = rs.line_height_pt {
237        s.typography.line_height = LineHeight::Length(Pt::new(pt));
238    }
239    if let Some(ref a) = rs.text_align {
240        s.typography.text_align = parse_text_align(a);
241    }
242    if let Some(ref ws) = rs.white_space {
243        s.typography.white_space = parse_white_space(ws);
244    }
245
246    // Margins
247    if let Some(v) = rs.margin_top {
248        s.layout.margin_top = Dimension::Length(Pt::new(v));
249    }
250    if let Some(v) = rs.margin_right {
251        s.layout.margin_right = Dimension::Length(Pt::new(v));
252    }
253    if let Some(v) = rs.margin_bottom {
254        s.layout.margin_bottom = Dimension::Length(Pt::new(v));
255    }
256    if let Some(v) = rs.margin_left {
257        s.layout.margin_left = Dimension::Length(Pt::new(v));
258    }
259
260    // Padding (shorthand overrides individual)
261    if let Some(v) = rs.padding {
262        let lp = oxipdf_ir::LengthPercentage::Length(Pt::new(v));
263        s.layout.padding_top = lp;
264        s.layout.padding_right = lp;
265        s.layout.padding_bottom = lp;
266        s.layout.padding_left = lp;
267    }
268    if let Some(v) = rs.padding_top {
269        s.layout.padding_top = oxipdf_ir::LengthPercentage::Length(Pt::new(v));
270    }
271    if let Some(v) = rs.padding_right {
272        s.layout.padding_right = oxipdf_ir::LengthPercentage::Length(Pt::new(v));
273    }
274    if let Some(v) = rs.padding_bottom {
275        s.layout.padding_bottom = oxipdf_ir::LengthPercentage::Length(Pt::new(v));
276    }
277    if let Some(v) = rs.padding_left {
278        s.layout.padding_left = oxipdf_ir::LengthPercentage::Length(Pt::new(v));
279    }
280
281    // Border radius
282    if let Some(v) = rs.border_radius {
283        let r = Pt::new(v);
284        s.visual.border_radius_top_left = r;
285        s.visual.border_radius_top_right = r;
286        s.visual.border_radius_bottom_right = r;
287        s.visual.border_radius_bottom_left = r;
288    }
289
290    // Left border (for blockquote)
291    if let Some(w) = rs.border_left_width {
292        s.visual.border_left.width = Pt::new(w);
293        s.visual.border_left.style = oxipdf_ir::style::visual::BorderStyle::Solid;
294    }
295    if let Some(ref c) = rs.border_left_color {
296        s.visual.border_left.color = parse_color(c)?;
297    }
298
299    Ok(s)
300}
301
302// ---------------------------------------------------------------------------
303// Parsers
304// ---------------------------------------------------------------------------
305
306fn parse_color(s: &str) -> Result<Color, ThemeLoadError> {
307    let hex = s.strip_prefix('#').unwrap_or(s);
308
309    // Hex string must be exactly 6 (RGB) or 8 (RGBA) characters.
310    // Reject odd-length or wrong-length strings immediately to
311    // prevent silent truncation of malformed input.
312    if hex.len() != 6 && hex.len() != 8 {
313        return Err(ThemeLoadError::InvalidColor(s.to_string()));
314    }
315
316    let bytes: Vec<u8> = (0..hex.len())
317        .step_by(2)
318        .filter_map(|i| u8::from_str_radix(hex.get(i..i + 2)?, 16).ok())
319        .collect();
320
321    match bytes.len() {
322        3 => Ok(Color::rgb(
323            bytes[0] as f32 / 255.0,
324            bytes[1] as f32 / 255.0,
325            bytes[2] as f32 / 255.0,
326        )),
327        4 => Ok(Color::rgba(
328            bytes[0] as f32 / 255.0,
329            bytes[1] as f32 / 255.0,
330            bytes[2] as f32 / 255.0,
331            bytes[3] as f32 / 255.0,
332        )),
333        _ => Err(ThemeLoadError::InvalidColor(s.to_string())),
334    }
335}
336
337fn parse_font_style(s: &str) -> FontStyle {
338    match s.to_lowercase().as_str() {
339        "italic" => FontStyle::Italic,
340        "oblique" => FontStyle::Oblique,
341        _ => FontStyle::Normal,
342    }
343}
344
345fn parse_text_align(s: &str) -> TextAlign {
346    match s.to_lowercase().as_str() {
347        "left" => TextAlign::Left,
348        "right" => TextAlign::Right,
349        "center" => TextAlign::Center,
350        "justify" => TextAlign::Justify,
351        "end" => TextAlign::End,
352        _ => TextAlign::Start,
353    }
354}
355
356fn parse_white_space(s: &str) -> WhiteSpace {
357    match s.to_lowercase().as_str() {
358        "pre" => WhiteSpace::Pre,
359        "nowrap" => WhiteSpace::NoWrap,
360        "pre-wrap" | "prewrap" => WhiteSpace::PreWrap,
361        "pre-line" | "preline" => WhiteSpace::PreLine,
362        _ => WhiteSpace::Normal,
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use super::*;
369
370    #[test]
371    fn parse_hex_color_rgb() {
372        let c = parse_color("#FF8800").unwrap();
373        match c {
374            Color::Srgb { r, g, b, a } => {
375                assert!((r - 1.0).abs() < 0.01);
376                assert!((g - 0.533).abs() < 0.01);
377                assert!((b - 0.0).abs() < 0.01);
378                assert!((a - 1.0).abs() < 0.01);
379            }
380            _ => panic!("expected Srgb"),
381        }
382    }
383
384    #[test]
385    fn parse_hex_color_rgba() {
386        let c = parse_color("#FF880080").unwrap();
387        match c {
388            Color::Srgb { r, a, .. } => {
389                assert!((r - 1.0).abs() < 0.01);
390                assert!((a - 0.502).abs() < 0.01);
391            }
392            _ => panic!("expected Srgb"),
393        }
394    }
395
396    #[test]
397    fn parse_invalid_color() {
398        assert!(parse_color("not-a-color").is_err());
399    }
400
401    #[test]
402    fn parse_color_rejects_odd_length_hex() {
403        assert!(parse_color("#FF88001").is_err()); // 7 chars — not 6 or 8
404        assert!(parse_color("#FFF").is_err()); // 3 chars — shorthand not supported
405        assert!(parse_color("#FFFFF").is_err()); // 5 chars
406    }
407
408    #[test]
409    fn parse_minimal_toml() {
410        let toml = r#"
411[paragraph]
412font_size = 14.0
413margin_bottom = 8.0
414"#;
415        let theme = parse_toml(toml).unwrap();
416        let p = theme.style_for(SemanticRole::Paragraph);
417        assert!((p.typography.font_size.get() - 14.0).abs() < 0.01);
418    }
419
420    #[test]
421    fn parse_full_toml() {
422        let toml = r##"
423[theme]
424name = "Test"
425base_fonts = ["Arial"]
426mono_fonts = ["Courier"]
427
428[heading_1]
429font_size = 30.0
430font_weight = 800
431margin_top = 40.0
432color = "#112233"
433
434[code_block]
435font_size = 9.0
436background_color = "#f0f0f0"
437padding = 12.0
438white_space = "pre"
439border_radius = 5.0
440"##;
441        let theme = parse_toml(toml).unwrap();
442        assert_eq!(theme.name(), "Test");
443        assert_eq!(theme.base_fonts(), &["Arial"]);
444
445        let h1 = theme.style_for(SemanticRole::Heading { level: 1 });
446        assert!((h1.typography.font_size.get() - 30.0).abs() < 0.01);
447        assert_eq!(h1.typography.font_weight, 800);
448
449        let cb = theme.style_for(SemanticRole::CodeBlock);
450        assert!((cb.typography.font_size.get() - 9.0).abs() < 0.01);
451        assert!(cb.visual.background_color.is_some());
452        assert!((cb.visual.border_radius_top_left.get() - 5.0).abs() < 0.01);
453    }
454
455    #[test]
456    fn partial_theme_preserves_defaults() {
457        let toml = r#"
458[paragraph]
459font_size = 14.0
460"#;
461        let theme = parse_toml(toml).unwrap();
462        // Paragraph overridden.
463        let p = theme.style_for(SemanticRole::Paragraph);
464        assert!((p.typography.font_size.get() - 14.0).abs() < 0.01);
465
466        // Heading not specified → uses default theme's heading.
467        let h1 = theme.style_for(SemanticRole::Heading { level: 1 });
468        assert!(h1.typography.font_weight >= 700);
469    }
470
471    #[test]
472    fn empty_toml_returns_default() {
473        let theme = parse_toml("").unwrap();
474        assert!(!theme.is_empty());
475    }
476}