1use 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#[derive(Debug)]
49pub enum ThemeLoadError {
50 Io(std::io::Error),
52 Parse(toml::de::Error),
54 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#[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
142pub(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
152pub(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 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
210fn 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 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 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 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 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
302fn parse_color(s: &str) -> Result<Color, ThemeLoadError> {
307 let hex = s.strip_prefix('#').unwrap_or(s);
308
309 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()); assert!(parse_color("#FFF").is_err()); assert!(parse_color("#FFFFF").is_err()); }
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 let p = theme.style_for(SemanticRole::Paragraph);
464 assert!((p.typography.font_size.get() - 14.0).abs() < 0.01);
465
466 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}