diff --git a/color/examples/parse.rs b/color/examples/parse.rs index 2281399..3ebe1c7 100644 --- a/color/examples/parse.rs +++ b/color/examples/parse.rs @@ -11,7 +11,7 @@ //! cargo run --example parse 'oklab(0.5 0.2 0)' //! ``` -use color::{AlphaColor, CssColor, Srgb}; +use color::{AlphaColor, CssColor, Lab, Srgb}; fn main() { let arg = std::env::args().nth(1).expect("give color as arg"); @@ -22,6 +22,8 @@ fn main() { let tagged = CssColor::to_tagged_color(color); let srgba: AlphaColor = tagged.to_alpha_color(); println!("{srgba:?}"); + let lab: AlphaColor = color.to_alpha_color(); + println!("{lab:?}"); } Err(e) => println!("error: {e}"), } diff --git a/color/src/color.rs b/color/src/color.rs index 038a2cd..0b70ac4 100644 --- a/color/src/color.rs +++ b/color/src/color.rs @@ -201,15 +201,22 @@ impl OpaqueColor { /// Map the lightness of the color. /// /// 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. + /// 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]. + /// + /// [Lab]: crate::Lab + /// [Lch]: crate::Lch + /// [HSL]: https://www.w3.org/TR/css-color-4/#the-hsl-notation #[must_use] pub fn map_lightness(self, f: impl Fn(f32) -> f32) -> Self { match CS::TAG { - Some(ColorSpaceTag::Oklab) - | Some(ColorSpaceTag::Oklch) - | Some(ColorSpaceTag::Lab) - | Some(ColorSpaceTag::Lch) => self.map(|l, c1, c2| [f(l), c1, c2]), + Some(ColorSpaceTag::Lab) | Some(ColorSpaceTag::Lch) => { + self.map(|l, c1, c2| [100.0 * f(l * 0.01), c1, c2]) + } + Some(ColorSpaceTag::Oklab) | Some(ColorSpaceTag::Oklch) => { + self.map(|l, c1, c2| [f(l), c1, c2]) + } Some(ColorSpaceTag::Hsl) => self.map(|h, s, l| [h, s, 100.0 * f(l * 0.01)]), _ => self.map_in::(|l, a, b| [f(l), a, b]), } @@ -297,15 +304,22 @@ impl AlphaColor { /// Map the lightness of the color. /// /// 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. + /// 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]. + /// + /// [Lab]: crate::Lab + /// [Lch]: crate::Lch + /// [HSL]: https://www.w3.org/TR/css-color-4/#the-hsl-notation #[must_use] pub fn map_lightness(self, f: impl Fn(f32) -> f32) -> Self { match CS::TAG { - Some(ColorSpaceTag::Oklab) - | Some(ColorSpaceTag::Oklch) - | Some(ColorSpaceTag::Lab) - | Some(ColorSpaceTag::Lch) => self.map(|l, c1, c2, a| [f(l), c1, c2, a]), + Some(ColorSpaceTag::Lab) | Some(ColorSpaceTag::Lch) => { + self.map(|l, c1, c2, a| [100.0 * f(l * 0.01), c1, c2, a]) + } + Some(ColorSpaceTag::Oklab) | Some(ColorSpaceTag::Oklch) => { + self.map(|l, c1, c2, a| [f(l), c1, c2, a]) + } Some(ColorSpaceTag::Hsl) => self.map(|h, s, l, a| [h, s, 100.0 * f(l * 0.01), a]), _ => self.map_in::(|l, a, b, alpha| [f(l), a, b, alpha]), } diff --git a/color/src/colorspace.rs b/color/src/colorspace.rs index 1ada0b5..947d522 100644 --- a/color/src/colorspace.rs +++ b/color/src/colorspace.rs @@ -407,8 +407,8 @@ impl ColorSpace for Oklab { matmul(&OKLAB_LMS_TO_LAB, lms) } - fn scale_chroma(src: [f32; 3], scale: f32) -> [f32; 3] { - [src[0], src[1] * scale, src[2] * scale] + fn scale_chroma([l, a, b]: [f32; 3], scale: f32) -> [f32; 3] { + [l, a * scale, b * scale] } fn convert(src: [f32; 3]) -> [f32; 3] { @@ -468,8 +468,8 @@ impl ColorSpace for Oklch { Oklab::to_linear_srgb(lch_to_lab(src)) } - fn scale_chroma(src: [f32; 3], scale: f32) -> [f32; 3] { - [src[0], src[1] * scale, src[2]] + fn scale_chroma([l, c, h]: [f32; 3], scale: f32) -> [f32; 3] { + [l, c * scale, h] } fn convert(src: [f32; 3]) -> [f32; 3] { @@ -487,3 +487,154 @@ impl ColorSpace for Oklch { [l.clamp(0., 1.), c.max(0.), h] } } + +/// ๐ŸŒŒ The CIELAB color space +/// +/// The CIE L\*a\*b\* color space was created in 1976 to be more perceptually +/// uniform than RGB color spaces, and is both widely used and the basis of +/// other efforts to express colors, including [FreieFarbe]. +/// +/// Its components are `[L, a, b]` with +/// - `L` - the lightness with a natural bound between 0 and 100, where 0 represents pure black and 100 +/// represents the lightness of white; +/// - `a` - how green/red the color is; and +/// - `b` - how blue/yellow the color is. +/// +/// `a` and `b` are unbounded, but are usually between -160 and 160. +/// +/// The color space has poor hue linearity and hue uniformity compared with +/// [Oklab], though superior lightness uniformity. Note that the lightness +/// range differs from Oklab as well; in Oklab white has a lightness of 1. +/// +/// The CIE L\*a\*b\* color space is defined in terms of a D50 white point. For +/// conversion between color spaces with other illuminants (especially D65 +/// as in sRGB), the standard Bradform linear chromatic adaptation transform +/// is used. +/// +/// This corresponds to the color space in [CSS Color Module Level 4 ยง 9.1 ][css-sec]. +/// +/// Lab has a cylindrical counterpart: [Lch]. +/// +/// [FreieFarbe]: https://freiefarbe.de/en/ +/// [css-sec]: https://www.w3.org/TR/css-color-4/#cie-lab +#[derive(Clone, Copy, Debug)] +pub struct Lab; + +// Matrices computed from CSS Color 4 spec, then used `cargo clippy --fix` +// to reduce precision to f32 and add underscores. + +// This is D65_to_D50 * lin_sRGB_to_XYZ, then rows scaled by 1 / D50[i]. +const LAB_SRGB_TO_XYZ: [[f32; 3]; 3] = [ + [0.452_211_65, 0.399_412_24, 0.148_376_09], + [0.222_493_17, 0.716_887, 0.060_619_81], + [0.016_875_342, 0.117_659_41, 0.865_465_2], +]; + +// This is XYZ_to_lin_sRGB * D50_to_D65, then columns scaled by D50[i]. +const LAB_XYZ_TO_SRGB: [[f32; 3]; 3] = [ + [3.022_233_7, -1.617_386, -0.404_847_65], + [-0.943_848_25, 1.916_254_4, 0.027_593_868], + [0.069_386_27, -0.228_976_76, 1.159_590_5], +]; + +const EPSILON: f32 = 216. / 24389.; +const KAPPA: f32 = 24389. / 27.; + +impl ColorSpace for Lab { + const TAG: Option = Some(ColorSpaceTag::Lab); + + fn to_linear_srgb([l, a, b]: [f32; 3]) -> [f32; 3] { + let f1 = l * (1. / 116.) + (16. / 116.); + let f0 = a * (1. / 500.) + f1; + let f2 = f1 - b * (1. / 200.); + let xyz = [f0, f1, f2].map(|value| { + // This is EPSILON.cbrt() but that function isn't const (yet) + const EPSILON_CBRT: f32 = 0.206_896_56; + if value > EPSILON_CBRT { + value * value * value + } else { + (116. / KAPPA) * value - (16. / KAPPA) + } + }); + matmul(&LAB_XYZ_TO_SRGB, xyz) + } + + fn from_linear_srgb(src: [f32; 3]) -> [f32; 3] { + let xyz = matmul(&LAB_SRGB_TO_XYZ, src); + let f = xyz.map(|value| { + if value > EPSILON { + value.cbrt() + } else { + (KAPPA / 116.) * value + (16. / 116.) + } + }); + let l = 116. * f[1] - 16.; + let a = 500. * (f[0] - f[1]); + let b = 200. * (f[1] - f[2]); + [l, a, b] + } + + fn scale_chroma([l, a, b]: [f32; 3], scale: f32) -> [f32; 3] { + [l, a * scale, b * scale] + } + + fn convert(src: [f32; 3]) -> [f32; 3] { + if TypeId::of::() == TypeId::of::() { + src + } else if TypeId::of::() == TypeId::of::() { + lab_to_lch(src) + } else { + let lin_rgb = Self::to_linear_srgb(src); + TargetCS::from_linear_srgb(lin_rgb) + } + } + + fn clip([l, a, b]: [f32; 3]) -> [f32; 3] { + [l.clamp(0., 100.), a, b] + } +} + +/// ๐ŸŒŒ The cylindrical version of the [Lab] color space. +/// +/// Its components are `[L, C, h]` with +/// - `L` - the lightness as in [`Lab`]; +/// - `C` - the chromatic intensity, the natural lower bound of 0 being achromatic, usually not +/// exceeding 160; and +/// - `h` - the hue angle in degrees. +/// +/// See [`Oklch`] for a similar color space but with better hue linearity. +#[derive(Clone, Copy, Debug)] +pub struct Lch; + +impl ColorSpace for Lch { + const TAG: Option = Some(ColorSpaceTag::Lch); + + const LAYOUT: ColorSpaceLayout = ColorSpaceLayout::HueThird; + + fn from_linear_srgb(src: [f32; 3]) -> [f32; 3] { + lab_to_lch(Lab::from_linear_srgb(src)) + } + + fn to_linear_srgb(src: [f32; 3]) -> [f32; 3] { + Lab::to_linear_srgb(lch_to_lab(src)) + } + + fn scale_chroma([l, c, h]: [f32; 3], scale: f32) -> [f32; 3] { + [l, c * scale, h] + } + + fn convert(src: [f32; 3]) -> [f32; 3] { + if TypeId::of::() == TypeId::of::() { + src + } else if TypeId::of::() == TypeId::of::() { + lch_to_lab(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/css.rs b/color/src/css.rs index b54edbc..2a3b0c4 100644 --- a/color/src/css.rs +++ b/color/src/css.rs @@ -254,10 +254,12 @@ impl CssColor { #[must_use] pub fn map_lightness(self, f: impl Fn(f32) -> f32) -> Self { match self.cs { - ColorSpaceTag::Oklab - | ColorSpaceTag::Oklch - | ColorSpaceTag::Lab - | ColorSpaceTag::Lch => self.map(|l, c1, c2, a| [f(l), c1, c2, a]), + ColorSpaceTag::Lab | ColorSpaceTag::Lch => { + self.map(|l, c1, c2, a| [100.0 * f(l * 0.01), c1, c2, a]) + } + ColorSpaceTag::Oklab | ColorSpaceTag::Oklch => { + self.map(|l, c1, c2, a| [f(l), c1, c2, a]) + } ColorSpaceTag::Hsl => self.map(|h, s, l, a| [h, s, 100.0 * f(l * 0.01), a]), _ => self.map_in(ColorSpaceTag::Oklab, |l, a, b, alpha| [f(l), a, b, alpha]), } diff --git a/color/src/lib.rs b/color/src/lib.rs index e1ac2f5..7a79d88 100644 --- a/color/src/lib.rs +++ b/color/src/lib.rs @@ -37,7 +37,7 @@ mod floatfuncs; pub use bitset::Bitset; pub use color::{AlphaColor, HueDirection, OpaqueColor, PremulColor}; pub use colorspace::{ - ColorSpace, ColorSpaceLayout, DisplayP3, LinearSrgb, Oklab, Oklch, Srgb, XyzD65, + ColorSpace, ColorSpaceLayout, DisplayP3, Lab, Lch, LinearSrgb, Oklab, Oklch, Srgb, XyzD65, }; pub use css::{CssColor, Interpolator}; pub use gradient::{gradient, GradientIter}; diff --git a/color/src/parse.rs b/color/src/parse.rs index 57b3fe5..16dd2a5 100644 --- a/color/src/parse.rs +++ b/color/src/parse.rs @@ -298,38 +298,36 @@ impl<'a> Parser<'a> { Ok(alpha) } - fn oklab(&mut self) -> Result { + fn lab(&mut self, lmax: f64, c: f64, tag: ColorSpaceTag) -> Result { if !self.raw_ch(b'(') { return Err("expected arguments"); } - let l = self.scaled_component(1., 0.01)?.map(|x| x.clamp(0., 1.)); - let a = self.scaled_component(1., 0.004)?; - let b = self.scaled_component(1., 0.004)?; + let l = self + .scaled_component(1., 0.01 * lmax)? + .map(|x| x.clamp(0., lmax)); + let a = self.scaled_component(1., c)?; + let b = self.scaled_component(1., c)?; let alpha = self.optional_alpha()?; if !self.ch(b')') { return Err("expected closing parenthesis"); } - Ok(color_from_components( - [l, a, b, alpha], - ColorSpaceTag::Oklab, - )) + Ok(color_from_components([l, a, b, alpha], tag)) } - fn oklch(&mut self) -> Result { + fn lch(&mut self, lmax: f64, c: f64, tag: ColorSpaceTag) -> Result { if !self.raw_ch(b'(') { return Err("expected arguments"); } - let l = self.scaled_component(1., 0.01)?.map(|x| x.clamp(0., 1.)); - let c = self.scaled_component(1., 0.004)?.map(|x| x.max(0.)); + let l = self + .scaled_component(1., 0.01 * lmax)? + .map(|x| x.clamp(0., lmax)); + let c = self.scaled_component(1., c)?.map(|x| x.max(0.)); let h = self.angle()?; let alpha = self.optional_alpha()?; if !self.ch(b')') { return Err("expected closing parenthesis"); } - Ok(color_from_components( - [l, c, h, alpha], - ColorSpaceTag::Oklch, - )) + Ok(color_from_components([l, c, h, alpha], tag)) } fn color(&mut self) -> Result { @@ -375,8 +373,10 @@ pub fn parse_color(s: &str) -> Result { if let Some(id) = parser.ident() { match id { "rgb" | "rgba" => parser.rgb(), - "oklab" => parser.oklab(), - "oklch" => parser.oklch(), + "lab" => parser.lab(100.0, 1.25, ColorSpaceTag::Lab), + "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)), "color" => parser.color(), _ => { diff --git a/color/src/tagged.rs b/color/src/tagged.rs index 2860096..cedfbdd 100644 --- a/color/src/tagged.rs +++ b/color/src/tagged.rs @@ -5,8 +5,8 @@ use crate::{ color::{add_alpha, split_alpha}, - AlphaColor, Bitset, ColorSpace, ColorSpaceLayout, DisplayP3, LinearSrgb, Oklab, Oklch, Srgb, - XyzD65, + AlphaColor, Bitset, ColorSpace, ColorSpaceLayout, DisplayP3, Lab, Lch, LinearSrgb, Oklab, + Oklch, Srgb, XyzD65, }; /// The color space tag for tagged colors. @@ -142,6 +142,8 @@ impl ColorSpaceTag { match self { Self::Srgb => Srgb::from_linear_srgb(rgb), Self::LinearSrgb => rgb, + Self::Lab => Lab::from_linear_srgb(rgb), + Self::Lch => Lch::from_linear_srgb(rgb), Self::Oklab => Oklab::from_linear_srgb(rgb), Self::Oklch => Oklch::from_linear_srgb(rgb), Self::DisplayP3 => DisplayP3::from_linear_srgb(rgb), @@ -157,6 +159,8 @@ impl ColorSpaceTag { match self { Self::Srgb => Srgb::to_linear_srgb(src), Self::LinearSrgb => src, + Self::Lab => Lab::to_linear_srgb(src), + Self::Lch => Lch::to_linear_srgb(src), Self::Oklab => Oklab::to_linear_srgb(src), Self::Oklch => Oklch::to_linear_srgb(src), Self::DisplayP3 => DisplayP3::to_linear_srgb(src), @@ -200,6 +204,8 @@ impl ColorSpaceTag { match self { Self::Srgb => Srgb::clip(src), Self::LinearSrgb => LinearSrgb::clip(src), + Self::Lab => Lab::clip(src), + Self::Lch => Lch::clip(src), Self::Oklab => Oklab::clip(src), Self::Oklch => Oklch::clip(src), Self::DisplayP3 => DisplayP3::clip(src),