nsys-color-utils 0.2.2

Color utilities
Documentation
//! Color utilities.

#![feature(decl_macro)]

use colorsys::Hsl;
use lab::Lab;

pub mod constants;
pub mod color;
mod traits;

pub use self::constants::*;
pub use self::color::*;
pub use self::traits::*;

pub use rgb;
pub use rgb::{Rgb, Rgba, RGB8, RGBA8};

/// Convert RGB8 to values in [0.0, 1.0]
pub const fn normalize_rgb (Rgb { r, g, b } : RGB8) -> Rgb <f32> {
  Rgb::new (
    r as f32 / 255.0,
    g as f32 / 255.0,
    b as f32 / 255.0
  )
}

/// Convert RGBA8 to values in [0.0, 1.0]
pub const fn normalize_rgba (Rgba { r, g, b, a } : RGBA8) -> Rgba <f32> {
  Rgba::new (
    r as f32 / 255.0,
    g as f32 / 255.0,
    b as f32 / 255.0,
    a as f32 / 255.0
  )
}

/// Convert normalized to values to RGB8
#[expect(clippy::cast_possible_truncation)]
#[expect(clippy::cast_sign_loss)]
pub const fn quantize_rgb (Rgb {r, g, b } : Rgb <f32>) -> RGB8 {
  Rgb::new (
    (r * 255.0) as u8,
    (g * 255.0) as u8,
    (b * 255.0) as u8
  )
}

/// Convert normalized to values to RGBA8
#[expect(clippy::cast_possible_truncation)]
#[expect(clippy::cast_sign_loss)]
pub const fn quantize_rgba (Rgba { r, g, b, a } : Rgba <f32>) -> RGBA8 {
  Rgba::new (
    (r * 255.0) as u8,
    (g * 255.0) as u8,
    (b * 255.0) as u8,
    (a * 255.0) as u8
  )
}

/// Return hue in [0, 360). Returns `None` if color is monochrome (grayscale).
pub fn hue_deg (rgb : RGB8) -> Option <f32> {
  let Rgb { r, g, b } = normalize_rgb (rgb);
  let max   = f32::max (f32::max (r, g), b);
  let min   = f32::min (f32::min (r, g), b);
  let delta = max - min;
  if delta == 0.0 {
    return None
  }
  let mut hue;
  if max == r {
    hue = (g - b) / delta % 6.0
  } else if max == g {
    hue = ((b - r) / delta) + 2.0;
  } else {
    hue = ((r - g) / delta) + 4.0;
  }
  hue *= 60.0;
  if hue < 0.0 {
    hue += 360.0;
  }
  Some (hue)
}

/// Given a hue in [0, 360) and luminance [0, 100], return the RGB value.
///
/// Note that this is an iterative method that walks up or down HSL lightness space
/// until the luminance reaches the desired value.
pub fn hue_luminance_custom (hue_deg : f32, luminance : f32) -> RGB8 {
  let mut rgb = hue_to_rgb (hue_deg);
  loop {
    let lum = luminance_custom (rgb);
    let diff = lum - luminance;
    if diff.abs() < 0.25 {
      return rgb
    }
    let mut hsl = Hsl::from (colorsys::Rgb::from (rgb.into_array()));
    let new_lightness = if lum > luminance {
      hsl.lightness() - 1.0
    } else {
      debug_assert!(lum < luminance);
      hsl.lightness() + 1.0
    };
    hsl.set_lightness (new_lightness);
    let array : [u8; 3] = colorsys::Rgb::from (hsl).into();
    rgb = Rgb::from (array);
  }
}

/// Give a fully saturated RGB value for the given hue
pub fn hue_to_rgb (hue : f32) -> RGB8 {
  let h = hue.rem_euclid (360.0);
  let c = 1.0; // saturation = 1, value = 1
  let x = c * (1.0 - (((h / 60.0) % 2.0) - 1.0).abs());
  let [r1, g1, b1] = match h {
    h if h < 60.0  => [c, x, 0.0],
    h if h < 120.0 => [x, c, 0.0],
    h if h < 180.0 => [0.0, c, x],
    h if h < 240.0 => [0.0, x, c],
    h if h < 300.0 => [x, 0.0, c],
    _              => [c, 0.0, x]
  };
  quantize_rgb (Rgb::new (r1, g1, b1))
}

/// Returns luminance value from 0.0-100.0 with custom chroma bias.
///
/// Implements algorithm used by <http://www.workwithcolor.com/hsl-color-picker-01.htm>.
#[expect(clippy::cast_possible_truncation)]
pub fn luminance_custom (Rgb { r, g, b } : RGB8) -> f32 {
  if r == g && g == b {
    return (r as f32 / 255.0) * 100.0
  }
  // rgb -> CIELAB
  let tmp_lab = Lab::from_rgb (&[r, g, b]);
  // grayscale RGB of computed LAB luminance
  let tmp_rgb = Lab { a: 0.0, b: 0.0, .. tmp_lab }.to_rgb();
  // custom chroma bias
  // maps a -> -5..5 (green-red)
  let lum_lab_a = ((tmp_lab.a / 127.0) * 50.0) / 10.0;
  // maps b -> -2.5..2.5 (blue-yellow)
  let lum_lab_b = (((tmp_lab.a - tmp_lab.b) / 127.0) * 50.0) / 10.0 / 2.0;
  // addition results in range of -7.5..7.5
  let mut lum_lab_ab = lum_lab_a + lum_lab_b;
  // chroma diff will be in the range -255..255
  if tmp_lab.a > tmp_lab.b {
    // if green-red chroma is greater than blue-yellow chroma,
    // add the difference mapped to 0.0..10.0, resulting in a range of -7.5..17.5
    lum_lab_ab += (((tmp_lab.a - tmp_lab.b) / 127.0) * 50.0) / 10.0;
  }
  // HSL from the grayscale RGB of computed LAB luminance, plus the custom luminance
  // factor
  Hsl::from (colorsys::Rgb::from (&tmp_rgb)).lightness() as f32 + lum_lab_ab
}

pub fn report_sizes() {
  use std::mem::size_of;
  macro_rules! show {
    ($e:expr) => { println!("{}: {:?}", stringify!($e), $e); }
  }
  println!("report sizes...");
  show!(size_of::<Color>());
  println!("...report sizes");
}

#[cfg(test)]
mod tests {
  use super::*;

  #[test]
  fn hue() {
    for hue in 0..360 {
      let rgb = hue_to_rgb (hue as f32);
      let hue_deg = hue_deg (rgb).unwrap();
      assert!((hue as f32 - hue_deg).abs() < 1.0,
        "rgb: {rgb:?}, hue: {hue}, hue_deg: {hue_deg}");
    }
  }

  #[test]
  fn hue_lum() {
    assert_eq!([211, 0, 0], hue_luminance_custom (0.0, 44.0).into_array());
    assert_eq!([121, 121, 0], hue_luminance_custom (60.0, 44.0).into_array());
    assert_eq!([0, 145, 0], hue_luminance_custom (120.0, 44.0).into_array());
    assert_eq!([0, 130, 130], hue_luminance_custom (180.0, 44.0).into_array());
    assert_eq!([0, 0, 255], hue_luminance_custom (240.0, 44.0).into_array());
    assert_eq!([159, 0, 159], hue_luminance_custom (300.0, 44.0).into_array());
    // make sure alglorithm terminates
    for hue in 0..360 {
      for luminance in 0..100 {
        let _rgb = hue_luminance_custom (hue as f32, luminance as f32);
      }
    }
  }
}