Skip to main content

vecslide_core/
theme.rs

1//! DaisyUI theme color extraction and conversion.
2//!
3//! Provides [`ThemeColors`] — a palette of 19 semantic colors (hex `#rrggbb`)
4//! extracted from DaisyUI CSS custom properties. Used by both the HTML viewer
5//! (as CSS custom properties) and the Typst preamble (as `rgb("#...")` values).
6
7use serde::{Deserialize, Serialize};
8
9/// All 19 DaisyUI semantic colors stored as hex strings (`#rrggbb`).
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ThemeColors {
12    pub theme_name: String,
13    pub primary: String,
14    pub primary_content: String,
15    pub secondary: String,
16    pub secondary_content: String,
17    pub accent: String,
18    pub accent_content: String,
19    pub neutral: String,
20    pub neutral_content: String,
21    pub base_100: String,
22    pub base_200: String,
23    pub base_300: String,
24    pub base_content: String,
25    pub info: String,
26    pub info_content: String,
27    pub success: String,
28    pub success_content: String,
29    pub warning: String,
30    pub warning_content: String,
31    pub error: String,
32    pub error_content: String,
33}
34
35impl Default for ThemeColors {
36    fn default() -> Self {
37        Self::dark_default()
38    }
39}
40
41impl ThemeColors {
42    /// Dark theme defaults matching DaisyUI "business" theme.
43    pub fn dark_default() -> Self {
44        Self {
45            theme_name: "business".to_string(),
46            primary: "#1c4f82".to_string(),
47            primary_content: "#d6e4f0".to_string(),
48            secondary: "#7c3aed".to_string(),
49            secondary_content: "#e4d8fd".to_string(),
50            accent: "#e68a00".to_string(),
51            accent_content: "#140c00".to_string(),
52            neutral: "#23282f".to_string(),
53            neutral_content: "#a6adba".to_string(),
54            base_100: "#1f2937".to_string(),
55            base_200: "#111827".to_string(),
56            base_300: "#0f1623".to_string(),
57            base_content: "#d5d9de".to_string(),
58            info: "#3abff8".to_string(),
59            info_content: "#002b3d".to_string(),
60            success: "#36d399".to_string(),
61            success_content: "#003320".to_string(),
62            warning: "#fbbd23".to_string(),
63            warning_content: "#382800".to_string(),
64            error: "#f87272".to_string(),
65            error_content: "#470000".to_string(),
66        }
67    }
68
69    /// Light theme defaults matching DaisyUI "bumblebee" theme.
70    pub fn light_default() -> Self {
71        Self {
72            theme_name: "bumblebee".to_string(),
73            primary: "#e0a82e".to_string(),
74            primary_content: "#181400".to_string(),
75            secondary: "#f9d72f".to_string(),
76            secondary_content: "#181400".to_string(),
77            accent: "#e0a82e".to_string(),
78            accent_content: "#181400".to_string(),
79            neutral: "#1f2937".to_string(),
80            neutral_content: "#d5d9de".to_string(),
81            base_100: "#ffffff".to_string(),
82            base_200: "#f2f2f2".to_string(),
83            base_300: "#e5e6e6".to_string(),
84            base_content: "#1f2937".to_string(),
85            info: "#3abff8".to_string(),
86            info_content: "#002b3d".to_string(),
87            success: "#36d399".to_string(),
88            success_content: "#003320".to_string(),
89            warning: "#fbbd23".to_string(),
90            warning_content: "#382800".to_string(),
91            error: "#f87272".to_string(),
92            error_content: "#470000".to_string(),
93        }
94    }
95
96    /// Generates CSS custom properties for the HTML viewer's `:root` block.
97    pub fn to_viewer_css(&self) -> String {
98        format!(
99            "\
100--vs-primary: {primary};\n  \
101--vs-primary-content: {primary_content};\n  \
102--vs-secondary: {secondary};\n  \
103--vs-accent: {accent};\n  \
104--vs-neutral: {neutral};\n  \
105--vs-neutral-content: {neutral_content};\n  \
106--vs-base-100: {base_100};\n  \
107--vs-base-200: {base_200};\n  \
108--vs-base-300: {base_300};\n  \
109--vs-base-content: {base_content};\n  \
110--vs-error: {error};",
111            primary = self.primary,
112            primary_content = self.primary_content,
113            secondary = self.secondary,
114            accent = self.accent,
115            neutral = self.neutral,
116            neutral_content = self.neutral_content,
117            base_100 = self.base_100,
118            base_200 = self.base_200,
119            base_300 = self.base_300,
120            base_content = self.base_content,
121            error = self.error,
122        )
123    }
124}
125
126// ── OKLCH → hex conversion ─────────────────────────────────────────────────────
127
128/// Parse a CSS `oklch(L C H)` string and convert to `#rrggbb` hex.
129///
130/// Accepts both `oklch(0.7 0.15 210)` and `oklch(70% 0.15 210)` forms.
131/// Returns `Err` if the string cannot be parsed.
132pub fn oklch_to_hex(s: &str) -> Result<String, String> {
133    let s = s.trim();
134
135    // Handle passthrough for already-hex values
136    if s.starts_with('#') && (s.len() == 7 || s.len() == 4) {
137        return Ok(s.to_string());
138    }
139
140    // Handle rgb() passthrough
141    if s.starts_with("rgb(") {
142        return rgb_str_to_hex(s);
143    }
144
145    let inner = s
146        .strip_prefix("oklch(")
147        .and_then(|s| s.strip_suffix(')'))
148        .ok_or_else(|| format!("not an oklch() value: {s}"))?
149        .trim();
150
151    // Split on whitespace or commas
152    let parts: Vec<&str> = inner.split(&[' ', ',', '/'] as &[char])
153        .map(str::trim)
154        .filter(|p| !p.is_empty())
155        .collect();
156
157    // Take first 3 components (ignore alpha if present)
158    if parts.len() < 3 {
159        return Err(format!("oklch needs 3 components, got {}: {s}", parts.len()));
160    }
161
162    let l = parse_lightness(parts[0])?;
163    let c: f64 = parts[1].parse().map_err(|e| format!("bad chroma: {e}"))?;
164    let h_deg: f64 = parts[2]
165        .strip_suffix("deg")
166        .unwrap_or(parts[2])
167        .parse()
168        .map_err(|e| format!("bad hue: {e}"))?;
169
170    let (r, g, b) = oklch_to_srgb(l, c, h_deg);
171    Ok(format!("#{:02x}{:02x}{:02x}", r, g, b))
172}
173
174fn parse_lightness(s: &str) -> Result<f64, String> {
175    if let Some(pct) = s.strip_suffix('%') {
176        let v: f64 = pct.parse().map_err(|e| format!("bad lightness: {e}"))?;
177        Ok(v / 100.0)
178    } else {
179        let v: f64 = s.parse().map_err(|e| format!("bad lightness: {e}"))?;
180        // Values > 1 are likely percentages without the % sign
181        if v > 1.0 { Ok(v / 100.0) } else { Ok(v) }
182    }
183}
184
185fn rgb_str_to_hex(s: &str) -> Result<String, String> {
186    let inner = s
187        .strip_prefix("rgb(")
188        .and_then(|s| s.strip_suffix(')'))
189        .ok_or_else(|| format!("not an rgb() value: {s}"))?
190        .trim();
191    let parts: Vec<&str> = inner.split(&[' ', ','] as &[char])
192        .map(str::trim)
193        .filter(|p| !p.is_empty())
194        .collect();
195    if parts.len() < 3 {
196        return Err(format!("rgb needs 3 components: {s}"));
197    }
198    let r: u8 = parts[0].parse().map_err(|e| format!("bad r: {e}"))?;
199    let g: u8 = parts[1].parse().map_err(|e| format!("bad g: {e}"))?;
200    let b: u8 = parts[2].parse().map_err(|e| format!("bad b: {e}"))?;
201    Ok(format!("#{r:02x}{g:02x}{b:02x}"))
202}
203
204/// Convert OKLCH (L in 0..1, C >= 0, H in degrees) to sRGB (0..255 each).
205fn oklch_to_srgb(l: f64, c: f64, h_deg: f64) -> (u8, u8, u8) {
206    let h_rad = h_deg.to_radians();
207    let a = c * h_rad.cos();
208    let b = c * h_rad.sin();
209    let (lr, lg, lb) = oklab_to_linear_srgb(l, a, b);
210    let r = linear_to_srgb(lr);
211    let g = linear_to_srgb(lg);
212    let b = linear_to_srgb(lb);
213    (r, g, b)
214}
215
216/// Convert Oklab (L, a, b) to linear sRGB.
217fn oklab_to_linear_srgb(l: f64, a: f64, b: f64) -> (f64, f64, f64) {
218    // Oklab → LMS (cube roots)
219    let l_ = l + 0.396_337_792_3 * a + 0.215_803_758_1 * b;
220    let m_ = l - 0.105_561_346_2 * a - 0.063_854_174_8 * b;
221    let s_ = l - 0.089_484_178_1 * a - 1.291_485_548_0 * b;
222
223    // Cube to get LMS
224    let l3 = l_ * l_ * l_;
225    let m3 = m_ * m_ * m_;
226    let s3 = s_ * s_ * s_;
227
228    // LMS → linear sRGB
229    let r = 4.076_741_662_0 * l3 - 3.307_711_590_4 * m3 + 0.230_969_928_4 * s3;
230    let g = -1.268_438_005_0 * l3 + 2.609_757_401_1 * m3 - 0.341_319_396_1 * s3;
231    let b = -0.004_196_086_3 * l3 - 0.703_418_614_7 * m3 + 1.707_614_701_0 * s3;
232
233    (r, g, b)
234}
235
236/// Apply sRGB gamma and clamp to 0..255.
237fn linear_to_srgb(c: f64) -> u8 {
238    let c = c.clamp(0.0, 1.0);
239    let s = if c <= 0.003_130_8 {
240        12.92 * c
241    } else {
242        1.055 * c.powf(1.0 / 2.4) - 0.055
243    };
244    (s * 255.0).round().clamp(0.0, 255.0) as u8
245}
246
247#[cfg(test)]
248mod tests {
249    use super::*;
250
251    #[test]
252    fn black_oklch() {
253        assert_eq!(oklch_to_hex("oklch(0 0 0)").unwrap(), "#000000");
254    }
255
256    #[test]
257    fn white_oklch() {
258        assert_eq!(oklch_to_hex("oklch(1 0 0)").unwrap(), "#ffffff");
259    }
260
261    #[test]
262    fn percentage_lightness() {
263        assert_eq!(oklch_to_hex("oklch(0% 0 0)").unwrap(), "#000000");
264        assert_eq!(oklch_to_hex("oklch(100% 0 0)").unwrap(), "#ffffff");
265    }
266
267    #[test]
268    fn known_blue() {
269        // oklch(0.5 0.2 260) should produce a blue-ish color
270        let hex = oklch_to_hex("oklch(0.5 0.2 260)").unwrap();
271        // Verify it's a valid hex and blue-dominant
272        assert!(hex.starts_with('#'));
273        assert_eq!(hex.len(), 7);
274        let b = u8::from_str_radix(&hex[5..7], 16).unwrap();
275        let r = u8::from_str_radix(&hex[1..3], 16).unwrap();
276        assert!(b > r, "expected blue > red for hue 260, got {hex}");
277    }
278
279    #[test]
280    fn known_red() {
281        // oklch(0.6 0.25 25) should produce a red-ish color
282        let hex = oklch_to_hex("oklch(0.6 0.25 25)").unwrap();
283        let r = u8::from_str_radix(&hex[1..3], 16).unwrap();
284        let b = u8::from_str_radix(&hex[5..7], 16).unwrap();
285        assert!(r > b, "expected red > blue for hue 25, got {hex}");
286    }
287
288    #[test]
289    fn hex_passthrough() {
290        assert_eq!(oklch_to_hex("#ff00aa").unwrap(), "#ff00aa");
291    }
292
293    #[test]
294    fn rgb_passthrough() {
295        assert_eq!(oklch_to_hex("rgb(255, 0, 128)").unwrap(), "#ff0080");
296    }
297
298    #[test]
299    fn with_deg_suffix() {
300        let hex = oklch_to_hex("oklch(0.7 0.15 210deg)").unwrap();
301        assert!(hex.starts_with('#'));
302        assert_eq!(hex.len(), 7);
303    }
304
305    #[test]
306    fn with_alpha_ignored() {
307        // oklch with alpha channel — we ignore the 4th component
308        let hex = oklch_to_hex("oklch(0.5 0.2 260 / 0.8)").unwrap();
309        assert!(hex.starts_with('#'));
310        assert_eq!(hex.len(), 7);
311    }
312
313    #[test]
314    fn viewer_css_contains_properties() {
315        let theme = ThemeColors::dark_default();
316        let css = theme.to_viewer_css();
317        assert!(css.contains("--vs-primary:"));
318        assert!(css.contains("--vs-base-100:"));
319        assert!(css.contains("--vs-base-content:"));
320        assert!(css.contains("--vs-error:"));
321    }
322}