|
| 1 | +use png::{BitDepth, ColorType, Encoder}; |
| 2 | +use rand::Rng; |
| 3 | +use std::io::Cursor; |
| 4 | + |
| 5 | +#[derive(Debug, Clone)] |
| 6 | +pub struct ColorQuiz { |
| 7 | + pub r: u8, |
| 8 | + pub g: u8, |
| 9 | + pub b: u8, |
| 10 | +} |
| 11 | + |
| 12 | +impl ColorQuiz { |
| 13 | + /// Generate a random color quiz |
| 14 | + pub fn generate<R: Rng>(rng: &mut R) -> Self { |
| 15 | + Self { |
| 16 | + r: rng.gen_range(0..=255), |
| 17 | + g: rng.gen_range(0..=255), |
| 18 | + b: rng.gen_range(0..=255), |
| 19 | + } |
| 20 | + } |
| 21 | + |
| 22 | + /// Generate a 16:9 image (640x360) with the color |
| 23 | + pub fn generate_image(&self) -> Result<Vec<u8>, Box<dyn std::error::Error>> { |
| 24 | + let width = 640u32; |
| 25 | + let height = 360u32; |
| 26 | + |
| 27 | + let mut buffer = Cursor::new(Vec::new()); |
| 28 | + |
| 29 | + { |
| 30 | + let mut encoder = Encoder::new(&mut buffer, width, height); |
| 31 | + encoder.set_color(ColorType::Rgb); |
| 32 | + encoder.set_depth(BitDepth::Eight); |
| 33 | + |
| 34 | + let mut writer = encoder.write_header()?; |
| 35 | + |
| 36 | + // Create image data - each pixel is RGB (3 bytes) |
| 37 | + let pixel_count = (width * height) as usize; |
| 38 | + let mut data = Vec::with_capacity(pixel_count * 3); |
| 39 | + |
| 40 | + for _ in 0..pixel_count { |
| 41 | + data.push(self.r); |
| 42 | + data.push(self.g); |
| 43 | + data.push(self.b); |
| 44 | + } |
| 45 | + |
| 46 | + writer.write_image_data(&data)?; |
| 47 | + } |
| 48 | + |
| 49 | + Ok(buffer.into_inner()) |
| 50 | + } |
| 51 | + |
| 52 | + /// Validate user's answer in hex or oklch format |
| 53 | + pub fn validate_answer(&self, user_answer: &str) -> bool { |
| 54 | + let answer = user_answer.trim(); |
| 55 | + |
| 56 | + // Try hex format first |
| 57 | + if answer.starts_with('#') { |
| 58 | + return self.validate_hex(answer); |
| 59 | + } |
| 60 | + |
| 61 | + // Try oklch format |
| 62 | + self.validate_oklch(answer) |
| 63 | + } |
| 64 | + |
| 65 | + /// Validate hex color format: #RRGGBB |
| 66 | + /// Tolerance: total difference of 20 across all channels |
| 67 | + fn validate_hex(&self, hex: &str) -> bool { |
| 68 | + // Remove the # and parse |
| 69 | + let hex = hex.trim_start_matches('#'); |
| 70 | + |
| 71 | + if hex.len() != 6 { |
| 72 | + return false; |
| 73 | + } |
| 74 | + |
| 75 | + let r = u8::from_str_radix(&hex[0..2], 16).ok(); |
| 76 | + let g = u8::from_str_radix(&hex[2..4], 16).ok(); |
| 77 | + let b = u8::from_str_radix(&hex[4..6], 16).ok(); |
| 78 | + |
| 79 | + if let (Some(r), Some(g), Some(b)) = (r, g, b) { |
| 80 | + let diff = (self.r as i32 - r as i32).abs() |
| 81 | + + (self.g as i32 - g as i32).abs() |
| 82 | + + (self.b as i32 - b as i32).abs(); |
| 83 | + |
| 84 | + return diff <= 20; |
| 85 | + } |
| 86 | + |
| 87 | + false |
| 88 | + } |
| 89 | + |
| 90 | + /// Validate oklch color format |
| 91 | + /// Supports: oklch(45.0% 0.306 65.4), 45.0% 0.306 65.4, 45.0%, 0.306, 65.4 |
| 92 | + /// Converts RGB to OKLCH and checks similarity |
| 93 | + fn validate_oklch(&self, input: &str) -> bool { |
| 94 | + // Parse the input |
| 95 | + let parsed = self.parse_oklch(input); |
| 96 | + if parsed.is_none() { |
| 97 | + return false; |
| 98 | + } |
| 99 | + |
| 100 | + let (l_user, c_user, h_user) = parsed.unwrap(); |
| 101 | + |
| 102 | + // Convert our RGB to OKLCH |
| 103 | + let (l_actual, c_actual, h_actual) = rgb_to_oklch(self.r, self.g, self.b); |
| 104 | + |
| 105 | + // Check if close enough |
| 106 | + // L: within 5% (0-100 scale) |
| 107 | + // C: within 0.05 |
| 108 | + // H: within 10 degrees (handle wrap-around at 360) |
| 109 | + let l_diff = (l_actual - l_user).abs(); |
| 110 | + let c_diff = (c_actual - c_user).abs(); |
| 111 | + let h_diff = { |
| 112 | + let diff = (h_actual - h_user).abs(); |
| 113 | + diff.min(360.0 - diff) // Handle wrap-around |
| 114 | + }; |
| 115 | + |
| 116 | + l_diff <= 5.0 && c_diff <= 0.05 && h_diff <= 10.0 |
| 117 | + } |
| 118 | + |
| 119 | + /// Parse OKLCH from various formats |
| 120 | + fn parse_oklch(&self, input: &str) -> Option<(f64, f64, f64)> { |
| 121 | + let input = input.trim(); |
| 122 | + |
| 123 | + // Remove oklch( and ) if present |
| 124 | + let inner = input |
| 125 | + .strip_prefix("oklch(") |
| 126 | + .and_then(|s| s.strip_suffix(')')) |
| 127 | + .unwrap_or(input); |
| 128 | + |
| 129 | + // Split by whitespace or comma |
| 130 | + let parts: Vec<&str> = inner |
| 131 | + .split(|c: char| c.is_whitespace() || c == ',') |
| 132 | + .filter(|s| !s.is_empty()) |
| 133 | + .collect(); |
| 134 | + |
| 135 | + if parts.len() != 3 { |
| 136 | + return None; |
| 137 | + } |
| 138 | + |
| 139 | + // Parse L (can have % suffix) |
| 140 | + let l = parts[0].trim_end_matches('%').parse::<f64>().ok()?; |
| 141 | + |
| 142 | + // Parse C |
| 143 | + let c = parts[1].parse::<f64>().ok()?; |
| 144 | + |
| 145 | + // Parse H |
| 146 | + let h = parts[2].parse::<f64>().ok()?; |
| 147 | + |
| 148 | + Some((l, c, h)) |
| 149 | + } |
| 150 | +} |
| 151 | + |
| 152 | +/// Convert RGB to OKLCH color space |
| 153 | +/// This is a simplified approximation |
| 154 | +fn rgb_to_oklch(r: u8, g: u8, b: u8) -> (f64, f64, f64) { |
| 155 | + // First convert to linear RGB |
| 156 | + let r_linear = srgb_to_linear(r as f64 / 255.0); |
| 157 | + let g_linear = srgb_to_linear(g as f64 / 255.0); |
| 158 | + let b_linear = srgb_to_linear(b as f64 / 255.0); |
| 159 | + |
| 160 | + // Convert to OKLab using the matrix transformation |
| 161 | + let l = 0.4122214708 * r_linear + 0.5363325363 * g_linear + 0.0514459929 * b_linear; |
| 162 | + let m = 0.2119034982 * r_linear + 0.6806995451 * g_linear + 0.1073969566 * b_linear; |
| 163 | + let s = 0.0883024619 * r_linear + 0.2817188376 * g_linear + 0.6299787005 * b_linear; |
| 164 | + |
| 165 | + let l_ = l.cbrt(); |
| 166 | + let m_ = m.cbrt(); |
| 167 | + let s_ = s.cbrt(); |
| 168 | + |
| 169 | + let l_oklab = 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_; |
| 170 | + let a_oklab = 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_; |
| 171 | + let b_oklab = 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_; |
| 172 | + |
| 173 | + // Convert OKLab to OKLCH |
| 174 | + let l_oklch = l_oklab * 100.0; // Convert to percentage |
| 175 | + let c_oklch = (a_oklab * a_oklab + b_oklab * b_oklab).sqrt(); |
| 176 | + let h_oklch = b_oklab.atan2(a_oklab).to_degrees(); |
| 177 | + let h_oklch = if h_oklch < 0.0 { |
| 178 | + h_oklch + 360.0 |
| 179 | + } else { |
| 180 | + h_oklch |
| 181 | + }; |
| 182 | + |
| 183 | + (l_oklch, c_oklch, h_oklch) |
| 184 | +} |
| 185 | + |
| 186 | +/// Convert sRGB to linear RGB |
| 187 | +fn srgb_to_linear(c: f64) -> f64 { |
| 188 | + if c <= 0.04045 { |
| 189 | + c / 12.92 |
| 190 | + } else { |
| 191 | + ((c + 0.055) / 1.055).powf(2.4) |
| 192 | + } |
| 193 | +} |
| 194 | + |
| 195 | +#[cfg(test)] |
| 196 | +mod tests { |
| 197 | + use super::*; |
| 198 | + |
| 199 | + #[test] |
| 200 | + fn test_hex_validation() { |
| 201 | + let quiz = ColorQuiz { |
| 202 | + r: 200, |
| 203 | + g: 200, |
| 204 | + b: 200, |
| 205 | + }; |
| 206 | + |
| 207 | + // Exact match |
| 208 | + assert!(quiz.validate_answer("#c8c8c8")); |
| 209 | + |
| 210 | + // Within tolerance (diff = 10) |
| 211 | + assert!(quiz.validate_answer("#bec8ca")); |
| 212 | + |
| 213 | + // At boundary (diff = 20) |
| 214 | + assert!(quiz.validate_answer("#b4c8c8")); |
| 215 | + |
| 216 | + // Outside tolerance (diff = 21) |
| 217 | + assert!(!quiz.validate_answer("#b3c8c8")); |
| 218 | + } |
| 219 | + |
| 220 | + #[test] |
| 221 | + fn test_oklch_parsing() { |
| 222 | + let quiz = ColorQuiz { |
| 223 | + r: 200, |
| 224 | + g: 200, |
| 225 | + b: 200, |
| 226 | + }; |
| 227 | + |
| 228 | + // Test various formats |
| 229 | + assert!(quiz.parse_oklch("oklch(45.0% 0.306 65.4)").is_some()); |
| 230 | + assert!(quiz.parse_oklch("45.0% 0.306 65.4").is_some()); |
| 231 | + assert!(quiz.parse_oklch("45.0%, 0.306, 65.4").is_some()); |
| 232 | + assert!(quiz.parse_oklch("45 0.306 65.4").is_some()); |
| 233 | + } |
| 234 | + |
| 235 | + #[test] |
| 236 | + fn test_rgb_to_oklch() { |
| 237 | + // Test with a known color (approximate values) |
| 238 | + let (l, c, h) = rgb_to_oklch(255, 0, 0); // Red |
| 239 | + assert!(l > 60.0 && l < 70.0); // Lightness around 62-63% |
| 240 | + assert!(c > 0.25); // High chroma |
| 241 | + assert!(h > 20.0 && h < 40.0); // Hue around 29 degrees |
| 242 | + } |
| 243 | +} |
0 commit comments