1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use std::{error::Error, fmt};
5
6pub mod prelude;
7
8#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
9pub struct Rgb {
10 pub red: u8,
11 pub green: u8,
12 pub blue: u8,
13}
14
15impl Rgb {
16 #[must_use]
17 pub const fn new(red: u8, green: u8, blue: u8) -> Self {
18 Self { red, green, blue }
19 }
20
21 #[must_use]
22 pub const fn is_grayscale(self) -> bool {
23 self.red == self.green && self.green == self.blue
24 }
25
26 #[must_use]
27 pub fn to_hex_rgb(self) -> String {
28 format!("#{:02X}{:02X}{:02X}", self.red, self.green, self.blue)
29 }
30
31 #[must_use]
32 pub fn relative_luminance(self) -> f64 {
33 0.2126 * srgb_channel_to_linear(self.red)
34 + 0.7152 * srgb_channel_to_linear(self.green)
35 + 0.0722 * srgb_channel_to_linear(self.blue)
36 }
37}
38
39pub const BLACK: Rgb = Rgb::new(0, 0, 0);
40pub const WHITE: Rgb = Rgb::new(255, 255, 255);
41pub const RED: Rgb = Rgb::new(255, 0, 0);
42pub const GREEN: Rgb = Rgb::new(0, 255, 0);
43pub const BLUE: Rgb = Rgb::new(0, 0, 255);
44
45#[derive(Clone, Debug, PartialEq, Eq)]
46pub enum HexColorError {
47 InvalidLength(usize),
48 InvalidCharacter,
49}
50
51impl fmt::Display for HexColorError {
52 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
53 match self {
54 Self::InvalidLength(length) => {
55 write!(formatter, "hex color must contain 6 digits, got {length}")
56 },
57 Self::InvalidCharacter => formatter.write_str("hex color contains invalid digits"),
58 }
59 }
60}
61
62impl Error for HexColorError {}
63
64pub fn parse_hex_rgb(input: &str) -> Result<Rgb, HexColorError> {
65 let value = input.strip_prefix('#').unwrap_or(input);
66
67 if value.len() != 6 {
68 return Err(HexColorError::InvalidLength(value.len()));
69 }
70
71 let red = parse_hex_pair(&value[0..2])?;
72 let green = parse_hex_pair(&value[2..4])?;
73 let blue = parse_hex_pair(&value[4..6])?;
74
75 Ok(Rgb::new(red, green, blue))
76}
77
78fn parse_hex_pair(pair: &str) -> Result<u8, HexColorError> {
79 u8::from_str_radix(pair, 16).map_err(|_| HexColorError::InvalidCharacter)
80}
81
82fn srgb_channel_to_linear(channel: u8) -> f64 {
83 let srgb = f64::from(channel) / 255.0;
84
85 if srgb <= 0.04045 {
86 srgb / 12.92
87 } else {
88 ((srgb + 0.055) / 1.055).powf(2.4)
89 }
90}
91
92#[cfg(test)]
93mod tests {
94 use super::{BLACK, HexColorError, Rgb, WHITE, parse_hex_rgb};
95
96 #[test]
97 fn parses_hex_colors() {
98 let color = parse_hex_rgb("#3366CC").expect("hex color should parse");
99
100 assert_eq!(color, Rgb::new(0x33, 0x66, 0xCC));
101 assert_eq!(color.to_hex_rgb(), "#3366CC");
102 }
103
104 #[test]
105 fn rejects_invalid_hex_colors() {
106 assert_eq!(parse_hex_rgb("#FFF"), Err(HexColorError::InvalidLength(3)));
107 assert_eq!(
108 parse_hex_rgb("#GG0000"),
109 Err(HexColorError::InvalidCharacter)
110 );
111 }
112
113 #[test]
114 fn detects_grayscale_and_luminance() {
115 assert!(Rgb::new(80, 80, 80).is_grayscale());
116 assert!(!Rgb::new(80, 81, 80).is_grayscale());
117 assert!(WHITE.relative_luminance() > BLACK.relative_luminance());
118 }
119}