use crate::syntax::TokenKind;
use anyhow::{bail, Context, Result};
use serde::Deserialize;
use std::path::Path;
pub const IMAGE_MAX_HEIGHT_FRACTION_NUM: u32 = 65;
pub const IMAGE_MAX_HEIGHT_FRACTION_DEN: u32 = 100;
pub const LONG_LIST_THRESHOLD: usize = 12;
#[derive(Debug, Default, Deserialize)]
pub struct ThemeOverride {
pub bg: Option<String>,
pub title_color: Option<String>,
pub body_color: Option<String>,
pub muted_color: Option<String>,
pub accent: Option<String>,
pub accent_soft: Option<String>,
pub divider: Option<String>,
pub code_bg: Option<String>,
pub code_text: Option<String>,
pub code_accent: Option<String>,
pub section_bg: Option<String>,
pub section_text: Option<String>,
pub link: Option<String>,
pub on_accent: Option<String>,
pub title_font: Option<String>,
pub body_font: Option<String>,
pub mono_font: Option<String>,
pub title_size: Option<u32>,
pub body_size: Option<u32>,
pub code_size: Option<u32>,
pub hero_size: Option<u32>,
pub syntax: Option<SyntaxOverride>,
}
#[derive(Debug, Default, Deserialize)]
pub struct SyntaxOverride {
pub keyword: Option<String>,
pub string: Option<String>,
pub number: Option<String>,
pub comment: Option<String>,
pub function: Option<String>,
#[serde(rename = "type")]
pub type_color: Option<String>,
pub attribute: Option<String>,
}
fn normalize_hex(s: &str, field: &str) -> Result<String> {
let trimmed = s.trim().trim_start_matches('#');
if !trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
bail!(
"{}: {:?} is not a valid hex colour (use #RRGGBB or #RGB)",
field,
s
);
}
let expanded = match trimmed.len() {
6 => trimmed.to_string(),
3 => trimmed.chars().flat_map(|c| [c, c]).collect(),
_ => bail!(
"{}: {:?} must have 3 or 6 hex digits (got {})",
field,
s,
trimmed.len()
),
};
Ok(expanded.to_ascii_uppercase())
}
#[derive(Debug, Clone)]
pub struct SyntaxStyle {
pub color: String,
pub italic: bool,
pub bold: bool,
}
#[derive(Debug, Clone)]
pub struct Theme {
pub name: &'static str,
pub slide_w: u32,
pub slide_h: u32,
pub aspect_kind: &'static str,
pub portrait: bool,
pub title_size: u32,
pub body_size: u32,
pub code_size: u32,
pub hero_size: u32,
pub bg: String,
pub title_color: String,
pub body_color: String,
pub muted_color: String,
pub accent: String,
pub accent_soft: String,
pub divider: String,
pub code_bg: String,
pub code_text: String,
pub code_accent: String,
pub section_bg: String,
pub section_text: String,
pub link: String,
pub on_accent: String,
pub title_font: String,
pub body_font: String,
pub mono_font: String,
pub syntax_keyword: String,
pub syntax_string: String,
pub syntax_number: String,
pub syntax_comment: String,
pub syntax_function: String,
pub syntax_type: String,
pub syntax_attribute: String,
}
impl Theme {
pub fn syntax_style(&self, kind: TokenKind) -> SyntaxStyle {
match kind {
TokenKind::Keyword => SyntaxStyle {
color: self.syntax_keyword.clone(),
italic: false,
bold: true,
},
TokenKind::String => SyntaxStyle {
color: self.syntax_string.clone(),
italic: false,
bold: false,
},
TokenKind::Number => SyntaxStyle {
color: self.syntax_number.clone(),
italic: false,
bold: false,
},
TokenKind::Comment => SyntaxStyle {
color: self.syntax_comment.clone(),
italic: true,
bold: false,
},
TokenKind::Function => SyntaxStyle {
color: self.syntax_function.clone(),
italic: false,
bold: false,
},
TokenKind::Type => SyntaxStyle {
color: self.syntax_type.clone(),
italic: false,
bold: false,
},
TokenKind::Attribute => SyntaxStyle {
color: self.syntax_attribute.clone(),
italic: false,
bold: false,
},
TokenKind::Default => SyntaxStyle {
color: self.code_text.clone(),
italic: false,
bold: false,
},
}
}
}
impl Theme {
pub fn resolve(name: &str, aspect: &str, font: Option<&str>) -> Result<Self> {
let lower = aspect.trim().to_ascii_lowercase();
let (slide_w, slide_h, aspect_kind, portrait) = match lower.as_str() {
"16:9" | "16x9" | "widescreen" => (12192000, 6858000, "screen16x9", false),
"4:3" | "4x3" | "standard" => (9144000, 6858000, "screen4x3", false),
"9:16" | "9x16" | "portrait" => (6858000, 12192000, "custom", true),
"a4" => (7560000, 10692000, "custom", true),
"a4-landscape" | "a4l" => (10692000, 7560000, "custom", false),
"a3" => (10692000, 15120000, "custom", true),
"a5" => (5328000, 7560000, "custom", true),
"letter" => (7772400, 10058400, "custom", true),
"letter-landscape" | "letter-l" => (10058400, 7772400, "custom", false),
"legal" => (7772400, 12801600, "custom", true),
"tabloid" => (10058400, 15544800, "custom", true),
other => parse_custom_aspect(other)?,
};
let (title_size, body_size, code_size, hero_size) = if portrait {
(2400, 1700, 1400, 4200)
} else {
(2800, 1800, 1500, 5400)
};
let body_font = font
.map(|s| s.to_string())
.unwrap_or_else(|| "Calibri".into());
let title_font = font
.map(|s| s.to_string())
.unwrap_or_else(|| "Calibri Light".into());
let theme = match name.to_lowercase().as_str() {
"light" | "default" => Theme {
name: "light",
slide_w,
slide_h,
aspect_kind,
portrait,
title_size,
body_size,
code_size,
hero_size,
bg: "FFFFFF".into(),
title_color: "0F172A".into(),
body_color: "334155".into(),
muted_color: "94A3B8".into(),
accent: "0EA5E9".into(),
accent_soft: "E0F2FE".into(),
divider: "E2E8F0".into(),
code_bg: "F1F5F9".into(),
code_text: "1E293B".into(),
code_accent: "0369A1".into(),
section_bg: "0F172A".into(),
section_text: "F8FAFC".into(),
link: "0EA5E9".into(),
on_accent: "FFFFFF".into(),
title_font,
body_font,
mono_font: "Consolas".into(),
syntax_keyword: "9333EA".into(),
syntax_string: "16A34A".into(),
syntax_number: "C2410C".into(),
syntax_comment: "94A3B8".into(),
syntax_function: "2563EB".into(),
syntax_type: "0891B2".into(),
syntax_attribute: "DC2626".into(),
},
"dark" | "night" => Theme {
name: "dark",
slide_w,
slide_h,
aspect_kind,
portrait,
title_size,
body_size,
code_size,
hero_size,
bg: "0B1220".into(),
title_color: "F8FAFC".into(),
body_color: "CBD5E1".into(),
muted_color: "64748B".into(),
accent: "38BDF8".into(),
accent_soft: "0C4A6E".into(),
divider: "1E293B".into(),
code_bg: "111A2E".into(),
code_text: "E2E8F0".into(),
code_accent: "7DD3FC".into(),
section_bg: "0EA5E9".into(),
section_text: "FFFFFF".into(),
link: "7DD3FC".into(),
on_accent: "0B1220".into(),
title_font,
body_font,
mono_font: "Consolas".into(),
syntax_keyword: "C792EA".into(),
syntax_string: "C3E88D".into(),
syntax_number: "FFCB6B".into(),
syntax_comment: "6B7B8E".into(),
syntax_function: "82AAFF".into(),
syntax_type: "FFC777".into(),
syntax_attribute: "F78C6C".into(),
},
other => bail!("unknown theme: {} (try light or dark)", other),
};
Ok(theme)
}
pub fn apply_override(&mut self, ov: &ThemeOverride) -> Result<()> {
macro_rules! set_color {
($field:ident) => {
if let Some(v) = &ov.$field {
self.$field = normalize_hex(v, stringify!($field))?;
}
};
}
set_color!(bg);
set_color!(title_color);
set_color!(body_color);
set_color!(muted_color);
set_color!(accent);
set_color!(accent_soft);
set_color!(divider);
set_color!(code_bg);
set_color!(code_text);
set_color!(code_accent);
set_color!(section_bg);
set_color!(section_text);
set_color!(link);
set_color!(on_accent);
if let Some(v) = &ov.title_font {
self.title_font = v.clone();
}
if let Some(v) = &ov.body_font {
self.body_font = v.clone();
}
if let Some(v) = &ov.mono_font {
self.mono_font = v.clone();
}
if let Some(v) = ov.title_size {
self.title_size = v;
}
if let Some(v) = ov.body_size {
self.body_size = v;
}
if let Some(v) = ov.code_size {
self.code_size = v;
}
if let Some(v) = ov.hero_size {
self.hero_size = v;
}
if let Some(s) = &ov.syntax {
if let Some(c) = &s.keyword {
self.syntax_keyword = normalize_hex(c, "syntax.keyword")?;
}
if let Some(c) = &s.string {
self.syntax_string = normalize_hex(c, "syntax.string")?;
}
if let Some(c) = &s.number {
self.syntax_number = normalize_hex(c, "syntax.number")?;
}
if let Some(c) = &s.comment {
self.syntax_comment = normalize_hex(c, "syntax.comment")?;
}
if let Some(c) = &s.function {
self.syntax_function = normalize_hex(c, "syntax.function")?;
}
if let Some(c) = &s.type_color {
self.syntax_type = normalize_hex(c, "syntax.type")?;
}
if let Some(c) = &s.attribute {
self.syntax_attribute = normalize_hex(c, "syntax.attribute")?;
}
}
Ok(())
}
}
fn parse_custom_aspect(s: &str) -> Result<(u32, u32, &'static str, bool)> {
let s = s.trim();
if s.is_empty() {
bail!("empty aspect string");
}
let sep_idx = s
.char_indices()
.filter(|(_, c)| *c == 'x' || *c == 'X' || *c == ':')
.map(|(i, _)| i)
.next()
.ok_or_else(|| anyhow::anyhow!(
"unknown aspect: {} (try 16:9, 4:3, 9:16, a4, letter, or `WxH[unit]` like 1920x1080px or 300x200mm)",
s
))?;
let left = &s[..sep_idx];
let rest = &s[sep_idx + 1..];
let unit_start = rest.len()
- rest
.chars()
.rev()
.take_while(|c| c.is_ascii_alphabetic())
.count();
let (right_num, unit) = rest.split_at(unit_start);
let unit = unit.to_ascii_lowercase();
let w_val: f64 = left
.trim()
.parse()
.map_err(|_| anyhow::anyhow!("invalid width in aspect {:?}", s))?;
let h_val: f64 = right_num
.trim()
.parse()
.map_err(|_| anyhow::anyhow!("invalid height in aspect {:?}", s))?;
if w_val <= 0.0 || h_val <= 0.0 {
bail!("aspect dimensions must be positive: {:?}", s);
}
let emu_per_unit: f64 = match unit.as_str() {
"" | "px" | "pixel" | "pixels" => 914_400.0 / 96.0,
"in" | "inch" | "inches" => 914_400.0,
"mm" => 36_000.0,
"cm" => 360_000.0,
"emu" => 1.0,
"pt" | "points" => 12_700.0,
other => bail!(
"unknown unit {:?} in aspect (try px, mm, cm, in, pt, emu)",
other
),
};
let w_emu = (w_val * emu_per_unit).round() as u32;
let h_emu = (h_val * emu_per_unit).round() as u32;
if w_emu < 100_000 || h_emu < 100_000 {
bail!(
"aspect {:?} is too small (< 0.1 in × 0.1 in once converted)",
s
);
}
let portrait = h_emu > w_emu;
Ok((w_emu, h_emu, "custom", portrait))
}
pub fn load_override(path: &Path) -> Result<ThemeOverride> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("read theme override {}", path.display()))?;
let ov: ThemeOverride = serde_yaml::from_str(&text)
.with_context(|| format!("parse theme override {}", path.display()))?;
Ok(ov)
}