From f25d6fbc8a1240cad29f9353ca6bf1ace7592f85 Mon Sep 17 00:00:00 2001 From: Raph Levien Date: Sat, 9 Nov 2024 20:16:17 -0800 Subject: [PATCH] Hsl color space I think this is complete. --- color/src/color.rs | 8 +-- color/src/colorspace.rs | 114 ++++++++++++++++++++++++++++++++++++++++ color/src/lib.rs | 2 +- color/src/parse.rs | 22 +++++++- color/src/serialize.rs | 42 +++++++++------ color/src/tag.rs | 12 +++-- 6 files changed, 173 insertions(+), 27 deletions(-) diff --git a/color/src/color.rs b/color/src/color.rs index 1fcf5ce..b7e657b 100644 --- a/color/src/color.rs +++ b/color/src/color.rs @@ -228,11 +228,11 @@ impl OpaqueColor { /// 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 { @@ -340,11 +340,11 @@ impl AlphaColor { /// 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 { diff --git a/color/src/colorspace.rs b/color/src/colorspace.rs index ff2c5a7..8dc8de0 100644 --- a/color/src/colorspace.rs +++ b/color/src/colorspace.rs @@ -221,6 +221,17 @@ impl ColorSpace for Srgb { src.map(lin_to_srgb) } + fn convert(src: [f32; 3]) -> [f32; 3] { + if TypeId::of::() == TypeId::of::() { + src + } else if TypeId::of::() == TypeId::of::() { + 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.)] } @@ -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 = 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(src: [f32; 3]) -> [f32; 3] { + if TypeId::of::() == TypeId::of::() { + src + } else if TypeId::of::() == TypeId::of::() { + 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] + } +} diff --git a/color/src/lib.rs b/color/src/lib.rs index 8933f32..db19623 100644 --- a/color/src/lib.rs +++ b/color/src/lib.rs @@ -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}; diff --git a/color/src/parse.rs b/color/src/parse.rs index 9afd9f7..7316dbf 100644 --- a/color/src/parse.rs +++ b/color/src/parse.rs @@ -336,6 +336,26 @@ impl<'a> Parser<'a> { Ok(color_from_components([l, c, h, alpha], tag)) } + fn hsl(&mut self) -> Result { + 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 { if !self.raw_ch(b'(') { return Err("expected arguments"); @@ -383,7 +403,7 @@ pub fn parse_color(s: &str) -> Result { "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) { diff --git a/color/src/serialize.rs b/color/src/serialize.rs index 73e7c92..cf22ba5 100644 --- a/color/src/serialize.rs +++ b/color/src/serialize.rs @@ -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), diff --git a/color/src/tag.rs b/color/src/tag.rs index df73c17..02557ee 100644 --- a/color/src/tag.rs +++ b/color/src/tag.rs @@ -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. @@ -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. @@ -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!(), } } @@ -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!(), } } @@ -166,6 +167,8 @@ impl ColorSpaceTag { _ if self == target => src, (Self::Oklab, Self::Oklch) | (Self::Lab, Self::Lch) => Oklab::convert::(src), (Self::Oklch, Self::Oklab) | (Self::Lch, Self::Lab) => Oklch::convert::(src), + (Self::Srgb, Self::Hsl) => Srgb::convert::(src), + (Self::Hsl, Self::Srgb) => Hsl::convert::(src), _ => target.from_linear_srgb(self.to_linear_srgb(src)), } } @@ -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!(), } }