1#![forbid(unsafe_code)]
35#![doc(html_root_url = "https://docs.rs/nearest-color/0.1.0")]
36#![allow(
37 clippy::cast_precision_loss,
38 clippy::cast_possible_truncation,
39 clippy::must_use_candidate
40)]
41
42use std::fmt;
43
44#[cfg(doctest)]
46#[doc = include_str!("../README.md")]
47struct ReadmeDoctests;
48
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
52pub struct Rgb {
53 pub r: i32,
55 pub g: i32,
57 pub b: i32,
59}
60
61#[derive(Debug, Clone, PartialEq)]
63pub struct ColorMatch {
64 pub name: Option<String>,
66 pub value: String,
68 pub rgb: Rgb,
70 pub distance: f64,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq)]
76pub struct InvalidColor(pub String);
77
78impl fmt::Display for InvalidColor {
79 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
80 write!(f, "\"{}\" is not a valid color", self.0)
81 }
82}
83
84impl std::error::Error for InvalidColor {}
85
86pub const STANDARD_COLORS: &[(&str, &str)] = &[
88 ("aqua", "#0ff"),
89 ("black", "#000"),
90 ("blue", "#00f"),
91 ("fuchsia", "#f0f"),
92 ("gray", "#808080"),
93 ("green", "#008000"),
94 ("lime", "#0f0"),
95 ("maroon", "#800000"),
96 ("navy", "#000080"),
97 ("olive", "#808000"),
98 ("orange", "#ffa500"),
99 ("purple", "#800080"),
100 ("red", "#f00"),
101 ("silver", "#c0c0c0"),
102 ("teal", "#008080"),
103 ("white", "#fff"),
104 ("yellow", "#ff0"),
105];
106
107const DEFAULT_COLORS: &[&str] = &["#f00", "#f80", "#ff0", "#0f0", "#00f", "#008", "#808"];
109
110pub fn parse_color(source: &str) -> Result<Rgb, InvalidColor> {
127 if let Some((_, hex)) = STANDARD_COLORS.iter().find(|(name, _)| *name == source) {
128 return parse_color(hex);
129 }
130 if let Some(rgb) = parse_hex(source) {
131 return Ok(rgb);
132 }
133 if let Some(rgb) = parse_rgb(source) {
134 return Ok(rgb);
135 }
136 Err(InvalidColor(source.to_string()))
137}
138
139fn parse_hex(source: &str) -> Option<Rgb> {
141 let hex = source.strip_prefix('#').unwrap_or(source);
142 if !(hex.len() == 3 || hex.len() == 6) || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
143 return None;
144 }
145 let pair = |a: u8, b: u8| -> i32 { i32::from(hex_val(a)) * 16 + i32::from(hex_val(b)) };
146 let bytes = hex.as_bytes();
147 let rgb = if hex.len() == 3 {
148 Rgb {
149 r: pair(bytes[0], bytes[0]),
150 g: pair(bytes[1], bytes[1]),
151 b: pair(bytes[2], bytes[2]),
152 }
153 } else {
154 Rgb {
155 r: pair(bytes[0], bytes[1]),
156 g: pair(bytes[2], bytes[3]),
157 b: pair(bytes[4], bytes[5]),
158 }
159 };
160 Some(rgb)
161}
162
163fn hex_val(b: u8) -> u8 {
164 match b {
165 b'0'..=b'9' => b - b'0',
166 b'a'..=b'f' => b - b'a' + 10,
167 b'A'..=b'F' => b - b'A' + 10,
168 _ => 0,
169 }
170}
171
172fn parse_rgb(source: &str) -> Option<Rgb> {
174 const WS: [char; 6] = [' ', '\t', '\n', '\r', '\u{0c}', '\u{0b}'];
175 let lower = source.to_ascii_lowercase();
177 let inner = lower.strip_prefix("rgb(")?.strip_suffix(')')?;
178 let segs: Vec<&str> = inner.split(',').collect();
179 if segs.len() != 3 {
180 return None;
181 }
182 let r = parse_component(segs[0].trim_start_matches(WS))?;
186 let g = parse_component(segs[1].trim_start_matches(WS))?;
187 let b = parse_component(segs[2].trim_start_matches(WS).trim_end_matches(WS))?;
188 Some(Rgb { r, g, b })
189}
190
191fn parse_component(s: &str) -> Option<i32> {
193 let (digits, percent) = match s.strip_suffix('%') {
194 Some(d) => (d, true),
195 None => (s, false),
196 };
197 if digits.is_empty() || digits.len() > 3 || !digits.bytes().all(|b| b.is_ascii_digit()) {
198 return None;
199 }
200 let n: i32 = digits.parse().ok()?;
201 if percent {
202 Some(((f64::from(n) * 255.0 / 100.0) + 0.5).floor() as i32)
204 } else {
205 Some(n)
206 }
207}
208
209#[derive(Debug, Clone)]
210struct Spec {
211 name: Option<String>,
212 source: String,
213 rgb: Rgb,
214}
215
216#[derive(Debug, Clone)]
218pub struct Matcher {
219 colors: Vec<Spec>,
220}
221
222impl Matcher {
223 pub fn from_colors(colors: &[&str]) -> Result<Self, InvalidColor> {
228 let colors = colors
229 .iter()
230 .map(|&c| {
231 Ok(Spec {
232 name: None,
233 source: c.to_string(),
234 rgb: parse_color(c)?,
235 })
236 })
237 .collect::<Result<Vec<_>, _>>()?;
238 Ok(Self { colors })
239 }
240
241 pub fn from_named(colors: &[(&str, &str)]) -> Result<Self, InvalidColor> {
246 let colors = colors
247 .iter()
248 .map(|&(name, c)| {
249 Ok(Spec {
250 name: Some(name.to_string()),
251 source: c.to_string(),
252 rgb: parse_color(c)?,
253 })
254 })
255 .collect::<Result<Vec<_>, _>>()?;
256 Ok(Self { colors })
257 }
258
259 #[must_use]
261 pub fn or(mut self, mut other: Matcher) -> Matcher {
262 self.colors.append(&mut other.colors);
263 self
264 }
265
266 pub fn nearest(&self, needle: &str) -> Result<Option<ColorMatch>, InvalidColor> {
272 let needle = parse_color(needle)?;
273 let mut best: Option<(&Spec, i64)> = None;
274 for spec in &self.colors {
275 let dr = i64::from(needle.r - spec.rgb.r);
276 let dg = i64::from(needle.g - spec.rgb.g);
277 let db = i64::from(needle.b - spec.rgb.b);
278 let dist_sq = dr * dr + dg * dg + db * db;
279 if best.map_or(true, |(_, bd)| dist_sq < bd) {
280 best = Some((spec, dist_sq));
281 }
282 }
283 Ok(best.map(|(spec, dist_sq)| ColorMatch {
284 name: spec.name.clone(),
285 value: spec.source.clone(),
286 rgb: spec.rgb,
287 #[allow(clippy::cast_precision_loss)]
288 distance: (dist_sq as f64).sqrt(),
289 }))
290 }
291}
292
293#[must_use]
298pub fn default_matcher() -> Matcher {
299 Matcher::from_colors(DEFAULT_COLORS).expect("default colors are valid")
300}
301
302pub fn nearest_color(needle: &str) -> Result<ColorMatch, InvalidColor> {
315 Ok(default_matcher()
316 .nearest(needle)?
317 .expect("default palette is non-empty"))
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 #[test]
325 fn parses_hex_forms() {
326 assert_eq!(parse_color("#f00").unwrap(), Rgb { r: 255, g: 0, b: 0 });
327 assert_eq!(parse_color("f00").unwrap(), Rgb { r: 255, g: 0, b: 0 });
328 assert_eq!(
329 parse_color("#04fbc8").unwrap(),
330 Rgb {
331 r: 4,
332 g: 251,
333 b: 200
334 }
335 );
336 assert_eq!(
337 parse_color("#FF0").unwrap(),
338 Rgb {
339 r: 255,
340 g: 255,
341 b: 0
342 }
343 );
344 assert_eq!(
345 parse_color("abc").unwrap(),
346 Rgb {
347 r: 170,
348 g: 187,
349 b: 204
350 }
351 );
352 }
353
354 #[test]
355 fn parses_rgb_and_names() {
356 assert_eq!(
357 parse_color("rgb(3, 10, 100)").unwrap(),
358 Rgb {
359 r: 3,
360 g: 10,
361 b: 100
362 }
363 );
364 assert_eq!(
365 parse_color("rgb(50%, 0%, 50%)").unwrap(),
366 Rgb {
367 r: 128,
368 g: 0,
369 b: 128
370 }
371 );
372 assert_eq!(
373 parse_color("aqua").unwrap(),
374 Rgb {
375 r: 0,
376 g: 255,
377 b: 255
378 }
379 );
380 assert_eq!(
381 parse_color("gray").unwrap(),
382 Rgb {
383 r: 128,
384 g: 128,
385 b: 128
386 }
387 );
388 }
389
390 #[test]
391 fn rejects_invalid() {
392 for bad in ["foo", "#ff", "#fffff", "rgb(1,2)", "Red", "", "#12g"] {
393 assert!(parse_color(bad).is_err(), "{bad:?} should be invalid");
394 }
395 }
396
397 #[test]
398 fn default_nearest() {
399 assert_eq!(nearest_color("#f11").unwrap().value, "#f00");
400 assert_eq!(nearest_color("#f88").unwrap().value, "#f80");
401 assert_eq!(nearest_color("#ffe").unwrap().value, "#ff0");
402 assert_eq!(nearest_color("#efe").unwrap().value, "#ff0");
403 assert_eq!(nearest_color("#abc").unwrap().value, "#808");
404 assert_eq!(nearest_color("red").unwrap().value, "#f00");
405 }
406
407 #[test]
408 fn named_match_has_metadata() {
409 let m = Matcher::from_named(&[("maroon", "#800"), ("white", "fff")]).unwrap();
410 let got = m.nearest("#f00").unwrap().unwrap();
411 assert_eq!(got.name.as_deref(), Some("maroon"));
412 assert_eq!(got.value, "#800");
413 assert_eq!(got.rgb, Rgb { r: 136, g: 0, b: 0 });
414 assert!((got.distance - 119.0).abs() < 0.5);
415 }
416
417 #[test]
418 fn combine_with_or() {
419 let a = Matcher::from_colors(&["#eee"]).unwrap();
420 let b = Matcher::from_colors(&["#444"]).unwrap();
421 let both = a.or(b);
422 assert_eq!(both.nearest("#000").unwrap().unwrap().value, "#444");
423 assert_eq!(both.nearest("#fff").unwrap().unwrap().value, "#eee");
424 }
425
426 #[test]
427 fn ties_keep_first() {
428 let m = Matcher::from_colors(&["#000", "#222"]).unwrap();
430 assert_eq!(m.nearest("#111").unwrap().unwrap().value, "#000");
431 }
432}