Skip to main content

nearest_color/
lib.rs

1//! # nearest-color — find the nearest color from a palette
2//!
3//! Given a target color and a list of available colors, find the closest one by Euclidean
4//! distance in RGB space — useful for snapping arbitrary colors to a theme palette, naming
5//! colors, or quantizing. Colors may be given as hex (`#f00`, `#ff0000`), `rgb(…)` strings,
6//! or the 17 standard CSS color names.
7//!
8//! A faithful Rust port of the [`nearest-color`](https://www.npmjs.com/package/nearest-color)
9//! npm package. **Zero dependencies.**
10//!
11//! ```
12//! use nearest_color::Matcher;
13//!
14//! let palette = Matcher::from_named(&[
15//!     ("maroon", "#800"),
16//!     ("pale blue", "#def"),
17//!     ("white", "fff"),
18//! ]).unwrap();
19//!
20//! let m = palette.nearest("#f00").unwrap().unwrap();
21//! assert_eq!(m.name.as_deref(), Some("maroon"));
22//! assert_eq!(m.value, "#800");
23//! assert_eq!(m.rgb, nearest_color::Rgb { r: 136, g: 0, b: 0 });
24//! ```
25//!
26//! With an unnamed palette (or the built-in rainbow defaults), the match's `name` is
27//! `None` and `value` holds the nearest color's source string:
28//!
29//! ```
30//! use nearest_color::nearest_color;
31//! assert_eq!(nearest_color("#f88").unwrap().value, "#f80");
32//! ```
33
34#![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// Compile-test the README's examples as part of `cargo test`.
45#[cfg(doctest)]
46#[doc = include_str!("../README.md")]
47struct ReadmeDoctests;
48
49/// An RGB color. Components are stored as `i32` to faithfully carry through out-of-range
50/// `rgb()` inputs (the reference does not clamp).
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
52pub struct Rgb {
53    /// Red component (0–255 for valid input).
54    pub r: i32,
55    /// Green component.
56    pub g: i32,
57    /// Blue component.
58    pub b: i32,
59}
60
61/// The result of a nearest-color match.
62#[derive(Debug, Clone, PartialEq)]
63pub struct ColorMatch {
64    /// The matched color's name, if the palette was named.
65    pub name: Option<String>,
66    /// The matched color's source string (e.g. `#800`).
67    pub value: String,
68    /// The matched color's RGB value.
69    pub rgb: Rgb,
70    /// The Euclidean RGB distance from the needle to the match.
71    pub distance: f64,
72}
73
74/// The error returned for an unparseable color string.
75#[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
86/// The 17 standard CSS color names recognized by [`parse_color`].
87pub 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
107/// The default rainbow palette (ROY G BIV) used by [`nearest_color`].
108const DEFAULT_COLORS: &[&str] = &["#f00", "#f80", "#ff0", "#0f0", "#00f", "#008", "#808"];
109
110/// Parse a color string into [`Rgb`].
111///
112/// Accepts a standard CSS color name, a 3- or 6-digit hex string (with or without a leading
113/// `#`), or an `rgb(r, g, b)` string whose components may be `0–255` integers or
114/// percentages.
115///
116/// # Errors
117/// Returns [`InvalidColor`] if `source` is not a recognizable color.
118///
119/// ```
120/// # use nearest_color::{parse_color, Rgb};
121/// assert_eq!(parse_color("#f00").unwrap(), Rgb { r: 255, g: 0, b: 0 });
122/// assert_eq!(parse_color("rgb(50%, 0%, 50%)").unwrap(), Rgb { r: 128, g: 0, b: 128 });
123/// assert_eq!(parse_color("aqua").unwrap(), Rgb { r: 0, g: 255, b: 255 });
124/// assert!(parse_color("foo").is_err());
125/// ```
126pub 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
139/// `/^#?((?:[0-9a-f]{3}){1,2})$/i`
140fn 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
172/// `/^rgb\(\s*(\d{1,3}%?),\s*(\d{1,3}%?),\s*(\d{1,3}%?)\s*\)$/i`
173fn parse_rgb(source: &str) -> Option<Rgb> {
174    const WS: [char; 6] = [' ', '\t', '\n', '\r', '\u{0c}', '\u{0b}'];
175    // Case-insensitive `rgb(` prefix and `)` suffix.
176    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    // Each comma is followed by `\s*`; the first component allows leading `\s*` and the
183    // last allows trailing `\s*`. A component itself contains no whitespace, so
184    // `parse_component` rejects any that leaks through.
185    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
191/// A component is `\d{1,3}` optionally followed by `%`.
192fn 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        // Math.round(n * 255 / 100), round half up.
203        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/// A reusable nearest-color matcher built from a fixed palette.
217#[derive(Debug, Clone)]
218pub struct Matcher {
219    colors: Vec<Spec>,
220}
221
222impl Matcher {
223    /// Build a matcher from a list of unnamed color strings.
224    ///
225    /// # Errors
226    /// Returns [`InvalidColor`] if any entry is not a valid color.
227    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    /// Build a matcher from `(name, color)` pairs.
242    ///
243    /// # Errors
244    /// Returns [`InvalidColor`] if any color is not valid.
245    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    /// Combine this palette with another, returning a matcher over both.
260    #[must_use]
261    pub fn or(mut self, mut other: Matcher) -> Matcher {
262        self.colors.append(&mut other.colors);
263        self
264    }
265
266    /// Find the nearest palette color to `needle`.
267    ///
268    /// # Errors
269    /// Returns [`InvalidColor`] if `needle` is not a valid color. Returns `Ok(None)` only if
270    /// the palette is empty.
271    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/// A matcher over the built-in default palette (the rainbow colors, unnamed).
294///
295/// # Panics
296/// Never; the built-in default colors are always valid.
297#[must_use]
298pub fn default_matcher() -> Matcher {
299    Matcher::from_colors(DEFAULT_COLORS).expect("default colors are valid")
300}
301
302/// Find the nearest of the built-in default (rainbow) colors to `needle`.
303///
304/// # Errors
305/// Returns [`InvalidColor`] if `needle` is not a valid color.
306///
307/// # Panics
308/// Never; the built-in default palette is non-empty.
309///
310/// ```
311/// # use nearest_color::nearest_color;
312/// assert_eq!(nearest_color("#0e0").unwrap().value, "#0f0");
313/// ```
314pub 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        // "#111" is exactly equidistant from "#000" and "#222"; the earlier entry wins.
429        let m = Matcher::from_colors(&["#000", "#222"]).unwrap();
430        assert_eq!(m.nearest("#111").unwrap().unwrap().value, "#000");
431    }
432}