#[must_use]
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(
feature = "serde-support",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct Color {
pub r: u8,
pub g: u8,
pub b: u8,
}
impl core::fmt::Display for Color {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
}
}
impl Color {
pub const fn new(r: u8, g: u8, b: u8) -> Self {
Self { r, g, b }
}
pub const fn from_hex(hex: u32) -> Self {
Self {
r: ((hex >> 16) & 0xFF) as u8,
g: ((hex >> 8) & 0xFF) as u8,
b: (hex & 0xFF) as u8,
}
}
#[must_use]
pub const fn to_hex(self) -> u32 {
(self.r as u32) << 16 | (self.g as u32) << 8 | self.b as u32
}
#[must_use]
#[cfg(any(feature = "alloc", feature = "std"))]
#[cfg_attr(docsrs, doc(cfg(any(feature = "alloc", feature = "std"))))]
pub fn to_css_hex(self) -> alloc::string::String {
alloc::format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b)
}
pub const fn from_css_hex(s: &str) -> Option<Self> {
let bytes = s.as_bytes();
let (start, hex_len) = if !bytes.is_empty() && bytes[0] == b'#' {
(1, bytes.len() - 1)
} else {
(0, bytes.len())
};
match hex_len {
6 => {
let (r_hi, r_lo) = (hex_digit(bytes[start]), hex_digit(bytes[start + 1]));
let (g_hi, g_lo) = (hex_digit(bytes[start + 2]), hex_digit(bytes[start + 3]));
let (b_hi, b_lo) = (hex_digit(bytes[start + 4]), hex_digit(bytes[start + 5]));
match (r_hi, r_lo, g_hi, g_lo, b_hi, b_lo) {
(Some(rh), Some(rl), Some(gh), Some(gl), Some(bh), Some(bl)) => Some(Color {
r: rh << 4 | rl,
g: gh << 4 | gl,
b: bh << 4 | bl,
}),
_ => None,
}
}
3 => {
let (r, g, b) = (
hex_digit(bytes[start]),
hex_digit(bytes[start + 1]),
hex_digit(bytes[start + 2]),
);
match (r, g, b) {
(Some(r), Some(g), Some(b)) => Some(Color {
r: r << 4 | r,
g: g << 4 | g,
b: b << 4 | b,
}),
_ => None,
}
}
_ => None,
}
}
#[must_use]
pub const fn to_f32(self) -> (f32, f32, f32) {
(
self.r as f32 / 255.0,
self.g as f32 / 255.0,
self.b as f32 / 255.0,
)
}
pub fn from_f32(r: f32, g: f32, b: f32) -> Self {
Self {
r: (r.clamp(0.0, 1.0) * 255.0 + 0.5) as u8,
g: (g.clamp(0.0, 1.0) * 255.0 + 0.5) as u8,
b: (b.clamp(0.0, 1.0) * 255.0 + 0.5) as u8,
}
}
#[must_use]
pub fn luminance(self) -> f64 {
let r = srgb_to_linear(self.r as f64 / 255.0);
let g = srgb_to_linear(self.g as f64 / 255.0);
let b = srgb_to_linear(self.b as f64 / 255.0);
0.2126 * r + 0.7152 * g + 0.0722 * b
}
#[must_use]
pub fn contrast_ratio(self, other: Color) -> f64 {
let l1 = self.luminance();
let l2 = other.luminance();
let (lighter, darker) = if l1 > l2 { (l1, l2) } else { (l2, l1) };
(lighter + 0.05) / (darker + 0.05)
}
pub fn lerp(self, other: Color, t: f32) -> Color {
let t = t.clamp(0.0, 1.0);
Color {
r: (self.r as f32 + (other.r as f32 - self.r as f32) * t) as u8,
g: (self.g as f32 + (other.g as f32 - self.g as f32) * t) as u8,
b: (self.b as f32 + (other.b as f32 - self.b as f32) * t) as u8,
}
}
}
impl Default for Color {
fn default() -> Self {
Self::new(0, 0, 0)
}
}
impl From<u32> for Color {
fn from(hex: u32) -> Self {
Self::from_hex(hex)
}
}
impl From<[u8; 3]> for Color {
fn from([r, g, b]: [u8; 3]) -> Self {
Self::new(r, g, b)
}
}
impl From<Color> for [u8; 3] {
fn from(c: Color) -> Self {
[c.r, c.g, c.b]
}
}
impl From<(u8, u8, u8)> for Color {
fn from((r, g, b): (u8, u8, u8)) -> Self {
Self::new(r, g, b)
}
}
impl From<Color> for (u8, u8, u8) {
fn from(c: Color) -> Self {
(c.r, c.g, c.b)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub struct ConversionError {
pub message: &'static str,
}
impl core::fmt::Display for ConversionError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "{}", self.message)
}
}
impl core::error::Error for ConversionError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParseColorError;
impl core::fmt::Display for ParseColorError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.write_str("invalid CSS hex color (expected #RGB, RGB, #RRGGBB, or RRGGBB)")
}
}
impl core::error::Error for ParseColorError {}
impl core::str::FromStr for Color {
type Err = ParseColorError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::from_css_hex(s).ok_or(ParseColorError)
}
}
const fn hex_digit(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(b - b'a' + 10),
b'A'..=b'F' => Some(b - b'A' + 10),
_ => None,
}
}
fn srgb_to_linear(c: f64) -> f64 {
if c <= 0.03928 {
c / 12.92
} else {
libm::pow((c + 0.055) / 1.055, 2.4)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
#[cfg_attr(
feature = "serde-support",
derive(serde::Serialize, serde::Deserialize)
)]
pub enum ColormapKind {
Sequential,
Diverging,
Cyclic,
Qualitative,
MultiSequential,
}
impl core::fmt::Display for ColormapKind {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Sequential => write!(f, "Sequential"),
Self::Diverging => write!(f, "Diverging"),
Self::Cyclic => write!(f, "Cyclic"),
Self::Qualitative => write!(f, "Qualitative"),
Self::MultiSequential => write!(f, "Multi-sequential"),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
#[cfg_attr(
feature = "serde-support",
derive(serde::Serialize, serde::Deserialize)
)]
pub struct ColormapMeta {
pub name: &'static str,
pub collection: &'static str,
pub author: &'static str,
pub kind: ColormapKind,
pub perceptually_uniform: bool,
pub cvd_friendly: bool,
pub grayscale_safe: bool,
pub lut_size: usize,
pub citation: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct Colormap {
pub meta: ColormapMeta,
pub lut: &'static [[u8; 3]],
}
impl Colormap {
pub fn eval(&self, t: f32) -> Color {
debug_assert!(!self.lut.is_empty(), "Colormap LUT must not be empty");
let t = t.clamp(0.0, 1.0);
let n = self.lut.len();
let scaled = t * (n - 1) as f32;
let idx = scaled as usize;
let frac = scaled - idx as f32;
if idx >= n - 1 {
let [r, g, b] = self.lut[n - 1];
return Color::new(r, g, b);
}
let [r0, g0, b0] = self.lut[idx];
let [r1, g1, b1] = self.lut[idx + 1];
Color::new(
(r0 as f32 + (r1 as f32 - r0 as f32) * frac) as u8,
(g0 as f32 + (g1 as f32 - g0 as f32) * frac) as u8,
(b0 as f32 + (b1 as f32 - b0 as f32) * frac) as u8,
)
}
pub fn eval_rational(&self, i: usize, n: usize) -> Color {
if n <= 1 {
return self.eval(0.0);
}
self.eval(i as f32 / (n - 1) as f32)
}
#[must_use]
pub fn reversed(&self) -> ReversedColormap<'_> {
ReversedColormap { inner: self }
}
#[must_use]
#[cfg(any(feature = "alloc", feature = "std"))]
#[cfg_attr(docsrs, doc(cfg(any(feature = "alloc", feature = "std"))))]
pub fn colors(&self, n: usize) -> alloc::vec::Vec<Color> {
(0..n).map(|i| self.eval_rational(i, n)).collect()
}
#[must_use]
pub fn name(&self) -> &'static str {
self.meta.name
}
#[must_use]
pub fn kind(&self) -> ColormapKind {
self.meta.kind
}
#[must_use]
pub fn collection(&self) -> &'static str {
self.meta.collection
}
}
impl core::fmt::Display for ColormapMeta {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(f, "{} ({}, {})", self.name, self.kind, self.collection)
}
}
impl core::fmt::Display for Colormap {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
core::fmt::Display::fmt(&self.meta, f)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct ReversedColormap<'a> {
inner: &'a Colormap,
}
impl ReversedColormap<'_> {
pub fn eval(&self, t: f32) -> Color {
self.inner.eval(1.0 - t)
}
pub fn eval_rational(&self, i: usize, n: usize) -> Color {
if n <= 1 {
return self.eval(0.0);
}
let reversed_i = (n - 1).saturating_sub(i);
self.inner.eval_rational(reversed_i, n)
}
#[must_use]
#[cfg(any(feature = "alloc", feature = "std"))]
#[cfg_attr(docsrs, doc(cfg(any(feature = "alloc", feature = "std"))))]
pub fn colors(&self, n: usize) -> alloc::vec::Vec<Color> {
(0..n).map(|i| self.eval_rational(i, n)).collect()
}
#[must_use]
pub fn meta(&self) -> &ColormapMeta {
&self.inner.meta
}
#[must_use]
pub fn name(&self) -> &'static str {
self.inner.meta.name
}
#[must_use]
pub fn kind(&self) -> ColormapKind {
self.inner.meta.kind
}
#[must_use]
pub fn collection(&self) -> &'static str {
self.inner.meta.collection
}
}
impl core::fmt::Display for ReversedColormap<'_> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"{} reversed ({}, {})",
self.inner.meta.name, self.inner.meta.kind, self.inner.meta.collection
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub struct DiscretePalette {
pub meta: ColormapMeta,
pub colors: &'static [[u8; 3]],
}
impl DiscretePalette {
pub fn get(&self, i: usize) -> Color {
let [r, g, b] = self.colors[i % self.colors.len()];
Color::new(r, g, b)
}
#[must_use]
pub fn len(&self) -> usize {
self.colors.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.colors.is_empty()
}
#[must_use]
#[cfg(any(feature = "alloc", feature = "std"))]
#[cfg_attr(docsrs, doc(cfg(any(feature = "alloc", feature = "std"))))]
pub fn all_colors(&self) -> alloc::vec::Vec<Color> {
self.colors
.iter()
.map(|[r, g, b]| Color::new(*r, *g, *b))
.collect()
}
pub fn iter(&self) -> impl ExactSizeIterator<Item = Color> + DoubleEndedIterator + '_ {
self.colors.iter().map(|&[r, g, b]| Color::new(r, g, b))
}
#[must_use]
pub fn name(&self) -> &'static str {
self.meta.name
}
#[must_use]
pub fn kind(&self) -> ColormapKind {
self.meta.kind
}
#[must_use]
pub fn collection(&self) -> &'static str {
self.meta.collection
}
}
impl core::fmt::Display for DiscretePalette {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
write!(
f,
"{} ({} colors, {})",
self.meta.name,
self.colors.len(),
self.meta.collection
)
}
}
fn array_to_color(rgb: &[u8; 3]) -> Color {
Color::new(rgb[0], rgb[1], rgb[2])
}
impl<'a> IntoIterator for &'a DiscretePalette {
type Item = Color;
type IntoIter = core::iter::Map<core::slice::Iter<'a, [u8; 3]>, fn(&[u8; 3]) -> Color>;
fn into_iter(self) -> Self::IntoIter {
self.colors
.iter()
.map(array_to_color as fn(&[u8; 3]) -> Color)
}
}
#[cfg(test)]
#[cfg(any(feature = "alloc", feature = "std"))]
mod tests {
use super::*;
use alloc::format;
#[test]
fn color_new() {
let c = Color::new(255, 128, 0);
assert_eq!(c.r, 255);
assert_eq!(c.g, 128);
assert_eq!(c.b, 0);
}
#[test]
fn color_from_hex() {
let c = Color::from_hex(0xFF8800);
assert_eq!(c, Color::new(255, 136, 0));
}
#[test]
fn color_to_f32() {
let c = Color::new(255, 0, 128);
let (r, g, b) = c.to_f32();
assert!((r - 1.0).abs() < 0.001);
assert!(g.abs() < 0.001);
assert!((b - 128.0 / 255.0).abs() < 0.001);
}
#[cfg(any(feature = "alloc", feature = "std"))]
#[test]
fn color_to_css_hex() {
let c = Color::new(255, 136, 0);
assert_eq!(c.to_css_hex(), "#ff8800");
}
#[test]
fn color_lerp_boundaries() {
let a = Color::new(0, 0, 0);
let b = Color::new(255, 255, 255);
assert_eq!(a.lerp(b, 0.0), a);
assert_eq!(a.lerp(b, 1.0), b);
}
#[test]
fn color_lerp_midpoint() {
let a = Color::new(0, 0, 0);
let b = Color::new(200, 100, 50);
let mid = a.lerp(b, 0.5);
assert_eq!(mid, Color::new(100, 50, 25));
}
#[test]
fn color_lerp_clamps() {
let a = Color::new(100, 100, 100);
let b = Color::new(200, 200, 200);
assert_eq!(a.lerp(b, -1.0), a);
assert_eq!(a.lerp(b, 2.0), b);
}
static TEST_LUT: [[u8; 3]; 3] = [[0, 0, 0], [128, 128, 128], [255, 255, 255]];
fn test_colormap() -> Colormap {
Colormap {
meta: ColormapMeta {
name: "test",
collection: "test",
author: "test",
kind: ColormapKind::Sequential,
perceptually_uniform: true,
cvd_friendly: true,
grayscale_safe: true,
lut_size: 3,
citation: "",
},
lut: &TEST_LUT,
}
}
#[test]
fn colormap_eval_boundaries() {
let cm = test_colormap();
assert_eq!(cm.eval(0.0), Color::new(0, 0, 0));
assert_eq!(cm.eval(1.0), Color::new(255, 255, 255));
}
#[test]
fn colormap_eval_clamps() {
let cm = test_colormap();
assert_eq!(cm.eval(-1.0), cm.eval(0.0));
assert_eq!(cm.eval(2.0), cm.eval(1.0));
}
#[test]
fn colormap_eval_midpoint() {
let cm = test_colormap();
let mid = cm.eval(0.5);
assert_eq!(mid, Color::new(128, 128, 128));
}
#[test]
fn colormap_reversed() {
let cm = test_colormap();
let rev = cm.reversed();
assert_eq!(rev.eval(0.0), cm.eval(1.0));
assert_eq!(rev.eval(1.0), cm.eval(0.0));
}
#[test]
fn colormap_eval_rational() {
let cm = test_colormap();
assert_eq!(cm.eval_rational(0, 3), cm.eval(0.0));
assert_eq!(cm.eval_rational(2, 3), cm.eval(1.0));
}
#[test]
fn colormap_lut_access() {
let cm = test_colormap();
assert_eq!(cm.lut.len(), 3);
}
static TEST_PALETTE_COLORS: [[u8; 3]; 3] = [[255, 0, 0], [0, 255, 0], [0, 0, 255]];
#[test]
fn discrete_palette_get() {
let p = DiscretePalette {
meta: ColormapMeta {
name: "test",
collection: "test",
author: "test",
kind: ColormapKind::Qualitative,
perceptually_uniform: false,
cvd_friendly: false,
grayscale_safe: false,
lut_size: 3,
citation: "",
},
colors: &TEST_PALETTE_COLORS,
};
assert_eq!(p.get(0), Color::new(255, 0, 0));
assert_eq!(p.get(1), Color::new(0, 255, 0));
assert_eq!(p.get(3), Color::new(255, 0, 0)); assert_eq!(p.len(), 3);
assert!(!p.is_empty());
}
#[test]
fn color_display() {
let c = Color::new(255, 136, 0);
assert_eq!(format!("{c}"), "#ff8800");
assert_eq!(format!("{}", Color::new(0, 0, 0)), "#000000");
}
#[test]
fn colormap_kind_display() {
assert_eq!(format!("{}", ColormapKind::Sequential), "Sequential");
assert_eq!(format!("{}", ColormapKind::Diverging), "Diverging");
assert_eq!(format!("{}", ColormapKind::Cyclic), "Cyclic");
assert_eq!(format!("{}", ColormapKind::Qualitative), "Qualitative");
assert_eq!(
format!("{}", ColormapKind::MultiSequential),
"Multi-sequential"
);
}
#[test]
fn color_to_hex_roundtrip() {
assert_eq!(Color::from_hex(0xFF8800).to_hex(), 0xFF8800);
assert_eq!(Color::new(0, 0, 0).to_hex(), 0x000000);
assert_eq!(Color::new(255, 255, 255).to_hex(), 0xFFFFFF);
}
#[test]
fn color_luminance_black_white() {
let black = Color::new(0, 0, 0).luminance();
let white = Color::new(255, 255, 255).luminance();
assert!(black < 0.01, "black luminance should be ~0, got {black}");
assert!(
(white - 1.0).abs() < 0.01,
"white luminance should be ~1, got {white}"
);
}
#[test]
fn color_contrast_ratio_bw() {
let ratio = Color::new(0, 0, 0).contrast_ratio(Color::new(255, 255, 255));
assert!(
(ratio - 21.0).abs() < 0.1,
"black/white contrast should be ~21, got {ratio}"
);
}
#[test]
fn color_contrast_ratio_symmetric() {
let a = Color::new(100, 50, 200);
let b = Color::new(200, 150, 50);
let ab = a.contrast_ratio(b);
let ba = b.contrast_ratio(a);
assert!(
(ab - ba).abs() < 0.001,
"contrast ratio should be symmetric"
);
}
#[test]
fn color_from_f32_basic() {
assert_eq!(Color::from_f32(1.0, 0.5, 0.0), Color::new(255, 128, 0));
assert_eq!(Color::from_f32(0.0, 0.0, 0.0), Color::new(0, 0, 0));
assert_eq!(Color::from_f32(1.0, 1.0, 1.0), Color::new(255, 255, 255));
}
#[test]
fn color_from_f32_clamps() {
assert_eq!(Color::from_f32(-0.5, 0.0, 2.0), Color::new(0, 0, 255));
}
#[test]
fn color_from_css_hex_valid() {
assert_eq!(
Color::from_css_hex("#ff8800"),
Some(Color::new(255, 136, 0))
);
assert_eq!(Color::from_css_hex("ff8800"), Some(Color::new(255, 136, 0)));
assert_eq!(Color::from_css_hex("#000000"), Some(Color::new(0, 0, 0)));
assert_eq!(
Color::from_css_hex("FFFFFF"),
Some(Color::new(255, 255, 255))
);
}
#[test]
fn color_from_css_hex_3digit() {
assert_eq!(Color::from_css_hex("#FFF"), Some(Color::new(255, 255, 255)));
assert_eq!(Color::from_css_hex("000"), Some(Color::new(0, 0, 0)));
assert_eq!(
Color::from_css_hex("#ABC"),
Some(Color::new(0xAA, 0xBB, 0xCC))
);
}
#[test]
fn color_from_css_hex_invalid() {
assert_eq!(Color::from_css_hex("#gg0000"), None);
assert_eq!(Color::from_css_hex(""), None);
assert_eq!(Color::from_css_hex("#1234567"), None);
assert_eq!(Color::from_css_hex("#zz"), None);
}
#[test]
fn color_from_u32() {
let c: Color = 0xFF8800u32.into();
assert_eq!(c, Color::new(255, 136, 0));
}
#[test]
fn color_from_str_valid() {
let c: Color = "#ff8800".parse().expect("valid hex");
assert_eq!(c, Color::new(255, 136, 0));
let c: Color = "FFF".parse().expect("valid 3-digit hex");
assert_eq!(c, Color::new(255, 255, 255));
}
#[test]
fn color_from_str_invalid() {
let err = "nope".parse::<Color>();
assert!(err.is_err());
assert_eq!(err.expect_err("should fail"), ParseColorError);
}
#[test]
fn colormap_display() {
let cm = test_colormap();
let s = format!("{cm}");
assert_eq!(s, "test (Sequential, test)");
}
#[test]
fn discrete_palette_display() {
let p = DiscretePalette {
meta: ColormapMeta {
name: "test",
collection: "test",
author: "test",
kind: ColormapKind::Qualitative,
perceptually_uniform: false,
cvd_friendly: false,
grayscale_safe: false,
lut_size: 3,
citation: "",
},
colors: &TEST_PALETTE_COLORS,
};
assert_eq!(format!("{p}"), "test (3 colors, test)");
}
#[test]
fn reversed_colormap_eval_rational() {
let cm = test_colormap();
let rev = cm.reversed();
assert_eq!(rev.eval_rational(0, 3), cm.eval_rational(2, 3));
assert_eq!(rev.eval_rational(2, 3), cm.eval_rational(0, 3));
}
#[test]
fn discrete_palette_iter() {
let p = DiscretePalette {
meta: ColormapMeta {
name: "test",
collection: "test",
author: "test",
kind: ColormapKind::Qualitative,
perceptually_uniform: false,
cvd_friendly: false,
grayscale_safe: false,
lut_size: 3,
citation: "",
},
colors: &TEST_PALETTE_COLORS,
};
let colors: alloc::vec::Vec<Color> = p.iter().collect();
assert_eq!(colors.len(), 3);
assert_eq!(colors[0], Color::new(255, 0, 0));
assert_eq!(colors[1], Color::new(0, 255, 0));
assert_eq!(colors[2], Color::new(0, 0, 255));
}
#[test]
fn colormap_convenience_accessors() {
let cm = test_colormap();
assert_eq!(cm.name(), "test");
assert_eq!(cm.kind(), ColormapKind::Sequential);
assert_eq!(cm.collection(), "test");
}
}