#![forbid(unsafe_code)]
#![doc(html_root_url = "https://docs.rs/nearest-color/0.1.0")]
#![allow(
clippy::cast_precision_loss,
clippy::cast_possible_truncation,
clippy::must_use_candidate
)]
use std::fmt;
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
struct ReadmeDoctests;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Rgb {
pub r: i32,
pub g: i32,
pub b: i32,
}
#[derive(Debug, Clone, PartialEq)]
pub struct ColorMatch {
pub name: Option<String>,
pub value: String,
pub rgb: Rgb,
pub distance: f64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InvalidColor(pub String);
impl fmt::Display for InvalidColor {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "\"{}\" is not a valid color", self.0)
}
}
impl std::error::Error for InvalidColor {}
pub const STANDARD_COLORS: &[(&str, &str)] = &[
("aqua", "#0ff"),
("black", "#000"),
("blue", "#00f"),
("fuchsia", "#f0f"),
("gray", "#808080"),
("green", "#008000"),
("lime", "#0f0"),
("maroon", "#800000"),
("navy", "#000080"),
("olive", "#808000"),
("orange", "#ffa500"),
("purple", "#800080"),
("red", "#f00"),
("silver", "#c0c0c0"),
("teal", "#008080"),
("white", "#fff"),
("yellow", "#ff0"),
];
const DEFAULT_COLORS: &[&str] = &["#f00", "#f80", "#ff0", "#0f0", "#00f", "#008", "#808"];
pub fn parse_color(source: &str) -> Result<Rgb, InvalidColor> {
if let Some((_, hex)) = STANDARD_COLORS.iter().find(|(name, _)| *name == source) {
return parse_color(hex);
}
if let Some(rgb) = parse_hex(source) {
return Ok(rgb);
}
if let Some(rgb) = parse_rgb(source) {
return Ok(rgb);
}
Err(InvalidColor(source.to_string()))
}
fn parse_hex(source: &str) -> Option<Rgb> {
let hex = source.strip_prefix('#').unwrap_or(source);
if !(hex.len() == 3 || hex.len() == 6) || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
let pair = |a: u8, b: u8| -> i32 { i32::from(hex_val(a)) * 16 + i32::from(hex_val(b)) };
let bytes = hex.as_bytes();
let rgb = if hex.len() == 3 {
Rgb {
r: pair(bytes[0], bytes[0]),
g: pair(bytes[1], bytes[1]),
b: pair(bytes[2], bytes[2]),
}
} else {
Rgb {
r: pair(bytes[0], bytes[1]),
g: pair(bytes[2], bytes[3]),
b: pair(bytes[4], bytes[5]),
}
};
Some(rgb)
}
fn hex_val(b: u8) -> u8 {
match b {
b'0'..=b'9' => b - b'0',
b'a'..=b'f' => b - b'a' + 10,
b'A'..=b'F' => b - b'A' + 10,
_ => 0,
}
}
fn parse_rgb(source: &str) -> Option<Rgb> {
const WS: [char; 6] = [' ', '\t', '\n', '\r', '\u{0c}', '\u{0b}'];
let lower = source.to_ascii_lowercase();
let inner = lower.strip_prefix("rgb(")?.strip_suffix(')')?;
let segs: Vec<&str> = inner.split(',').collect();
if segs.len() != 3 {
return None;
}
let r = parse_component(segs[0].trim_start_matches(WS))?;
let g = parse_component(segs[1].trim_start_matches(WS))?;
let b = parse_component(segs[2].trim_start_matches(WS).trim_end_matches(WS))?;
Some(Rgb { r, g, b })
}
fn parse_component(s: &str) -> Option<i32> {
let (digits, percent) = match s.strip_suffix('%') {
Some(d) => (d, true),
None => (s, false),
};
if digits.is_empty() || digits.len() > 3 || !digits.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let n: i32 = digits.parse().ok()?;
if percent {
Some(((f64::from(n) * 255.0 / 100.0) + 0.5).floor() as i32)
} else {
Some(n)
}
}
#[derive(Debug, Clone)]
struct Spec {
name: Option<String>,
source: String,
rgb: Rgb,
}
#[derive(Debug, Clone)]
pub struct Matcher {
colors: Vec<Spec>,
}
impl Matcher {
pub fn from_colors(colors: &[&str]) -> Result<Self, InvalidColor> {
let colors = colors
.iter()
.map(|&c| {
Ok(Spec {
name: None,
source: c.to_string(),
rgb: parse_color(c)?,
})
})
.collect::<Result<Vec<_>, _>>()?;
Ok(Self { colors })
}
pub fn from_named(colors: &[(&str, &str)]) -> Result<Self, InvalidColor> {
let colors = colors
.iter()
.map(|&(name, c)| {
Ok(Spec {
name: Some(name.to_string()),
source: c.to_string(),
rgb: parse_color(c)?,
})
})
.collect::<Result<Vec<_>, _>>()?;
Ok(Self { colors })
}
#[must_use]
pub fn or(mut self, mut other: Matcher) -> Matcher {
self.colors.append(&mut other.colors);
self
}
pub fn nearest(&self, needle: &str) -> Result<Option<ColorMatch>, InvalidColor> {
let needle = parse_color(needle)?;
let mut best: Option<(&Spec, i64)> = None;
for spec in &self.colors {
let dr = i64::from(needle.r - spec.rgb.r);
let dg = i64::from(needle.g - spec.rgb.g);
let db = i64::from(needle.b - spec.rgb.b);
let dist_sq = dr * dr + dg * dg + db * db;
if best.map_or(true, |(_, bd)| dist_sq < bd) {
best = Some((spec, dist_sq));
}
}
Ok(best.map(|(spec, dist_sq)| ColorMatch {
name: spec.name.clone(),
value: spec.source.clone(),
rgb: spec.rgb,
#[allow(clippy::cast_precision_loss)]
distance: (dist_sq as f64).sqrt(),
}))
}
}
#[must_use]
pub fn default_matcher() -> Matcher {
Matcher::from_colors(DEFAULT_COLORS).expect("default colors are valid")
}
pub fn nearest_color(needle: &str) -> Result<ColorMatch, InvalidColor> {
Ok(default_matcher()
.nearest(needle)?
.expect("default palette is non-empty"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_hex_forms() {
assert_eq!(parse_color("#f00").unwrap(), Rgb { r: 255, g: 0, b: 0 });
assert_eq!(parse_color("f00").unwrap(), Rgb { r: 255, g: 0, b: 0 });
assert_eq!(
parse_color("#04fbc8").unwrap(),
Rgb {
r: 4,
g: 251,
b: 200
}
);
assert_eq!(
parse_color("#FF0").unwrap(),
Rgb {
r: 255,
g: 255,
b: 0
}
);
assert_eq!(
parse_color("abc").unwrap(),
Rgb {
r: 170,
g: 187,
b: 204
}
);
}
#[test]
fn parses_rgb_and_names() {
assert_eq!(
parse_color("rgb(3, 10, 100)").unwrap(),
Rgb {
r: 3,
g: 10,
b: 100
}
);
assert_eq!(
parse_color("rgb(50%, 0%, 50%)").unwrap(),
Rgb {
r: 128,
g: 0,
b: 128
}
);
assert_eq!(
parse_color("aqua").unwrap(),
Rgb {
r: 0,
g: 255,
b: 255
}
);
assert_eq!(
parse_color("gray").unwrap(),
Rgb {
r: 128,
g: 128,
b: 128
}
);
}
#[test]
fn rejects_invalid() {
for bad in ["foo", "#ff", "#fffff", "rgb(1,2)", "Red", "", "#12g"] {
assert!(parse_color(bad).is_err(), "{bad:?} should be invalid");
}
}
#[test]
fn default_nearest() {
assert_eq!(nearest_color("#f11").unwrap().value, "#f00");
assert_eq!(nearest_color("#f88").unwrap().value, "#f80");
assert_eq!(nearest_color("#ffe").unwrap().value, "#ff0");
assert_eq!(nearest_color("#efe").unwrap().value, "#ff0");
assert_eq!(nearest_color("#abc").unwrap().value, "#808");
assert_eq!(nearest_color("red").unwrap().value, "#f00");
}
#[test]
fn named_match_has_metadata() {
let m = Matcher::from_named(&[("maroon", "#800"), ("white", "fff")]).unwrap();
let got = m.nearest("#f00").unwrap().unwrap();
assert_eq!(got.name.as_deref(), Some("maroon"));
assert_eq!(got.value, "#800");
assert_eq!(got.rgb, Rgb { r: 136, g: 0, b: 0 });
assert!((got.distance - 119.0).abs() < 0.5);
}
#[test]
fn combine_with_or() {
let a = Matcher::from_colors(&["#eee"]).unwrap();
let b = Matcher::from_colors(&["#444"]).unwrap();
let both = a.or(b);
assert_eq!(both.nearest("#000").unwrap().unwrap().value, "#444");
assert_eq!(both.nearest("#fff").unwrap().unwrap().value, "#eee");
}
#[test]
fn ties_keep_first() {
let m = Matcher::from_colors(&["#000", "#222"]).unwrap();
assert_eq!(m.nearest("#111").unwrap().unwrap().value, "#000");
}
}