Skip to content

Commit

Permalink
Hsl color space
Browse files Browse the repository at this point in the history
I think this is complete.
  • Loading branch information
raphlinus committed Nov 10, 2024
1 parent b57296a commit f25d6fb
Show file tree
Hide file tree
Showing 6 changed files with 173 additions and 27 deletions.
8 changes: 4 additions & 4 deletions color/src/color.rs
Original file line number Diff line number Diff line change
Expand Up @@ -228,11 +228,11 @@ impl<CS: ColorSpace> OpaqueColor<CS> {
/// In a color space that naturally has a lightness component, map that value.
/// Otherwise, do the mapping in [Oklab]. The lightness range is normalized so
/// that 1.0 is white. That is the normal range for Oklab but differs from the
/// range in [Lab], [Lch], and [HSL].
/// range in [Lab], [Lch], and [Hsl].
///
/// [Lab]: crate::Lab
/// [Lch]: crate::Lch
/// [HSL]: https://www.w3.org/TR/css-color-4/#the-hsl-notation
/// [Hsl]: crate::Hsl
#[must_use]
pub fn map_lightness(self, f: impl Fn(f32) -> f32) -> Self {
match CS::TAG {
Expand Down Expand Up @@ -340,11 +340,11 @@ impl<CS: ColorSpace> AlphaColor<CS> {
/// In a color space that naturally has a lightness component, map that value.
/// Otherwise, do the mapping in [Oklab]. The lightness range is normalized so
/// that 1.0 is white. That is the normal range for [Oklab] but differs from the
/// range in [Lab], [Lch], and [HSL].
/// range in [Lab], [Lch], and [Hsl].
///
/// [Lab]: crate::Lab
/// [Lch]: crate::Lch
/// [HSL]: https://www.w3.org/TR/css-color-4/#the-hsl-notation
/// [Hsl]: crate::Hsl
#[must_use]
pub fn map_lightness(self, f: impl Fn(f32) -> f32) -> Self {
match CS::TAG {
Expand Down
114 changes: 114 additions & 0 deletions color/src/colorspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,17 @@ impl ColorSpace for Srgb {
src.map(lin_to_srgb)
}

fn convert<TargetCS: ColorSpace>(src: [f32; 3]) -> [f32; 3] {
if TypeId::of::<Self>() == TypeId::of::<TargetCS>() {
src
} else if TypeId::of::<TargetCS>() == TypeId::of::<Hsl>() {
rgb_to_hsl(src)
} else {
let lin_rgb = Self::to_linear_srgb(src);
TargetCS::from_linear_srgb(lin_rgb)
}
}

fn clip([r, g, b]: [f32; 3]) -> [f32; 3] {
[r.clamp(0., 1.), g.clamp(0., 1.), b.clamp(0., 1.)]
}
Expand Down Expand Up @@ -641,3 +652,106 @@ impl ColorSpace for Lch {
[l.clamp(0., 100.), c.max(0.), h]
}
}

/// 🌌 The HSL color space
///
/// The HSL color space is fairly widely used and convenient, but it is
/// not based on sound color science. Among its flaws, colors with the
/// same "lightness" value can have wildly varying perceptual lightness.
///
/// Its components are `[H, S, L]` with
/// - `H` - the hue angle in degrees, with red at 0, green at 120, and blue at 240.
/// - `S` - the saturation, where 0 is gray and 100 is maximally saturated.
/// - `L` - the lightness, where 0 is black and 100 is white.
///
/// This corresponds to the color space in [CSS Color Module Level 4 § 7 ][css-sec].
///
/// [css-sec]: https://www.w3.org/TR/css-color-4/#the-hsl-notation
#[derive(Clone, Copy, Debug)]
pub struct Hsl;

/// Convert HSL to RGB.
///
/// Reference: § 7.1 of CSS Color 4 spec.
fn hsl_to_rgb([h, s, l]: [f32; 3]) -> [f32; 3] {
// Don't need mod 360 for hue, it's subsumed by mod 12 below.
let sat = s * 0.01;
let light = l * 0.01;
let a = sat * light.min(1.0 - light);
[0.0, 8.0, 4.0].map(|n| {
let x = n + h * (1.0 / 30.0);
let k = x - 12.0 * (x * (1.0 / 12.0)).floor();
light - a * (k - 3.0).min(9.0 - k).clamp(-1.0, 1.0)
})
}

/// Convert RGB to HSL.
///
/// Reference: § 7.2 of CSS Color 4 spec.
fn rgb_to_hsl([r, g, b]: [f32; 3]) -> [f32; 3] {
let max = r.max(g).max(b);
let min = r.min(g).min(b);
let mut hue = 0.0;
let mut sat = 0.0;
let light = 0.5 * (min + max);
let d = max - min;

const EPSILON: f32 = 1e-6;
if d > EPSILON {
let denom = light.min(1.0 - light);
if denom.abs() > EPSILON {
sat = (max - light) / denom;
}
hue = if max == r {
(g - b) / d
} else if max == g {
(b - r) / d + 2.0
} else {
// max == b
(r - g) / d + 4.0
};
hue *= 60.0;
// Deal with negative saturation from out of gamut colors
if sat < 0.0 {
hue += 180.0;
sat = sat.abs();
}
hue -= 360. * (hue * (1.0 / 360.0)).floor();
}
[hue, sat * 100.0, light * 100.0]
}

impl ColorSpace for Hsl {
const TAG: Option<ColorSpaceTag> = Some(ColorSpaceTag::Hsl);

const LAYOUT: ColorSpaceLayout = ColorSpaceLayout::HueFirst;

fn from_linear_srgb(src: [f32; 3]) -> [f32; 3] {
let rgb = Srgb::from_linear_srgb(src);
rgb_to_hsl(rgb)
}

fn to_linear_srgb(src: [f32; 3]) -> [f32; 3] {
let rgb = hsl_to_rgb(src);
Srgb::to_linear_srgb(rgb)
}

fn scale_chroma([h, s, v]: [f32; 3], scale: f32) -> [f32; 3] {
[h, s * scale, v]
}

fn convert<TargetCS: ColorSpace>(src: [f32; 3]) -> [f32; 3] {
if TypeId::of::<Self>() == TypeId::of::<TargetCS>() {
src
} else if TypeId::of::<TargetCS>() == TypeId::of::<Srgb>() {
hsl_to_rgb(src)
} else {
let lin_rgb = Self::to_linear_srgb(src);
TargetCS::from_linear_srgb(lin_rgb)
}
}

fn clip([l, c, h]: [f32; 3]) -> [f32; 3] {
[l.clamp(0., 100.), c.max(0.), h]
}
}
2 changes: 1 addition & 1 deletion color/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ mod floatfuncs;

pub use color::{AlphaColor, HueDirection, OpaqueColor, PremulColor};
pub use colorspace::{
ColorSpace, ColorSpaceLayout, DisplayP3, Lab, Lch, LinearSrgb, Oklab, Oklch, Srgb, XyzD65,
ColorSpace, ColorSpaceLayout, DisplayP3, Hsl, Lab, Lch, LinearSrgb, Oklab, Oklch, Srgb, XyzD65,
};
pub use dynamic::{DynamicColor, Interpolator};
pub use gradient::{gradient, GradientIter};
Expand Down
22 changes: 21 additions & 1 deletion color/src/parse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,26 @@ impl<'a> Parser<'a> {
Ok(color_from_components([l, c, h, alpha], tag))
}

fn hsl(&mut self) -> Result<DynamicColor, Error> {
if !self.raw_ch(b'(') {
return Err("expected arguments");
}
let h = self.angle()?;
let comma = self.ch(b',');
let s = self.scaled_component(1., 1.)?.map(|x| x.max(0.));
self.optional_comma(comma)?;
let l = self.scaled_component(1., 1.)?.map(|x| x.clamp(0., 100.));
let mut alpha = Some(1.0);
if self.opacity_separator(comma) {
alpha = self.scaled_component(1., 0.01)?.map(|a| a.clamp(0., 1.));
}
self.ws();
if !self.ch(b')') {
return Err("expected closing parenthesis");
}
Ok(color_from_components([h, s, l, alpha], ColorSpaceTag::Hsl))
}

fn color(&mut self) -> Result<DynamicColor, Error> {
if !self.raw_ch(b'(') {
return Err("expected arguments");
Expand Down Expand Up @@ -383,7 +403,7 @@ pub fn parse_color(s: &str) -> Result<DynamicColor, Error> {
"lch" => parser.lch(100.0, 1.25, ColorSpaceTag::Lch),
"oklab" => parser.lab(1.0, 0.004, ColorSpaceTag::Oklab),
"oklch" => parser.lch(1.0, 0.004, ColorSpaceTag::Oklch),
"transparent" => Ok(color_from_components([Some(0.); 4], ColorSpaceTag::Srgb)),
"hsl" | "hsla" => parser.hsl(),
"color" => parser.color(),
_ => {
if let Some([r, g, b, a]) = crate::x11_colors::lookup_palette(id) {
Expand Down
42 changes: 25 additions & 17 deletions color/src/serialize.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,28 +54,36 @@ fn write_color_function(color: &DynamicColor, name: &str, f: &mut Formatter<'_>)
write!(f, ")")
}

fn write_legacy_function(
color: &DynamicColor,
name: &str,
scale: f32,
f: &mut Formatter<'_>,
) -> Result {
let opt_a = if color.components[3] < 1.0 { "a" } else { "" };
write!(f, "{name}{opt_a}(")?;
write_scaled_component(color, 0, f, scale)?;
write!(f, ", ")?;
write_scaled_component(color, 1, f, scale)?;
write!(f, ", ")?;
write_scaled_component(color, 2, f, scale)?;
if color.components[3] < 1.0 {
write!(f, ", ")?;
// TODO: clamp negative values
write_scaled_component(color, 3, f, 1.0)?;
}
write!(f, ")")
}

impl core::fmt::Display for DynamicColor {
fn fmt(&self, f: &mut Formatter<'_>) -> Result {
match self.cs {
ColorSpaceTag::Srgb => {
// A case can be made this isn't the best serialization in general,
// because CSS parsing of out-of-gamut components will clamp.
let opt_a = if self.components[3] < 1.0 { "a" } else { "" };
write!(f, "rgb{opt_a}(")?;
write_scaled_component(self, 0, f, 255.0)?;
write!(f, ", ")?;
write_scaled_component(self, 1, f, 255.0)?;
write!(f, ", ")?;
write_scaled_component(self, 2, f, 255.0)?;
if self.components[3] < 1.0 {
write!(f, ", ")?;
// TODO: clamp negative values
write_scaled_component(self, 3, f, 1.0)?;
}
write!(f, ")")
}
// A case can be made this isn't the best serialization in general,
// because CSS parsing of out-of-gamut components will clamp.
ColorSpaceTag::Srgb => write_legacy_function(self, "rgb", 255.0, f),
ColorSpaceTag::LinearSrgb => write_color_function(self, "srgb-linear", f),
ColorSpaceTag::DisplayP3 => write_color_function(self, "display-p3", f),
ColorSpaceTag::Hsl => write_legacy_function(self, "hsl", 1.0, f),
ColorSpaceTag::XyzD65 => write_color_function(self, "xyz", f),
ColorSpaceTag::Lab => write_modern_function(self, "lab", f),
ColorSpaceTag::Lch => write_modern_function(self, "lch", f),
Expand Down
12 changes: 8 additions & 4 deletions color/src/tag.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
//! The color space tag enum.
use crate::{
ColorSpace, ColorSpaceLayout, DisplayP3, Lab, Lch, LinearSrgb, Missing, Oklab, Oklch, Srgb,
XyzD65,
ColorSpace, ColorSpaceLayout, DisplayP3, Hsl, Lab, Lch, LinearSrgb, Missing, Oklab, Oklch,
Srgb, XyzD65,
};

/// The color space tag for dynamic colors.
Expand All @@ -28,8 +28,7 @@ pub enum ColorSpaceTag {
Lab,
/// The [`Lch`] color space.
Lch,
// TODO: link
/// The `Hsl` color space.
/// The [`Hsl`] color space.
Hsl,
// TODO: link
/// The `Hwb` color space.
Expand Down Expand Up @@ -137,6 +136,7 @@ impl ColorSpaceTag {
Self::Oklch => Oklch::from_linear_srgb(rgb),
Self::DisplayP3 => DisplayP3::from_linear_srgb(rgb),
Self::XyzD65 => XyzD65::from_linear_srgb(rgb),
Self::Hsl => Hsl::from_linear_srgb(rgb),
_ => todo!(),
}
}
Expand All @@ -154,6 +154,7 @@ impl ColorSpaceTag {
Self::Oklch => Oklch::to_linear_srgb(src),
Self::DisplayP3 => DisplayP3::to_linear_srgb(src),
Self::XyzD65 => XyzD65::to_linear_srgb(src),
Self::Hsl => Hsl::to_linear_srgb(src),
_ => todo!(),
}
}
Expand All @@ -166,6 +167,8 @@ impl ColorSpaceTag {
_ if self == target => src,
(Self::Oklab, Self::Oklch) | (Self::Lab, Self::Lch) => Oklab::convert::<Oklch>(src),
(Self::Oklch, Self::Oklab) | (Self::Lch, Self::Lab) => Oklch::convert::<Oklab>(src),
(Self::Srgb, Self::Hsl) => Srgb::convert::<Hsl>(src),
(Self::Hsl, Self::Srgb) => Hsl::convert::<Srgb>(src),
_ => target.from_linear_srgb(self.to_linear_srgb(src)),
}
}
Expand Down Expand Up @@ -199,6 +202,7 @@ impl ColorSpaceTag {
Self::Oklch => Oklch::clip(src),
Self::DisplayP3 => DisplayP3::clip(src),
Self::XyzD65 => XyzD65::clip(src),
Self::Hsl => Hsl::clip(src),
_ => todo!(),
}
}
Expand Down

0 comments on commit f25d6fb

Please sign in to comment.