Skip to main content

deckcraft_color/
lib.rs

1//! DeckCraft colour: sRGB colours, theme colour slots and DrawingML-style colour transforms
2//! (tint, shade, luminance modulation/offset, alpha…), so theme-relative colours resolve the way
3//! presentation files expect.
4#![deny(
5    clippy::unwrap_used,
6    clippy::expect_used,
7    clippy::panic,
8    clippy::unimplemented,
9    clippy::todo,
10    clippy::unreachable
11)]
12#![forbid(unsafe_code)]
13
14use serde::{Deserialize, Serialize};
15
16/// An sRGB colour with straight alpha, components 0–255.
17#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
18pub struct Rgba {
19    pub r: u8,
20    pub g: u8,
21    pub b: u8,
22    pub a: u8,
23}
24
25impl Default for Rgba {
26    fn default() -> Self {
27        Rgba::BLACK
28    }
29}
30
31impl Rgba {
32    pub const BLACK: Rgba = Rgba::rgb(0, 0, 0);
33    pub const WHITE: Rgba = Rgba::rgb(255, 255, 255);
34    pub const TRANSPARENT: Rgba = Rgba {
35        r: 0,
36        g: 0,
37        b: 0,
38        a: 0,
39    };
40
41    pub const fn rgb(r: u8, g: u8, b: u8) -> Self {
42        Rgba { r, g, b, a: 255 }
43    }
44    pub const fn rgba(r: u8, g: u8, b: u8, a: u8) -> Self {
45        Rgba { r, g, b, a }
46    }
47    /// `RRGGBB` or `#RRGGBB` (also `RRGGBBAA`).
48    pub fn from_hex(s: &str) -> Option<Self> {
49        let s = s.trim().trim_start_matches('#');
50        if !s.is_ascii() {
51            return None;
52        }
53        let byte = |i: usize| s.get(i..i + 2).and_then(|h| u8::from_str_radix(h, 16).ok());
54        match s.len() {
55            6 => Some(Rgba::rgb(byte(0)?, byte(2)?, byte(4)?)),
56            8 => Some(Rgba::rgba(byte(0)?, byte(2)?, byte(4)?, byte(6)?)),
57            3 => {
58                let n = |i: usize| {
59                    s.get(i..i + 1)
60                        .and_then(|h| u8::from_str_radix(h, 16).ok())
61                        .map(|v| v * 17)
62                };
63                Some(Rgba::rgb(n(0)?, n(1)?, n(2)?))
64            }
65            _ => None,
66        }
67    }
68    /// `RRGGBB` uppercase (no alpha).
69    pub fn hex(&self) -> String {
70        format!("{:02X}{:02X}{:02X}", self.r, self.g, self.b)
71    }
72    pub fn with_alpha(self, a: u8) -> Self {
73        Rgba { a, ..self }
74    }
75    pub fn to_f32(self) -> [f32; 4] {
76        [
77            self.r as f32 / 255.0,
78            self.g as f32 / 255.0,
79            self.b as f32 / 255.0,
80            self.a as f32 / 255.0,
81        ]
82    }
83    pub fn from_f64(r: f64, g: f64, b: f64, a: f64) -> Self {
84        let c = |v: f64| {
85            if v.is_finite() {
86                (v.clamp(0.0, 1.0) * 255.0).round() as u8
87            } else {
88                0
89            }
90        };
91        Rgba {
92            r: c(r),
93            g: c(g),
94            b: c(b),
95            a: c(a),
96        }
97    }
98    /// Relative luminance (WCAG), 0–1.
99    pub fn luminance(self) -> f64 {
100        let lin = |c: u8| {
101            let v = c as f64 / 255.0;
102            if v <= 0.04045 {
103                v / 12.92
104            } else {
105                ((v + 0.055) / 1.055).powf(2.4)
106            }
107        };
108        0.2126 * lin(self.r) + 0.7152 * lin(self.g) + 0.0722 * lin(self.b)
109    }
110    /// WCAG contrast ratio between two colours (1–21).
111    pub fn contrast(self, other: Rgba) -> f64 {
112        let (a, b) = (self.luminance(), other.luminance());
113        let (hi, lo) = if a > b { (a, b) } else { (b, a) };
114        (hi + 0.05) / (lo + 0.05)
115    }
116    pub fn to_hsl(self) -> (f64, f64, f64) {
117        let r = self.r as f64 / 255.0;
118        let g = self.g as f64 / 255.0;
119        let b = self.b as f64 / 255.0;
120        let max = r.max(g).max(b);
121        let min = r.min(g).min(b);
122        let l = (max + min) / 2.0;
123        if (max - min).abs() < 1e-12 {
124            return (0.0, 0.0, l);
125        }
126        let d = max - min;
127        let s = if l > 0.5 {
128            d / (2.0 - max - min)
129        } else {
130            d / (max + min)
131        };
132        let h = if (max - r).abs() < 1e-12 {
133            (g - b) / d + if g < b { 6.0 } else { 0.0 }
134        } else if (max - g).abs() < 1e-12 {
135            (b - r) / d + 2.0
136        } else {
137            (r - g) / d + 4.0
138        };
139        (h * 60.0, s, l)
140    }
141    pub fn from_hsl(h: f64, s: f64, l: f64, a: u8) -> Self {
142        let h = ((h % 360.0) + 360.0) % 360.0 / 360.0;
143        let s = s.clamp(0.0, 1.0);
144        let l = l.clamp(0.0, 1.0);
145        if s == 0.0 {
146            return Rgba::from_f64(l, l, l, a as f64 / 255.0);
147        }
148        let q = if l < 0.5 {
149            l * (1.0 + s)
150        } else {
151            l + s - l * s
152        };
153        let p = 2.0 * l - q;
154        let f = |t: f64| {
155            let t = if t < 0.0 {
156                t + 1.0
157            } else if t > 1.0 {
158                t - 1.0
159            } else {
160                t
161            };
162            if t < 1.0 / 6.0 {
163                p + (q - p) * 6.0 * t
164            } else if t < 0.5 {
165                q
166            } else if t < 2.0 / 3.0 {
167                p + (q - p) * (2.0 / 3.0 - t) * 6.0
168            } else {
169                p
170            }
171        };
172        Rgba::from_f64(f(h + 1.0 / 3.0), f(h), f(h - 1.0 / 3.0), a as f64 / 255.0)
173    }
174    /// Linear interpolation (straight alpha).
175    pub fn lerp(self, o: Rgba, t: f64) -> Rgba {
176        let t = if t.is_finite() {
177            t.clamp(0.0, 1.0)
178        } else {
179            0.0
180        };
181        let m = |a: u8, b: u8| {
182            (a as f64 + (b as f64 - a as f64) * t)
183                .round()
184                .clamp(0.0, 255.0) as u8
185        };
186        Rgba {
187            r: m(self.r, o.r),
188            g: m(self.g, o.g),
189            b: m(self.b, o.b),
190            a: m(self.a, o.a),
191        }
192    }
193}
194
195/// The twelve theme colour slots, plus the mapped aliases (`bg1`, `tx1`…) and `phClr` (the
196/// placeholder colour a style matrix entry is applied with).
197#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
198#[serde(rename_all = "camelCase")]
199pub enum SchemeSlot {
200    Dk1,
201    Lt1,
202    Dk2,
203    Lt2,
204    Accent1,
205    Accent2,
206    Accent3,
207    Accent4,
208    Accent5,
209    Accent6,
210    Hlink,
211    FolHlink,
212    Bg1,
213    Tx1,
214    Bg2,
215    Tx2,
216    PhClr,
217}
218
219impl SchemeSlot {
220    pub const THEME: [SchemeSlot; 12] = [
221        SchemeSlot::Dk1,
222        SchemeSlot::Lt1,
223        SchemeSlot::Dk2,
224        SchemeSlot::Lt2,
225        SchemeSlot::Accent1,
226        SchemeSlot::Accent2,
227        SchemeSlot::Accent3,
228        SchemeSlot::Accent4,
229        SchemeSlot::Accent5,
230        SchemeSlot::Accent6,
231        SchemeSlot::Hlink,
232        SchemeSlot::FolHlink,
233    ];
234    pub fn xml_name(self) -> &'static str {
235        match self {
236            SchemeSlot::Dk1 => "dk1",
237            SchemeSlot::Lt1 => "lt1",
238            SchemeSlot::Dk2 => "dk2",
239            SchemeSlot::Lt2 => "lt2",
240            SchemeSlot::Accent1 => "accent1",
241            SchemeSlot::Accent2 => "accent2",
242            SchemeSlot::Accent3 => "accent3",
243            SchemeSlot::Accent4 => "accent4",
244            SchemeSlot::Accent5 => "accent5",
245            SchemeSlot::Accent6 => "accent6",
246            SchemeSlot::Hlink => "hlink",
247            SchemeSlot::FolHlink => "folHlink",
248            SchemeSlot::Bg1 => "bg1",
249            SchemeSlot::Tx1 => "tx1",
250            SchemeSlot::Bg2 => "bg2",
251            SchemeSlot::Tx2 => "tx2",
252            SchemeSlot::PhClr => "phClr",
253        }
254    }
255    pub fn from_xml(s: &str) -> Option<Self> {
256        Some(match s {
257            "dk1" => SchemeSlot::Dk1,
258            "lt1" => SchemeSlot::Lt1,
259            "dk2" => SchemeSlot::Dk2,
260            "lt2" => SchemeSlot::Lt2,
261            "accent1" => SchemeSlot::Accent1,
262            "accent2" => SchemeSlot::Accent2,
263            "accent3" => SchemeSlot::Accent3,
264            "accent4" => SchemeSlot::Accent4,
265            "accent5" => SchemeSlot::Accent5,
266            "accent6" => SchemeSlot::Accent6,
267            "hlink" => SchemeSlot::Hlink,
268            "folHlink" => SchemeSlot::FolHlink,
269            "bg1" => SchemeSlot::Bg1,
270            "tx1" => SchemeSlot::Tx1,
271            "bg2" => SchemeSlot::Bg2,
272            "tx2" => SchemeSlot::Tx2,
273            "phClr" => SchemeSlot::PhClr,
274            _ => return None,
275        })
276    }
277    pub fn label(self) -> &'static str {
278        match self {
279            SchemeSlot::Dk1 | SchemeSlot::Tx1 => "Text/Background - Dark 1",
280            SchemeSlot::Lt1 | SchemeSlot::Bg1 => "Text/Background - Light 1",
281            SchemeSlot::Dk2 | SchemeSlot::Tx2 => "Text/Background - Dark 2",
282            SchemeSlot::Lt2 | SchemeSlot::Bg2 => "Text/Background - Light 2",
283            SchemeSlot::Accent1 => "Accent 1",
284            SchemeSlot::Accent2 => "Accent 2",
285            SchemeSlot::Accent3 => "Accent 3",
286            SchemeSlot::Accent4 => "Accent 4",
287            SchemeSlot::Accent5 => "Accent 5",
288            SchemeSlot::Accent6 => "Accent 6",
289            SchemeSlot::Hlink => "Hyperlink",
290            SchemeSlot::FolHlink => "Followed Hyperlink",
291            SchemeSlot::PhClr => "Placeholder colour",
292        }
293    }
294}
295
296/// A colour scheme: the twelve theme colours.
297#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
298#[serde(rename_all = "camelCase")]
299pub struct ColorScheme {
300    pub name: String,
301    pub colors: [Rgba; 12],
302}
303
304impl ColorScheme {
305    pub fn get(&self, slot: SchemeSlot) -> Rgba {
306        let i = match slot {
307            SchemeSlot::Dk1 | SchemeSlot::Tx1 => 0,
308            SchemeSlot::Lt1 | SchemeSlot::Bg1 => 1,
309            SchemeSlot::Dk2 | SchemeSlot::Tx2 => 2,
310            SchemeSlot::Lt2 | SchemeSlot::Bg2 => 3,
311            SchemeSlot::Accent1 => 4,
312            SchemeSlot::Accent2 => 5,
313            SchemeSlot::Accent3 => 6,
314            SchemeSlot::Accent4 => 7,
315            SchemeSlot::Accent5 => 8,
316            SchemeSlot::Accent6 => 9,
317            SchemeSlot::Hlink => 10,
318            SchemeSlot::FolHlink => 11,
319            SchemeSlot::PhClr => 4,
320        };
321        self.colors.get(i).copied().unwrap_or(Rgba::BLACK)
322    }
323    pub fn set(&mut self, slot: SchemeSlot, c: Rgba) {
324        if let Some(i) = SchemeSlot::THEME.iter().position(|s| *s == slot)
325            && let Some(v) = self.colors.get_mut(i)
326        {
327            *v = c;
328        }
329    }
330}
331
332/// One colour transform, in file units (percentages ×1000, angles ×60000), applied in order.
333#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
334#[serde(rename_all = "camelCase", tag = "op", content = "val")]
335pub enum ColorTransform {
336    Tint(i32),
337    Shade(i32),
338    LumMod(i32),
339    LumOff(i32),
340    SatMod(i32),
341    SatOff(i32),
342    HueMod(i32),
343    HueOff(i32),
344    Alpha(i32),
345    AlphaMod(i32),
346    AlphaOff(i32),
347    Comp,
348    Inv,
349    Gray,
350    RedMod(i32),
351    GreenMod(i32),
352    BlueMod(i32),
353}
354
355impl ColorTransform {
356    pub fn xml_name(&self) -> &'static str {
357        match self {
358            ColorTransform::Tint(_) => "tint",
359            ColorTransform::Shade(_) => "shade",
360            ColorTransform::LumMod(_) => "lumMod",
361            ColorTransform::LumOff(_) => "lumOff",
362            ColorTransform::SatMod(_) => "satMod",
363            ColorTransform::SatOff(_) => "satOff",
364            ColorTransform::HueMod(_) => "hueMod",
365            ColorTransform::HueOff(_) => "hueOff",
366            ColorTransform::Alpha(_) => "alpha",
367            ColorTransform::AlphaMod(_) => "alphaMod",
368            ColorTransform::AlphaOff(_) => "alphaOff",
369            ColorTransform::Comp => "comp",
370            ColorTransform::Inv => "inv",
371            ColorTransform::Gray => "gray",
372            ColorTransform::RedMod(_) => "redMod",
373            ColorTransform::GreenMod(_) => "greenMod",
374            ColorTransform::BlueMod(_) => "blueMod",
375        }
376    }
377    pub fn value(&self) -> Option<i32> {
378        match *self {
379            ColorTransform::Tint(v)
380            | ColorTransform::Shade(v)
381            | ColorTransform::LumMod(v)
382            | ColorTransform::LumOff(v)
383            | ColorTransform::SatMod(v)
384            | ColorTransform::SatOff(v)
385            | ColorTransform::HueMod(v)
386            | ColorTransform::HueOff(v)
387            | ColorTransform::Alpha(v)
388            | ColorTransform::AlphaMod(v)
389            | ColorTransform::AlphaOff(v)
390            | ColorTransform::RedMod(v)
391            | ColorTransform::GreenMod(v)
392            | ColorTransform::BlueMod(v) => Some(v),
393            _ => None,
394        }
395    }
396    pub fn from_xml(name: &str, val: Option<i32>) -> Option<Self> {
397        let v = val.unwrap_or(0);
398        Some(match name {
399            "tint" => ColorTransform::Tint(v),
400            "shade" => ColorTransform::Shade(v),
401            "lumMod" => ColorTransform::LumMod(v),
402            "lumOff" => ColorTransform::LumOff(v),
403            "satMod" => ColorTransform::SatMod(v),
404            "satOff" => ColorTransform::SatOff(v),
405            "hueMod" => ColorTransform::HueMod(v),
406            "hueOff" => ColorTransform::HueOff(v),
407            "alpha" => ColorTransform::Alpha(v),
408            "alphaMod" => ColorTransform::AlphaMod(v),
409            "alphaOff" => ColorTransform::AlphaOff(v),
410            "comp" => ColorTransform::Comp,
411            "inv" => ColorTransform::Inv,
412            "gray" => ColorTransform::Gray,
413            "redMod" => ColorTransform::RedMod(v),
414            "greenMod" => ColorTransform::GreenMod(v),
415            "blueMod" => ColorTransform::BlueMod(v),
416            _ => return None,
417        })
418    }
419}
420
421/// Apply transforms to a base colour.
422pub fn apply(base: Rgba, transforms: &[ColorTransform]) -> Rgba {
423    let mut c = base;
424    for t in transforms {
425        c = apply_one(c, *t);
426    }
427    c
428}
429
430fn pct(v: i32) -> f64 {
431    v as f64 / 100_000.0
432}
433
434fn apply_one(c: Rgba, t: ColorTransform) -> Rgba {
435    match t {
436        // tint: towards white by (1 - value); shade: towards black by (1 - value). Office applies
437        // these in linear light, which keeps mid-tones from washing out.
438        ColorTransform::Tint(v) => {
439            let k = pct(v).clamp(0.0, 1.0);
440            map_linear(c, |x| x * k + (1.0 - k))
441        }
442        ColorTransform::Shade(v) => {
443            let k = pct(v).clamp(0.0, 1.0);
444            map_linear(c, |x| x * k)
445        }
446        ColorTransform::LumMod(v) => {
447            let (h, s, l) = c.to_hsl();
448            Rgba::from_hsl(h, s, l * pct(v), c.a)
449        }
450        ColorTransform::LumOff(v) => {
451            let (h, s, l) = c.to_hsl();
452            Rgba::from_hsl(h, s, l + pct(v), c.a)
453        }
454        ColorTransform::SatMod(v) => {
455            let (h, s, l) = c.to_hsl();
456            Rgba::from_hsl(h, s * pct(v), l, c.a)
457        }
458        ColorTransform::SatOff(v) => {
459            let (h, s, l) = c.to_hsl();
460            Rgba::from_hsl(h, s + pct(v), l, c.a)
461        }
462        ColorTransform::HueMod(v) => {
463            let (h, s, l) = c.to_hsl();
464            Rgba::from_hsl(h * pct(v), s, l, c.a)
465        }
466        ColorTransform::HueOff(v) => {
467            let (h, s, l) = c.to_hsl();
468            Rgba::from_hsl(h + v as f64 / 60_000.0, s, l, c.a)
469        }
470        ColorTransform::Alpha(v) => c.with_alpha((pct(v).clamp(0.0, 1.0) * 255.0).round() as u8),
471        ColorTransform::AlphaMod(v) => {
472            c.with_alpha((c.a as f64 * pct(v)).round().clamp(0.0, 255.0) as u8)
473        }
474        ColorTransform::AlphaOff(v) => {
475            c.with_alpha((c.a as f64 + pct(v) * 255.0).round().clamp(0.0, 255.0) as u8)
476        }
477        ColorTransform::Comp => {
478            let (h, s, l) = c.to_hsl();
479            Rgba::from_hsl(h + 180.0, s, l, c.a)
480        }
481        ColorTransform::Inv => Rgba {
482            r: 255 - c.r,
483            g: 255 - c.g,
484            b: 255 - c.b,
485            a: c.a,
486        },
487        ColorTransform::Gray => {
488            let y = (0.299 * c.r as f64 + 0.587 * c.g as f64 + 0.114 * c.b as f64)
489                .round()
490                .clamp(0.0, 255.0) as u8;
491            Rgba {
492                r: y,
493                g: y,
494                b: y,
495                a: c.a,
496            }
497        }
498        ColorTransform::RedMod(v) => Rgba {
499            r: (c.r as f64 * pct(v)).round().clamp(0.0, 255.0) as u8,
500            ..c
501        },
502        ColorTransform::GreenMod(v) => Rgba {
503            g: (c.g as f64 * pct(v)).round().clamp(0.0, 255.0) as u8,
504            ..c
505        },
506        ColorTransform::BlueMod(v) => Rgba {
507            b: (c.b as f64 * pct(v)).round().clamp(0.0, 255.0) as u8,
508            ..c
509        },
510    }
511}
512
513fn to_lin(v: u8) -> f64 {
514    let v = v as f64 / 255.0;
515    if v <= 0.04045 {
516        v / 12.92
517    } else {
518        ((v + 0.055) / 1.055).powf(2.4)
519    }
520}
521fn from_lin(v: f64) -> f64 {
522    let v = v.clamp(0.0, 1.0);
523    if v <= 0.003_130_8 {
524        v * 12.92
525    } else {
526        1.055 * v.powf(1.0 / 2.4) - 0.055
527    }
528}
529fn map_linear(c: Rgba, f: impl Fn(f64) -> f64) -> Rgba {
530    Rgba::from_f64(
531        from_lin(f(to_lin(c.r))),
532        from_lin(f(to_lin(c.g))),
533        from_lin(f(to_lin(c.b))),
534        c.a as f64 / 255.0,
535    )
536}
537
538/// The colour-picker grid shown under each theme colour: lighter 80/60/40%, darker 25/50%
539/// (for dark colours: lighter 90/75/50/25/10%).
540pub fn theme_variants(c: Rgba) -> [(Vec<ColorTransform>, &'static str); 5] {
541    let (_, _, l) = c.to_hsl();
542    if l < 0.2 {
543        [
544            (
545                vec![ColorTransform::LumMod(10000), ColorTransform::LumOff(90000)],
546                "Lighter 90%",
547            ),
548            (
549                vec![ColorTransform::LumMod(25000), ColorTransform::LumOff(75000)],
550                "Lighter 75%",
551            ),
552            (
553                vec![ColorTransform::LumMod(50000), ColorTransform::LumOff(50000)],
554                "Lighter 50%",
555            ),
556            (
557                vec![ColorTransform::LumMod(75000), ColorTransform::LumOff(25000)],
558                "Lighter 25%",
559            ),
560            (
561                vec![ColorTransform::LumMod(90000), ColorTransform::LumOff(10000)],
562                "Lighter 10%",
563            ),
564        ]
565    } else if l > 0.8 {
566        [
567            (vec![ColorTransform::LumMod(95000)], "Darker 5%"),
568            (vec![ColorTransform::LumMod(85000)], "Darker 15%"),
569            (vec![ColorTransform::LumMod(75000)], "Darker 25%"),
570            (vec![ColorTransform::LumMod(65000)], "Darker 35%"),
571            (vec![ColorTransform::LumMod(50000)], "Darker 50%"),
572        ]
573    } else {
574        [
575            (
576                vec![ColorTransform::LumMod(20000), ColorTransform::LumOff(80000)],
577                "Lighter 80%",
578            ),
579            (
580                vec![ColorTransform::LumMod(40000), ColorTransform::LumOff(60000)],
581                "Lighter 60%",
582            ),
583            (
584                vec![ColorTransform::LumMod(60000), ColorTransform::LumOff(40000)],
585                "Lighter 40%",
586            ),
587            (vec![ColorTransform::LumMod(75000)], "Darker 25%"),
588            (vec![ColorTransform::LumMod(50000)], "Darker 50%"),
589        ]
590    }
591}
592
593/// Standard colours row of the colour picker.
594pub const STANDARD_COLORS: [(Rgba, &str); 10] = [
595    (Rgba::rgb(0xC0, 0x00, 0x00), "Dark Red"),
596    (Rgba::rgb(0xFF, 0x00, 0x00), "Red"),
597    (Rgba::rgb(0xFF, 0xC0, 0x00), "Orange"),
598    (Rgba::rgb(0xFF, 0xFF, 0x00), "Yellow"),
599    (Rgba::rgb(0x92, 0xD0, 0x50), "Light Green"),
600    (Rgba::rgb(0x00, 0xB0, 0x50), "Green"),
601    (Rgba::rgb(0x00, 0xB0, 0xF0), "Light Blue"),
602    (Rgba::rgb(0x00, 0x70, 0xC0), "Blue"),
603    (Rgba::rgb(0x00, 0x20, 0x60), "Dark Blue"),
604    (Rgba::rgb(0x70, 0x30, 0xA0), "Purple"),
605];
606
607/// A few CSS-like preset colour names (`prstClr`).
608pub fn preset(name: &str) -> Option<Rgba> {
609    let c = match name {
610        "black" => (0, 0, 0),
611        "white" => (255, 255, 255),
612        "red" => (255, 0, 0),
613        "green" => (0, 128, 0),
614        "lime" => (0, 255, 0),
615        "blue" => (0, 0, 255),
616        "yellow" => (255, 255, 0),
617        "cyan" | "aqua" => (0, 255, 255),
618        "magenta" | "fuchsia" => (255, 0, 255),
619        "gray" | "grey" => (128, 128, 128),
620        "silver" => (192, 192, 192),
621        "maroon" => (128, 0, 0),
622        "navy" => (0, 0, 128),
623        "olive" => (128, 128, 0),
624        "purple" => (128, 0, 128),
625        "teal" => (0, 128, 128),
626        "orange" => (255, 165, 0),
627        "pink" => (255, 192, 203),
628        "brown" => (165, 42, 42),
629        "gold" => (255, 215, 0),
630        "ltGray" | "lightGray" => (211, 211, 211),
631        "dkGray" | "darkGray" => (169, 169, 169),
632        _ => return None,
633    };
634    Some(Rgba::rgb(c.0, c.1, c.2))
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640
641    #[test]
642    fn hex_roundtrip() {
643        assert_eq!(Rgba::from_hex("#156082"), Some(Rgba::rgb(0x15, 0x60, 0x82)));
644        assert_eq!(Rgba::rgb(0x15, 0x60, 0x82).hex(), "156082");
645        assert_eq!(Rgba::from_hex("fff"), Some(Rgba::WHITE));
646        assert_eq!(Rgba::from_hex("zz0000"), None);
647        assert_eq!(Rgba::from_hex("ééé"), None);
648        assert_eq!(Rgba::from_hex(""), None);
649    }
650
651    #[test]
652    fn hsl_roundtrip() {
653        for c in [
654            Rgba::rgb(10, 200, 30),
655            Rgba::rgb(255, 0, 0),
656            Rgba::rgb(128, 128, 128),
657            Rgba::rgb(21, 96, 130),
658        ] {
659            let (h, s, l) = c.to_hsl();
660            let d = Rgba::from_hsl(h, s, l, 255);
661            assert!(
662                (d.r as i32 - c.r as i32).abs() <= 1
663                    && (d.g as i32 - c.g as i32).abs() <= 1
664                    && (d.b as i32 - c.b as i32).abs() <= 1,
665                "{c:?} {d:?}"
666            );
667        }
668    }
669
670    #[test]
671    fn transforms() {
672        let c = Rgba::rgb(0x40, 0x80, 0xC0);
673        assert_eq!(apply(c, &[ColorTransform::Alpha(50000)]).a, 128);
674        let lighter = apply(
675            c,
676            &[ColorTransform::LumMod(20000), ColorTransform::LumOff(80000)],
677        );
678        assert!(lighter.luminance() > c.luminance());
679        let darker = apply(c, &[ColorTransform::LumMod(50000)]);
680        assert!(darker.luminance() < c.luminance());
681        assert_eq!(apply(Rgba::WHITE, &[ColorTransform::Shade(0)]), Rgba::BLACK);
682        assert_eq!(apply(Rgba::BLACK, &[ColorTransform::Tint(0)]), Rgba::WHITE);
683        assert_eq!(
684            apply(c, &[ColorTransform::Inv]),
685            Rgba::rgb(0xBF, 0x7F, 0x3F)
686        );
687        // hostile values never panic
688        let _ = apply(
689            c,
690            &[
691                ColorTransform::LumMod(i32::MAX),
692                ColorTransform::HueOff(i32::MIN),
693                ColorTransform::AlphaMod(i32::MIN),
694            ],
695        );
696    }
697
698    #[test]
699    fn contrast_ratio() {
700        assert!((Rgba::BLACK.contrast(Rgba::WHITE) - 21.0).abs() < 0.01);
701        assert!((Rgba::WHITE.contrast(Rgba::WHITE) - 1.0).abs() < 1e-9);
702    }
703
704    #[test]
705    fn scheme_slots() {
706        let mut s = ColorScheme {
707            name: "t".into(),
708            colors: [Rgba::BLACK; 12],
709        };
710        s.set(SchemeSlot::Accent3, Rgba::WHITE);
711        assert_eq!(s.get(SchemeSlot::Accent3), Rgba::WHITE);
712        assert_eq!(s.get(SchemeSlot::Tx1), Rgba::BLACK);
713        for slot in SchemeSlot::THEME {
714            assert_eq!(SchemeSlot::from_xml(slot.xml_name()), Some(slot));
715        }
716    }
717}