diff --git a/Cargo.lock b/Cargo.lock index 0b44436..6be21e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5,6 +5,9 @@ version = 3 [[package]] name = "color" version = "0.1.0" +dependencies = [ + "libm", +] [[package]] name = "color_operations" @@ -12,3 +15,9 @@ version = "0.1.0" dependencies = [ "color", ] + +[[package]] +name = "libm" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" diff --git a/color/Cargo.toml b/color/Cargo.toml index 6a4c154..6e00619 100644 --- a/color/Cargo.toml +++ b/color/Cargo.toml @@ -15,7 +15,7 @@ publish = false [features] default = ["std"] std = [] -libm = [] +libm = ["dep:libm"] [package.metadata.docs.rs] all-features = true @@ -25,5 +25,9 @@ targets = [] [dependencies] +[dependencies.libm] +version = "0.2.11" +optional = true + [lints] workspace = true diff --git a/color/src/bitset.rs b/color/src/bitset.rs new file mode 100644 index 0000000..9b897f4 --- /dev/null +++ b/color/src/bitset.rs @@ -0,0 +1,50 @@ +// Copyright 2024 the Color Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! A simple bitset. + +/// A simple bitset, for representing missing components. +#[derive(Default, Clone, Copy, PartialEq, Eq, Debug)] +pub struct Bitset(u8); + +impl Bitset { + pub fn contains(self, ix: usize) -> bool { + (self.0 & (1 << ix)) != 0 + } + + pub fn set(&mut self, ix: usize) { + self.0 |= 1 << ix; + } + + pub fn single(ix: usize) -> Self { + Self(1 << ix) + } + + pub fn any(self) -> bool { + self.0 != 0 + } +} + +impl core::ops::BitAnd for Bitset { + type Output = Self; + + fn bitand(self, rhs: Self) -> Self { + Self(self.0 & rhs.0) + } +} + +impl core::ops::BitOr for Bitset { + type Output = Self; + + fn bitor(self, rhs: Self) -> Self { + Self(self.0 | rhs.0) + } +} + +impl core::ops::Not for Bitset { + type Output = Self; + + fn not(self) -> Self::Output { + Self(!self.0) + } +} diff --git a/color/src/color.rs b/color/src/color.rs new file mode 100644 index 0000000..ee3780b --- /dev/null +++ b/color/src/color.rs @@ -0,0 +1,531 @@ +// Copyright 2024 the Color Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Concrete types for colors. + +use core::any::TypeId; +use core::marker::PhantomData; + +use crate::{Colorspace, ColorspaceLayout}; + +#[cfg(all(not(feature = "std"), not(test)))] +use crate::floatfuncs::FloatFuncs; + +/// An opaque color. +/// +/// A color in a colorspace known at compile time, without transparency. Note +/// that "opaque" refers to the color, not the representation; the components +/// are publicly accessible. +#[derive(Clone, Copy, Debug)] +pub struct OpaqueColor { + pub components: [f32; 3], + pub cs: PhantomData, +} + +/// A color with an alpha channel. +/// +/// A color in a colorspace known at compile time, with an alpha channel. +#[derive(Clone, Copy, Debug)] +pub struct AlphaColor { + pub components: [f32; 4], + pub cs: PhantomData, +} + +/// A color with premultiplied alpha. +/// +/// A color in a colorspace known at compile time, with a premultiplied +/// alpha channel. +/// +/// Following the convention of CSS Color 4, in cylindrical color spaces +/// the hue channel is not premultiplied. If it were, interpolation would +/// give undesirable results. +#[derive(Clone, Copy, Debug)] +pub struct PremulColor { + pub components: [f32; 4], + pub cs: PhantomData, +} + +/// The hue direction for interpolation. +/// +/// This type corresponds to `hue-interpolation-method` in the CSS Color +/// 4 spec. +#[derive(Clone, Copy, Default, Debug)] +#[non_exhaustive] +pub enum HueDirection { + #[default] + Shorter, + Longer, + Increasing, + Decreasing, + // It's possible we'll add "raw"; color.js has it. +} + +/// Fixup hue based on specified hue direction. +/// +/// Reference: §12.4 of CSS Color 4 spec +/// +/// Note that this technique has been tweaked to only modify the second hue. +/// The rationale for this is to support multiple gradient stops, for example +/// in a spline. Apply the fixup to successive adjacent pairs. +/// +/// In addition, hues outside [0, 360) are supported, with a resulting hue +/// difference always in [-360, 360]. +fn fixup_hue(h1: f32, h2: &mut f32, direction: HueDirection) { + let dh = (*h2 - h1) * (1. / 360.); + match direction { + HueDirection::Shorter => { + // Round, resolving ties toward zero. + let rounded = if dh - dh.floor() == 0.5 { + dh.trunc() + } else { + dh.round() + }; + *h2 -= 360. * rounded; + } + HueDirection::Longer => { + let t = 2.0 * dh.abs().ceil() - (dh.abs() + 1.5).floor(); + *h2 += 360.0 * (t.copysign(0.0 - dh)); + } + HueDirection::Increasing => *h2 -= 360.0 * dh.floor(), + HueDirection::Decreasing => *h2 -= 360.0 * dh.ceil(), + } +} + +pub(crate) fn fixup_hues_for_interpolate( + a: [f32; 3], + b: &mut [f32; 3], + layout: ColorspaceLayout, + direction: HueDirection, +) { + if let Some(ix) = layout.hue_channel() { + fixup_hue(a[ix], &mut b[ix], direction); + } +} + +impl OpaqueColor { + pub const fn new(components: [f32; 3]) -> Self { + let cs = PhantomData; + Self { components, cs } + } + + pub fn convert(self) -> OpaqueColor { + if TypeId::of::() == TypeId::of::() { + OpaqueColor::new(self.components) + } else { + let lin_rgb = T::to_linear_srgb(self.components); + OpaqueColor::new(U::from_linear_srgb(lin_rgb)) + } + } + + /// Add an alpha channel. + /// + /// This function is the inverse of [`AlphaColor::split`]. + pub const fn with_alpha(self, alpha: f32) -> AlphaColor { + AlphaColor::new(add_alpha(self.components, alpha)) + } + + /// Difference between two colors by Euclidean metric. + pub fn difference(self, other: Self) -> f32 { + let x = self.components; + let y = other.components; + let (d0, d1, d2) = (x[0] - y[0], x[1] - y[1], x[2] - y[2]); + (d0 * d0 + d1 * d1 + d2 * d2).sqrt() + } + + /// Linearly interpolate colors, without hue fixup. + /// + /// This method produces meaningful results in rectangular colorspaces, + /// or if hue fixup has been applied. + #[must_use] + pub fn lerp_rect(self, other: Self, t: f32) -> Self { + self + t * (other - self) + } + + /// Apply hue fixup for interpolation. + /// + /// Adjust the hue angle of `other` so that linear interpolation results in + /// the expected hue direction. + pub fn fixup_hues(self, other: &mut Self, direction: HueDirection) { + fixup_hues_for_interpolate(self.components, &mut other.components, T::LAYOUT, direction); + } + + /// Linearly interpolate colors, with hue fixup if needed. + #[must_use] + pub fn lerp(self, mut other: Self, t: f32, direction: HueDirection) -> Self { + self.fixup_hues(&mut other, direction); + self.lerp_rect(other, t) + } + + /// Scale the chroma by the given amount. + /// + /// See [`Colorspace::scale_chroma`] for more details. + #[must_use] + pub fn scale_chroma(self, scale: f32) -> Self { + Self::new(T::scale_chroma(self.components, scale)) + } + + /// Compute the relative luminance of the color. + /// + /// This can be useful for choosing contrasting colors, and follows the + /// WCAG 2.1 spec. + pub fn relative_luminance(self) -> f32 { + let rgb = T::to_linear_srgb(self.components); + 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2] + } +} + +pub(crate) const fn split_alpha(x: [f32; 4]) -> ([f32; 3], f32) { + ([x[0], x[1], x[2]], x[3]) +} + +pub(crate) const fn add_alpha(x: [f32; 3], a: f32) -> [f32; 4] { + [x[0], x[1], x[2], a] +} + +impl AlphaColor { + pub const fn new(components: [f32; 4]) -> Self { + let cs = PhantomData; + Self { components, cs } + } + + /// Split into opaque and alpha components. + /// + /// This function is the inverse of [`OpaqueColor::with_alpha`]. + #[must_use] + pub const fn split(self) -> (OpaqueColor, f32) { + let (opaque, alpha) = split_alpha(self.components); + (OpaqueColor::new(opaque), alpha) + } + + #[must_use] + pub fn convert(self) -> AlphaColor { + if TypeId::of::() == TypeId::of::() { + AlphaColor::new(self.components) + } else { + let (opaque, alpha) = split_alpha(self.components); + let lin_rgb = T::to_linear_srgb(opaque); + AlphaColor::new(add_alpha(U::from_linear_srgb(lin_rgb), alpha)) + } + } + + #[must_use] + pub const fn premultiply(self) -> PremulColor { + let (opaque, alpha) = split_alpha(self.components); + PremulColor::new(add_alpha(T::LAYOUT.scale(opaque, alpha), alpha)) + } + + #[must_use] + pub fn lerp_rect(self, other: Self, t: f32) -> Self { + self.premultiply() + .lerp_rect(other.premultiply(), t) + .un_premultiply() + } + + #[must_use] + pub fn lerp(self, other: Self, t: f32, direction: HueDirection) -> Self { + self.premultiply() + .lerp(other.premultiply(), t, direction) + .un_premultiply() + } + + #[must_use] + pub const fn mul_alpha(self, rhs: f32) -> Self { + let (opaque, alpha) = split_alpha(self.components); + Self::new(add_alpha(opaque, alpha * rhs)) + } + + /// Scale the chroma by the given amount. + /// + /// See [`Colorspace::scale_chroma`] for more details. + #[must_use] + pub fn scale_chroma(self, scale: f32) -> Self { + let (opaque, alpha) = split_alpha(self.components); + Self::new(add_alpha(T::scale_chroma(opaque, scale), alpha)) + } +} + +impl PremulColor { + pub const fn new(components: [f32; 4]) -> Self { + let cs = PhantomData; + Self { components, cs } + } + + #[must_use] + pub fn convert(self) -> PremulColor { + if TypeId::of::() == TypeId::of::() { + PremulColor::new(self.components) + } else if U::IS_LINEAR && T::IS_LINEAR { + let (multiplied, alpha) = split_alpha(self.components); + let lin_rgb = T::to_linear_srgb(multiplied); + PremulColor::new(add_alpha(U::from_linear_srgb(lin_rgb), alpha)) + } else { + self.un_premultiply().convert().premultiply() + } + } + + #[must_use] + pub fn un_premultiply(self) -> AlphaColor { + let (multiplied, alpha) = split_alpha(self.components); + let scale = if alpha == 0.0 { 1.0 } else { 1.0 / alpha }; + AlphaColor::new(add_alpha(T::LAYOUT.scale(multiplied, scale), alpha)) + } + + /// Interpolate colors. + /// + /// Note: this function doesn't fix up hue in cylindrical spaces. It is + /// still be useful if the hue angles are compatible, particularly if + /// the fixup has been applied. + #[must_use] + pub fn lerp_rect(self, other: Self, t: f32) -> Self { + self + t * (other - self) + } + + /// Apply hue fixup for interpolation. + /// + /// Adjust the hue angle of `other` so that linear interpolation results in + /// the expected hue direction. + pub fn fixup_hues(self, other: &mut Self, direction: HueDirection) { + if let Some(ix) = T::LAYOUT.hue_channel() { + fixup_hue(self.components[ix], &mut other.components[ix], direction); + } + } + + /// Linearly interpolate colors, with hue fixup if needed. + #[must_use] + pub fn lerp(self, mut other: Self, t: f32, direction: HueDirection) -> Self { + self.fixup_hues(&mut other, direction); + self.lerp_rect(other, t) + } + + #[must_use] + pub const fn mul_alpha(self, rhs: f32) -> Self { + let (multiplied, alpha) = split_alpha(self.components); + Self::new(add_alpha(T::LAYOUT.scale(multiplied, rhs), alpha * rhs)) + } + + /// Difference between two colors by Euclidean metric. + #[must_use] + pub fn difference(self, other: Self) -> f32 { + let x = self.components; + let y = other.components; + let (d0, d1, d2, d3) = (x[0] - y[0], x[1] - y[1], x[2] - y[2], x[3] - y[3]); + (d0 * d0 + d1 * d1 + d2 * d2 + d3 * d3).sqrt() + } +} + +// Lossless conversion traits. + +impl From> for AlphaColor { + fn from(value: OpaqueColor) -> Self { + value.with_alpha(1.0) + } +} + +impl From> for PremulColor { + fn from(value: OpaqueColor) -> Self { + Self::new(add_alpha(value.components, 1.0)) + } +} + +// Arithmetic traits. A major motivation for providing these is to enable +// weighted sums, which are well defined when the weights sum to 1. In +// addition, multiplication by alpha is well defined for premultiplied +// colors in rectangular colorspaces. + +impl core::ops::Mul for OpaqueColor { + type Output = Self; + + fn mul(self, rhs: f32) -> Self { + Self::new(self.components.map(|x| x * rhs)) + } +} + +impl core::ops::Mul> for f32 { + type Output = OpaqueColor; + + fn mul(self, rhs: OpaqueColor) -> Self::Output { + rhs * self + } +} + +impl core::ops::Div for OpaqueColor { + type Output = Self; + + #[expect( + clippy::suspicious_arithmetic_impl, + reason = "somebody please teach clippy about multiplicative inverses" + )] + fn div(self, rhs: f32) -> Self { + self * rhs.recip() + } +} + +impl core::ops::Add for OpaqueColor { + type Output = Self; + + fn add(self, rhs: Self) -> Self { + let x = self.components; + let y = rhs.components; + Self::new([x[0] + y[0], x[1] + y[1], x[2] + y[2]]) + } +} + +impl core::ops::Sub for OpaqueColor { + type Output = Self; + + fn sub(self, rhs: Self) -> Self { + let x = self.components; + let y = rhs.components; + Self::new([x[0] - y[0], x[1] - y[1], x[2] - y[2]]) + } +} + +impl core::ops::Mul for AlphaColor { + type Output = Self; + + fn mul(self, rhs: f32) -> Self { + Self::new(self.components.map(|x| x * rhs)) + } +} + +impl core::ops::Mul> for f32 { + type Output = AlphaColor; + + fn mul(self, rhs: AlphaColor) -> Self::Output { + rhs * self + } +} + +impl core::ops::Div for AlphaColor { + type Output = Self; + + #[expect( + clippy::suspicious_arithmetic_impl, + reason = "somebody please teach clippy about multiplicative inverses" + )] + fn div(self, rhs: f32) -> Self { + self * rhs.recip() + } +} + +impl core::ops::Add for AlphaColor { + type Output = Self; + + fn add(self, rhs: Self) -> Self { + let x = self.components; + let y = rhs.components; + Self::new([x[0] + y[0], x[1] + y[1], x[2] + y[2], x[3] + y[3]]) + } +} + +impl core::ops::Sub for AlphaColor { + type Output = Self; + + fn sub(self, rhs: Self) -> Self { + let x = self.components; + let y = rhs.components; + Self::new([x[0] - y[0], x[1] - y[1], x[2] - y[2], x[3] - y[3]]) + } +} + +impl core::ops::Mul for PremulColor { + type Output = Self; + + fn mul(self, rhs: f32) -> Self { + Self::new(self.components.map(|x| x * rhs)) + } +} + +impl core::ops::Mul> for f32 { + type Output = PremulColor; + + fn mul(self, rhs: PremulColor) -> Self::Output { + rhs * self + } +} + +impl core::ops::Div for PremulColor { + type Output = Self; + + #[expect( + clippy::suspicious_arithmetic_impl, + reason = "somebody please teach clippy about multiplicative inverses" + )] + fn div(self, rhs: f32) -> Self { + self * rhs.recip() + } +} + +impl core::ops::Add for PremulColor { + type Output = Self; + + fn add(self, rhs: Self) -> Self { + let x = self.components; + let y = rhs.components; + Self::new([x[0] + y[0], x[1] + y[1], x[2] + y[2], x[3] + y[3]]) + } +} + +impl core::ops::Sub for PremulColor { + type Output = Self; + + fn sub(self, rhs: Self) -> Self { + let x = self.components; + let y = rhs.components; + Self::new([x[0] - y[0], x[1] - y[1], x[2] - y[2], x[3] - y[3]]) + } +} + +#[cfg(test)] +mod test { + use super::{fixup_hue, HueDirection}; + + #[test] + fn test_hue_fixup() { + // Verify that the hue arc matches the spec for all hues specified + // within [0,360). + for h1 in [0.0, 10.0, 180.0, 190.0, 350.0] { + for h2 in [0.0, 10.0, 180.0, 190.0, 350.0] { + let dh = h2 - h1; + let mut fixed_h2 = h2; + fixup_hue(h1, &mut fixed_h2, HueDirection::Shorter); + let (mut spec_h1, mut spec_h2) = (h1, h2); + if dh > 180.0 { + spec_h1 += 360.0; + } else if dh < -180.0 { + spec_h2 += 360.0; + } + assert_eq!(fixed_h2 - h1, spec_h2 - spec_h1); + + fixed_h2 = h2; + fixup_hue(h1, &mut fixed_h2, HueDirection::Longer); + spec_h1 = h1; + spec_h2 = h2; + if 0.0 < dh && dh < 180.0 { + spec_h1 += 360.0; + } else if -180.0 < dh && dh <= 0.0 { + spec_h2 += 360.0; + } + assert_eq!(fixed_h2 - h1, spec_h2 - spec_h1); + + fixed_h2 = h2; + fixup_hue(h1, &mut fixed_h2, HueDirection::Increasing); + spec_h1 = h1; + spec_h2 = h2; + if dh < 0.0 { + spec_h2 += 360.0; + } + assert_eq!(fixed_h2 - h1, spec_h2 - spec_h1); + + fixed_h2 = h2; + fixup_hue(h1, &mut fixed_h2, HueDirection::Decreasing); + spec_h1 = h1; + spec_h2 = h2; + if dh > 0.0 { + spec_h1 += 360.0; + } + assert_eq!(fixed_h2 - h1, spec_h2 - spec_h1); + } + } + } +} diff --git a/color/src/colorspace.rs b/color/src/colorspace.rs new file mode 100644 index 0000000..34465fc --- /dev/null +++ b/color/src/colorspace.rs @@ -0,0 +1,287 @@ +// Copyright 2024 the Color Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use core::f32; + +use crate::{matmul, tagged::ColorspaceTag}; + +#[cfg(all(not(feature = "std"), not(test)))] +use crate::floatfuncs::FloatFuncs; + +/// The main trait for colorspaces. +/// +/// This can be implemented by clients for conversions in and out of +/// new colorspaces. It is expected to be a zero-sized type. +/// +/// The linear sRGB colorspace is central, and other colorspaces are +/// defined as conversions in and out of that. A colorspace does not +/// explicitly define a gamut, so generally conversions will succeed +/// and round-trip, subject to numerical precision. +/// +/// White point is not explicitly represented. For color spaces with a +/// white point other than D65 (the native white point for sRGB), use +/// a linear Bradford chromatic adaptation, following CSS Color 4. +pub trait Colorspace: Clone + Copy + 'static { + /// Whether the colorspace is linear. + /// + /// Calculations in linear colorspaces can sometimes be simplified, + /// for example it is not necessary to undo premultiplication when + /// converting. + const IS_LINEAR: bool = false; + + /// The layout of the colorspace. + /// + /// The layout primarily identifies the hue channel for cylindrical + /// colorspaces, which is important because hue is not premultiplied. + const LAYOUT: ColorspaceLayout = ColorspaceLayout::Rectangular; + + /// The tag corresponding to this colorspace, if a matching tag exists. + const CS_TAG: Option = None; + + /// Convert an opaque color to linear sRGB. + /// + /// Values are likely to exceed [0, 1] for wide-gamut and HDR colors. + fn to_linear_srgb(src: [f32; 3]) -> [f32; 3]; + + /// Convert an opaque color from linear sRGB. + /// + /// In general, this method should not do any gamut clipping. + fn from_linear_srgb(src: [f32; 3]) -> [f32; 3]; + + /// Scale the chroma by the given amount. + /// + /// In colorspaces with a natural representation of chroma, scale + /// directly. In other colorspaces, equivalent results as scaling + /// chroma in Oklab. + fn scale_chroma(src: [f32; 3], scale: f32) -> [f32; 3] { + let rgb = Self::to_linear_srgb(src); + let scaled = LinearSrgb::scale_chroma(rgb, scale); + Self::from_linear_srgb(scaled) + } +} + +/// The layout of a colorspace, particularly the hue channel. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[non_exhaustive] +pub enum ColorspaceLayout { + Rectangular, + HueFirst, + HueThird, +} + +impl ColorspaceLayout { + /// Multiply all components except for hue by scale. + /// + /// This function is used for both premultiplying and un-premultiplying. See + /// §12.3 of Color 4 spec for context. + pub(crate) const fn scale(self, components: [f32; 3], scale: f32) -> [f32; 3] { + match self { + Self::Rectangular => [ + components[0] * scale, + components[1] * scale, + components[2] * scale, + ], + Self::HueFirst => [components[0], components[1] * scale, components[2] * scale], + Self::HueThird => [components[0] * scale, components[1] * scale, components[2]], + } + } + + pub(crate) const fn hue_channel(self) -> Option { + match self { + Self::Rectangular => None, + Self::HueFirst => Some(0), + Self::HueThird => Some(2), + } + } +} + +#[derive(Clone, Copy, Debug)] +pub struct LinearSrgb; + +impl Colorspace for LinearSrgb { + const IS_LINEAR: bool = true; + + const CS_TAG: Option = Some(ColorspaceTag::LinearSrgb); + + fn to_linear_srgb(src: [f32; 3]) -> [f32; 3] { + src + } + + fn from_linear_srgb(src: [f32; 3]) -> [f32; 3] { + src + } + + fn scale_chroma(src: [f32; 3], scale: f32) -> [f32; 3] { + let lms = matmul(&OKLAB_SRGB_TO_LMS, src).map(f32::cbrt); + let l = OKLAB_LMS_TO_LAB[0]; + let lightness = l[0] * lms[0] + l[1] * lms[1] + l[2] * lms[2]; + let lms_scaled = [ + lightness + scale * (lms[0] - lightness), + lightness + scale * (lms[1] - lightness), + lightness + scale * (lms[2] - lightness), + ]; + matmul(&OKLAB_LMS_TO_SRGB, lms_scaled.map(|x| x * x * x)) + } +} + +// It might be a better idea to write custom debug impls for AlphaColor and friends +#[derive(Clone, Copy, Debug)] +pub struct Srgb; + +fn srgb_to_lin(x: f32) -> f32 { + if x.abs() <= 0.04045 { + x * (1.0 / 12.92) + } else { + ((x.abs() + 0.055) * (1.0 / 1.055)).powf(2.4).copysign(x) + } +} + +fn lin_to_srgb(x: f32) -> f32 { + if x.abs() <= 0.0031308 { + x * 12.92 + } else { + (1.055 * x.abs().powf(1.0 / 2.4) - 0.055).copysign(x) + } +} + +impl Colorspace for Srgb { + const CS_TAG: Option = Some(ColorspaceTag::Srgb); + + fn to_linear_srgb(src: [f32; 3]) -> [f32; 3] { + src.map(srgb_to_lin) + } + + fn from_linear_srgb(src: [f32; 3]) -> [f32; 3] { + src.map(lin_to_srgb) + } +} + +#[derive(Clone, Copy, Debug)] +pub struct DisplayP3; + +impl Colorspace for DisplayP3 { + const CS_TAG: Option = Some(ColorspaceTag::DisplayP3); + + fn to_linear_srgb(src: [f32; 3]) -> [f32; 3] { + const LINEAR_DISPLAYP3_TO_SRGB: [[f32; 3]; 3] = [ + [1.224_940_2, -0.224_940_18, 0.0], + [-0.042_056_955, 1.042_056_9, 0.0], + [-0.019_637_555, -0.078_636_04, 1.098_273_6], + ]; + matmul(&LINEAR_DISPLAYP3_TO_SRGB, src.map(srgb_to_lin)) + } + + fn from_linear_srgb(src: [f32; 3]) -> [f32; 3] { + const LINEAR_SRGB_TO_DISPLAYP3: [[f32; 3]; 3] = [ + [0.822_461_96, 0.177_538_04, 0.0], + [0.033_194_2, 0.966_805_8, 0.0], + [0.017_082_632, 0.072_397_44, 0.910_519_96], + ]; + matmul(&LINEAR_SRGB_TO_DISPLAYP3, src).map(lin_to_srgb) + } +} + +#[derive(Clone, Copy, Debug)] +pub struct XyzD65; + +impl Colorspace for XyzD65 { + const IS_LINEAR: bool = true; + + const CS_TAG: Option = Some(ColorspaceTag::XyzD65); + + fn to_linear_srgb(src: [f32; 3]) -> [f32; 3] { + const XYZ_TO_LINEAR_SRGB: [[f32; 3]; 3] = [ + [3.240_97, -1.537_383_2, -0.498_610_76], + [-0.969_243_65, 1.875_967_5, 0.041_555_06], + [0.055_630_08, -0.203_976_96, 1.056_971_5], + ]; + matmul(&XYZ_TO_LINEAR_SRGB, src) + } + + fn from_linear_srgb(src: [f32; 3]) -> [f32; 3] { + const LINEAR_SRGB_TO_XYZ: [[f32; 3]; 3] = [ + [0.412_390_8, 0.357_584_33, 0.180_480_8], + [0.212_639, 0.715_168_65, 0.072_192_32], + [0.019_330_818, 0.119_194_78, 0.950_532_14], + ]; + matmul(&LINEAR_SRGB_TO_XYZ, src) + } +} + +#[derive(Clone, Copy, Debug)] +pub struct Oklab; + +// Matrices taken from Oklab blog post, precision reduced to f32 +const OKLAB_LAB_TO_LMS: [[f32; 3]; 3] = [ + [1.0, 0.396_337_78, 0.215_803_76], + [1.0, -0.105_561_346, -0.063_854_17], + [1.0, -0.089_484_18, -1.291_485_5], +]; + +const OKLAB_LMS_TO_SRGB: [[f32; 3]; 3] = [ + [4.076_741_7, -3.307_711_6, 0.230_969_94], + [-1.268_438, 2.609_757_4, -0.341_319_38], + [-0.004_196_086_3, -0.703_418_6, 1.707_614_7], +]; + +const OKLAB_SRGB_TO_LMS: [[f32; 3]; 3] = [ + [0.412_221_46, 0.536_332_55, 0.051_445_995], + [0.211_903_5, 0.680_699_5, 0.107_396_96], + [0.088_302_46, 0.281_718_85, 0.629_978_7], +]; + +const OKLAB_LMS_TO_LAB: [[f32; 3]; 3] = [ + [0.210_454_26, 0.793_617_8, -0.004_072_047], + [1.977_998_5, -2.428_592_2, 0.450_593_7], + [0.025_904_037, 0.782_771_77, -0.808_675_77], +]; + +impl Colorspace for Oklab { + const CS_TAG: Option = Some(ColorspaceTag::Oklab); + + fn to_linear_srgb(src: [f32; 3]) -> [f32; 3] { + let lms = matmul(&OKLAB_LAB_TO_LMS, src).map(|x| x * x * x); + matmul(&OKLAB_LMS_TO_SRGB, lms) + } + + fn from_linear_srgb(src: [f32; 3]) -> [f32; 3] { + let lms = matmul(&OKLAB_SRGB_TO_LMS, src).map(f32::cbrt); + matmul(&OKLAB_LMS_TO_LAB, lms) + } + + fn scale_chroma(src: [f32; 3], scale: f32) -> [f32; 3] { + [src[0], src[1] * scale, src[2] * scale] + } +} + +#[derive(Clone, Copy, Debug)] +pub struct Oklch; + +impl Colorspace for Oklch { + const CS_TAG: Option = Some(ColorspaceTag::Oklch); + + const LAYOUT: ColorspaceLayout = ColorspaceLayout::HueThird; + + fn from_linear_srgb(src: [f32; 3]) -> [f32; 3] { + let lab = Oklab::from_linear_srgb(src); + let l = lab[0]; + let mut h = lab[2].atan2(lab[1]) * (180. / f32::consts::PI); + if h < 0.0 { + h += 360.0; + } + let c = lab[2].hypot(lab[1]); + [l, c, h] + } + + fn to_linear_srgb(src: [f32; 3]) -> [f32; 3] { + let l = src[0]; + let (sin, cos) = (src[2] * (f32::consts::PI / 180.)).sin_cos(); + let a = src[1] * cos; + let b = src[1] * sin; + Oklab::to_linear_srgb([l, a, b]) + } + + fn scale_chroma(src: [f32; 3], scale: f32) -> [f32; 3] { + [src[0], src[1] * scale, src[2]] + } +} diff --git a/color/src/css.rs b/color/src/css.rs new file mode 100644 index 0000000..b080f8e --- /dev/null +++ b/color/src/css.rs @@ -0,0 +1,222 @@ +// Copyright 2024 the Color Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! CSS colors and syntax. + +use crate::{ + color::{add_alpha, fixup_hues_for_interpolate, split_alpha}, + AlphaColor, Bitset, Colorspace, ColorspaceLayout, ColorspaceTag, HueDirection, TaggedColor, +}; + +#[derive(Clone, Copy, Debug)] +pub struct CssColor { + pub cs: ColorspaceTag, + /// A bitmask of missing components. + pub missing: Bitset, + pub components: [f32; 4], +} + +#[derive(Clone, Copy)] +#[expect( + missing_debug_implementations, + reason = "it's an intermediate struct, only used for eval" +)] +pub struct Interpolator { + premul1: [f32; 3], + alpha1: f32, + premul2: [f32; 3], + alpha2: f32, + cs: ColorspaceTag, + missing: Bitset, +} + +impl From for CssColor { + fn from(value: TaggedColor) -> Self { + Self { + cs: value.cs, + missing: Bitset::default(), + components: value.components, + } + } +} + +impl CssColor { + pub fn to_tagged_color(self) -> TaggedColor { + TaggedColor { + cs: self.cs, + components: self.components, + } + } + + pub fn to_alpha_color(self) -> AlphaColor { + self.to_tagged_color().to_alpha_color() + } + + #[must_use] + pub fn from_alpha_color(color: AlphaColor) -> Self { + TaggedColor::from_alpha_color(color).into() + } + + #[must_use] + pub fn convert(self, cs: ColorspaceTag) -> Self { + if self.cs == cs { + // Note: §12 suggests that changing powerless to missing happens + // even when the color is already in the interpolation color space, + // but Chrome and color.js don't seem do to that. + self + } else { + let tagged = self.to_tagged_color(); + let converted = tagged.convert(cs); + let mut components = converted.components; + // Reference: §12.2 of Color 4 spec + let missing = if self.missing.any() { + if self.cs.same_analogous(cs) { + for (i, component) in components.iter_mut().enumerate() { + if self.missing.contains(i) { + *component = 0.0; + } + } + self.missing + } else { + let mut missing = self.missing & Bitset::single(3); + if self.cs.h_missing(self.missing) { + cs.set_h_missing(&mut missing, &mut components); + } + if self.cs.c_missing(self.missing) { + cs.set_c_missing(&mut missing, &mut components); + } + if self.cs.l_missing(self.missing) { + cs.set_l_missing(&mut missing, &mut components); + } + missing + } + } else { + Bitset::default() + }; + let mut result = Self { + cs, + missing, + components, + }; + result.powerless_to_missing(); + result + } + } + + /// Scale the chroma by the given amount. + /// + /// See [`Colorspace::scale_chroma`] for more details. + #[must_use] + pub fn scale_chroma(self, scale: f32) -> Self { + let (opaque, alpha) = split_alpha(self.components); + let mut components = self.cs.scale_chroma(opaque, scale); + if self.missing.any() { + for (i, component) in components.iter_mut().enumerate() { + if self.missing.contains(i) { + *component = 0.0; + } + } + } + Self { + cs: self.cs, + missing: self.missing, + components: add_alpha(components, alpha), + } + } + + fn premultiply_split(self) -> ([f32; 3], f32) { + // Reference: §12.3 of Color 4 spec + let (opaque, alpha) = split_alpha(self.components); + let premul = if alpha == 1.0 || self.missing.contains(3) { + opaque + } else { + self.cs.layout().scale(opaque, alpha) + }; + (premul, alpha) + } + + fn powerless_to_missing(&mut self) { + // Note: the spec seems vague on the details of what this should do, + // and there is some controversy in discussion threads. For example, + // in Lab-like spaces, if L is 0 do the other components become powerless? + const POWERLESS_EPSILON: f32 = 1e-6; + if self.cs.layout() != ColorspaceLayout::Rectangular + && self.components[1] < POWERLESS_EPSILON + { + self.cs + .set_h_missing(&mut self.missing, &mut self.components); + } + } + + /// Interpolate two colors, according to CSS Color 4 spec. + /// + /// This method does a bunch of precomputation, resulting in an [`Interpolator`] + /// object that can be evaluated at various `t` values. + /// + /// Reference: §12 of Color 4 spec + pub fn interpolate( + self, + other: Self, + cs: ColorspaceTag, + direction: HueDirection, + ) -> Interpolator { + let mut a = self.convert(cs); + let mut b = other.convert(cs); + let missing = a.missing & b.missing; + if self.missing != other.missing { + for i in 0..4 { + if (a.missing & !b.missing).contains(i) { + a.components[i] = b.components[i]; + } else if (!a.missing & b.missing).contains(i) { + b.components[i] = a.components[i]; + } + } + } + let (premul1, alpha1) = a.premultiply_split(); + let (mut premul2, alpha2) = b.premultiply_split(); + fixup_hues_for_interpolate(premul1, &mut premul2, cs.layout(), direction); + Interpolator { + premul1, + alpha1, + premul2, + alpha2, + cs, + missing, + } + } + + /// Compute the relative luminance of the color. + /// + /// This can be useful for choosing contrasting colors, and follows the + /// WCAG 2.1 spec. + /// + /// Note that this method only considers the opaque color, not the alpha. + /// Blending semi-transparent colors will reduce contrast, and that + /// should also be taken into account. + pub fn relative_luminance(self) -> f32 { + let rgb = self.convert(ColorspaceTag::LinearSrgb).components; + 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2] + } +} + +impl Interpolator { + pub fn eval(&self, t: f32) -> CssColor { + let premul = [ + self.premul1[0] + t * (self.premul2[0] - self.premul1[0]), + self.premul1[1] + t * (self.premul2[1] - self.premul1[1]), + self.premul1[2] + t * (self.premul2[2] - self.premul1[2]), + ]; + let alpha = self.alpha1 + t * (self.alpha2 - self.alpha1); + let opaque = if alpha == 0.0 || alpha == 1.0 { + premul + } else { + self.cs.layout().scale(premul, 1.0 / alpha) + }; + let components = add_alpha(opaque, alpha); + CssColor { + cs: self.cs, + missing: self.missing, + components, + } + } +} diff --git a/color/src/floatfuncs.rs b/color/src/floatfuncs.rs new file mode 100644 index 0000000..fbfb76d --- /dev/null +++ b/color/src/floatfuncs.rs @@ -0,0 +1,51 @@ +// Copyright 2024 the Color Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Shims for math functions that ordinarily come from std. + +/// Defines a trait that chooses between libstd or libm implementations of float methods. +macro_rules! define_float_funcs { + ($( + fn $name:ident(self $(,$arg:ident: $arg_ty:ty)*) -> $ret:ty + => $lname:ident/$lfname:ident; + )+) => { + + /// Since core doesn't depend upon libm, this provides libm implementations + /// of float functions which are typically provided by the std library, when + /// the `std` feature is not enabled. + /// + /// For documentation see the respective functions in the std library. + #[cfg(not(feature = "std"))] + pub(crate) trait FloatFuncs : Sized { + $(fn $name(self $(,$arg: $arg_ty)*) -> $ret;)+ + } + + #[cfg(not(feature = "std"))] + impl FloatFuncs for f32 { + $(fn $name(self $(,$arg: $arg_ty)*) -> $ret { + #[cfg(feature = "libm")] + return libm::$lfname(self $(,$arg)*); + + #[cfg(not(feature = "libm"))] + compile_error!("color requires either the `std` or `libm` feature") + })+ + } + + } +} + +define_float_funcs! { + fn abs(self) -> Self => fabs/fabsf; + fn atan2(self, other: Self) -> Self => atan2/atan2f; + fn cbrt(self) -> Self => cbrt/cbrtf; + fn ceil(self) -> Self => ceil/ceilf; + fn copysign(self, sign: Self) -> Self => copysign/copysignf; + fn floor(self) -> Self => floor/floorf; + fn hypot(self, other: Self) -> Self => hypot/hypotf; + // Note: powi is missing because its libm implementation is not efficient + fn powf(self, n: Self) -> Self => pow/powf; + fn round(self) -> Self => round/roundf; + fn sin_cos(self) -> (Self, Self) => sincos/sincosf; + fn sqrt(self) -> Self => sqrt/sqrtf; + fn trunc(self) -> Self => trunc/truncf; +} diff --git a/color/src/gradient.rs b/color/src/gradient.rs new file mode 100644 index 0000000..2911ed3 --- /dev/null +++ b/color/src/gradient.rs @@ -0,0 +1,89 @@ +// Copyright 2024 the Color Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +use crate::{Colorspace, ColorspaceTag, CssColor, HueDirection, Interpolator, Oklab, PremulColor}; + +#[expect(missing_debug_implementations, reason = "it's an iterator")] +pub struct GradientIter { + interpolator: Interpolator, + // This is in deltaEOK units + tolerance: f32, + // The adaptive subdivision logic is lifted from the stroke expansion paper. + t0: u32, + dt: f32, + target0: PremulColor, + target1: PremulColor, + end_color: PremulColor, +} + +pub fn gradient( + mut color0: CssColor, + mut color1: CssColor, + interp_cs: ColorspaceTag, + direction: HueDirection, + tolerance: f32, +) -> GradientIter { + let interpolator = color0.interpolate(color1, interp_cs, direction); + if color0.missing.any() { + color0 = interpolator.eval(0.0); + } + let target0 = color0.to_alpha_color().premultiply(); + if color1.missing.any() { + color1 = interpolator.eval(1.0); + } + let target1 = color1.to_alpha_color().premultiply(); + let end_color = target1; + GradientIter { + interpolator, + tolerance, + t0: 0, + dt: 0.0, + target0, + target1, + end_color, + } +} + +impl Iterator for GradientIter { + type Item = (f32, PremulColor); + + fn next(&mut self) -> Option { + if self.dt == 0.0 { + self.dt = 1.0; + return Some((0.0, self.target0)); + } + let t0 = self.t0 as f32 * self.dt; + if t0 == 1.0 { + return None; + } + loop { + // compute midpoint color + let midpoint = self.interpolator.eval(t0 + 0.5 * self.dt); + let midpoint_oklab: PremulColor = midpoint.to_alpha_color().premultiply(); + let approx = self.target0.lerp_rect(self.target1, 0.5); + let error = midpoint_oklab.difference(approx.convert()); + if error <= self.tolerance { + let t1 = t0 + self.dt; + self.t0 += 1; + let shift = self.t0.trailing_zeros(); + self.t0 >>= shift; + self.dt *= (1 << shift) as f32; + self.target0 = self.target1; + let new_t1 = t1 + self.dt; + if new_t1 < 1.0 { + self.target1 = self + .interpolator + .eval(new_t1) + .to_alpha_color() + .premultiply(); + } else { + self.target1 = self.end_color; + } + return Some((t1, self.target0)); + } + self.t0 *= 2; + self.dt *= 0.5; + self.target1 = midpoint.to_alpha_color().premultiply(); + } + } +} diff --git a/color/src/lib.rs b/color/src/lib.rs index f33c3bf..4fcd679 100644 --- a/color/src/lib.rs +++ b/color/src/lib.rs @@ -8,5 +8,67 @@ // shouldn't apply to examples and tests #![warn(unused_crate_dependencies)] #![warn(clippy::print_stdout, clippy::print_stderr)] +// TODO: parts of the crate are not done, with some missing docstring, +// and some enum variants not yet implemented. Finish those and remove +// these allow attributes. +#![allow(missing_docs, reason = "need to write more docs")] +#![allow(clippy::todo, reason = "need to fix todos")] //! # Color +//! +//! TODO: need to write a treatise on the nature of color and how to model +//! a reasonable fragment of it in the Rust type system. + +mod bitset; +mod color; +mod colorspace; +mod css; +mod gradient; +// Note: this will be feature-gated, but not bothering for now +mod parse; +mod serialize; +mod tagged; + +#[cfg(all(not(feature = "std"), not(test)))] +mod floatfuncs; + +pub use bitset::Bitset; +pub use color::{AlphaColor, HueDirection, OpaqueColor, PremulColor}; +pub use colorspace::{ + Colorspace, ColorspaceLayout, DisplayP3, LinearSrgb, Oklab, Oklch, Srgb, XyzD65, +}; +pub use css::{CssColor, Interpolator}; +pub use gradient::{gradient, GradientIter}; +pub use parse::{parse_color, Error}; +pub use tagged::{ColorspaceTag, TaggedColor}; + +const fn u8_to_f32(x: u32) -> f32 { + x as f32 * (1.0 / 255.0) +} + +fn matmul(m: &[[f32; 3]; 3], x: [f32; 3]) -> [f32; 3] { + [ + m[0][0] * x[0] + m[0][1] * x[1] + m[0][2] * x[2], + m[1][0] * x[0] + m[1][1] * x[1] + m[1][2] * x[2], + m[2][0] * x[0] + m[2][1] * x[1] + m[2][2] * x[2], + ] +} + +impl AlphaColor { + pub const fn from_rgba8(r: u8, g: u8, b: u8, a: u8) -> Self { + let components = [ + u8_to_f32(r as u32), + u8_to_f32(g as u32), + u8_to_f32(b as u32), + u8_to_f32(a as u32), + ]; + Self::new(components) + } +} + +// Keep clippy from complaining about unused libm in nostd test case. +#[cfg(feature = "libm")] +#[expect(unused, reason = "keep clippy happy")] +fn ensure_libm_dependency_used() -> f32 { + libm::sqrtf(4_f32) +} diff --git a/color/src/parse.rs b/color/src/parse.rs new file mode 100644 index 0000000..d11e613 --- /dev/null +++ b/color/src/parse.rs @@ -0,0 +1,444 @@ +// Copyright 2024 the Color Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Parse CSS4 color + +use core::f64; +use core::str::FromStr; + +use crate::{AlphaColor, Bitset, ColorspaceTag, CssColor, Srgb, TaggedColor}; + +// TODO: proper error type, maybe include string offset +pub type Error = &'static str; + +#[derive(Default)] +struct Parser<'a> { + s: &'a str, + ix: usize, +} + +/// A parsed value. +#[derive(Debug, Clone)] +enum Value<'a> { + Symbol(&'a str), + Number(f64), + Percent(f64), + Dimension(f64, &'a str), +} + +#[expect( + clippy::cast_possible_truncation, + reason = "deliberate choice of f32 for colors" +)] +fn color_from_components(components: [Option; 4], cs: ColorspaceTag) -> CssColor { + let mut missing = Bitset::default(); + for (i, component) in components.iter().enumerate() { + if component.is_none() { + missing.set(i); + } + } + CssColor { + cs, + missing, + components: components.map(|x| x.unwrap_or(0.0) as f32), + } +} + +impl<'a> Parser<'a> { + fn new(s: &'a str) -> Self { + let ix = 0; + Parser { s, ix } + } + + // This will be called at the start of most tokens. + fn consume_comments(&mut self) -> Result<(), Error> { + while self.s[self.ix..].starts_with("/*") { + if let Some(i) = self.s[self.ix + 2..].find("*/") { + self.ix += i + 4; + } else { + return Err("unclosed comment"); + } + } + Ok(()) + } + + fn number(&mut self) -> Option { + self.consume_comments().ok()?; + let tail = &self.s[self.ix..]; + let mut i = 0; + let mut valid = false; + if matches!(tail.as_bytes().first(), Some(b'+' | b'-')) { + i += 1; + } + while let Some(c) = tail.as_bytes().get(i) { + if c.is_ascii_digit() { + valid = true; + i += 1; + } else { + break; + } + } + if let Some(b'.') = tail.as_bytes().get(i) { + if let Some(c) = tail.as_bytes().get(i + 1) { + if c.is_ascii_digit() { + valid = true; + i += 2; + while let Some(c2) = tail.as_bytes().get(i) { + if c2.is_ascii_digit() { + i += 1; + } else { + break; + } + } + } + } + } + if matches!(tail.as_bytes().get(i), Some(b'e' | b'E')) { + let mut j = i + 1; + if matches!(tail.as_bytes().get(j), Some(b'+' | b'-')) { + j += 1; + } + if let Some(c) = tail.as_bytes().get(j) { + if c.is_ascii_digit() { + i = j + 1; + while let Some(c2) = tail.as_bytes().get(i) { + if c2.is_ascii_digit() { + i += 1; + } else { + break; + } + } + } + } + } + if valid { + // For this parse to fail would be strange, but we'll be careful. + if let Ok(value) = tail[..i].parse() { + self.ix += i; + return Some(value); + } + } + None + } + + // Complies with ident-token production with three exceptions: + // Escapes are not supported. + // Non-ASCII characters are not supported. + // Result is case sensitive. + fn ident(&mut self) -> Option<&'a str> { + // This does *not* strip initial whitespace. + let tail = &self.s[self.ix..]; + let i_init = 0; // This exists as a vestige for syntax like :ident + let mut i = i_init; + while i < tail.len() { + let b = tail.as_bytes()[i]; + if b.is_ascii_alphabetic() + || b == b'_' + || b == b'-' + || ((i >= 2 || i == 1 && tail.as_bytes()[i_init] != b'-') && b.is_ascii_digit()) + { + i += 1; + } else { + break; + } + } + // Reject '', '-', and anything starting with '--' + let mut j = i_init; + while j < i.min(i_init + 2) { + if tail.as_bytes()[j] == b'-' { + j += 1; + } else { + self.ix += i; + return Some(&tail[..i]); + } + } + None + } + + fn ch(&mut self, ch: u8) -> bool { + if self.consume_comments().is_err() { + return false; + } + self.raw_ch(ch) + } + + fn raw_ch(&mut self, ch: u8) -> bool { + if self.s[self.ix..].as_bytes().first() == Some(&ch) { + self.ix += 1; + true + } else { + false + } + } + + fn ws_one(&mut self) -> bool { + if self.consume_comments().is_err() { + return false; + } + let tail = &self.s[self.ix..]; + let mut i = 0; + while let Some(&b) = tail.as_bytes().get(i) { + if !(b == b' ' || b == b'\t' || b == b'\r' || b == b'\n') { + break; + } + i += 1; + } + self.ix += i; + i > 0 + } + + fn ws(&mut self) -> bool { + if !self.ws_one() { + return false; + } + while self.consume_comments().is_ok() { + if !self.ws_one() { + break; + } + } + true + } + + fn value(&mut self) -> Option> { + if let Some(number) = self.number() { + if self.raw_ch(b'%') { + Some(Value::Percent(number)) + } else if let Some(unit) = self.ident() { + Some(Value::Dimension(number, unit)) + } else { + Some(Value::Number(number)) + } + } else { + self.ident().map(Value::Symbol) + } + } + + /// Parse a color component. + fn scaled_component(&mut self, scale: f64, pct_scale: f64) -> Result, Error> { + self.ws(); + let value = self.value(); + match value { + Some(Value::Number(n)) => Ok(Some(n * scale)), + Some(Value::Percent(n)) => Ok(Some(n * pct_scale)), + Some(Value::Symbol("none")) => Ok(None), + _ => Err("unknown color component"), + } + } + + fn angle(&mut self) -> Result, Error> { + self.ws(); + let value = self.value(); + match value { + Some(Value::Number(n)) => Ok(Some(n)), + Some(Value::Symbol("none")) => Ok(None), + Some(Value::Dimension(n, dim)) => { + let scale = match dim { + "deg" => 1.0, + "rad" => 180.0 / f64::consts::PI, + "grad" => 0.9, + "turn" => 360.0, + _ => return Err("unknown angle dimension"), + }; + Ok(Some(n * scale)) + } + _ => Err("unknown angle"), + } + } + + fn optional_comma(&mut self, comma: bool) -> Result<(), Error> { + self.ws(); + if comma && !self.ch(b',') { + Err("expected comma to separate components") + } else { + Ok(()) + } + } + + fn opacity_separator(&mut self, comma: bool) -> bool { + self.ws(); + self.ch(if comma { b',' } else { b'/' }) + } + + fn rgb(&mut self) -> Result { + if !self.raw_ch(b'(') { + return Err("expected arguments"); + } + // TODO: in legacy mode, be stricter about not mixing numbers + // and percentages, and disallowing "none" + let r = self + .scaled_component(1. / 255., 0.01)? + .map(|x| x.clamp(0., 1.)); + self.ws(); + let comma = self.ch(b','); + let g = self + .scaled_component(1. / 255., 0.01)? + .map(|x| x.clamp(0., 1.)); + self.optional_comma(comma)?; + let b = self + .scaled_component(1. / 255., 0.01)? + .map(|x| x.clamp(0., 1.)); + 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([r, g, b, alpha], ColorspaceTag::Srgb)) + } + + fn optional_alpha(&mut self) -> Result, Error> { + let mut alpha = Some(1.0); + self.ws(); + if self.ch(b'/') { + alpha = self.scaled_component(1., 0.01)?; + } + self.ws(); + Ok(alpha) + } + + fn oklab(&mut self) -> 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 alpha = self.optional_alpha()?; + if !self.ch(b')') { + return Err("expected closing parenthesis"); + } + Ok(color_from_components( + [l, a, b, alpha], + ColorspaceTag::Oklab, + )) + } + + fn oklch(&mut self) -> 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 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, + )) + } + + fn color(&mut self) -> Result { + if !self.raw_ch(b'(') { + return Err("expected arguments"); + } + self.ws(); + let Some(id) = self.ident() else { + return Err("expected identifier for colorspace"); + }; + let cs = match id { + "srgb" => ColorspaceTag::Srgb, + "srgb-linear" => ColorspaceTag::LinearSrgb, + "display-p3" => ColorspaceTag::DisplayP3, + "xyz" | "xyz-d65" => ColorspaceTag::XyzD65, + _ => return Err("unknown colorspace"), + }; + let r = self.scaled_component(1., 0.01)?; + let g = self.scaled_component(1., 0.01)?; + let b = self.scaled_component(1., 0.01)?; + let alpha = self.optional_alpha()?; + if !self.ch(b')') { + return Err("expected closing parenthesis"); + } + Ok(color_from_components([r, g, b, alpha], cs)) + } +} + +// Arguably this should be an implementation of FromStr. +/// Parse a color string in CSS syntax into a color. +/// +/// # Errors +/// +/// Tries to return a suitable error for any invalid string, but may be +/// a little lax on some details. +pub fn parse_color(s: &str) -> Result { + if let Some(stripped) = s.strip_prefix('#') { + let color = color_from_4bit_hex(get_4bit_hex_channels(stripped)?); + return Ok(TaggedColor::from_alpha_color(color).into()); + } + // TODO: the named x11 colors (steal from peniko) + let mut parser = Parser::new(s); + if let Some(id) = parser.ident() { + match id { + "rgb" | "rgba" => parser.rgb(), + "oklab" => parser.oklab(), + "oklch" => parser.oklch(), + "transparent" => Ok(color_from_components([Some(0.); 4], ColorspaceTag::Srgb)), + "color" => parser.color(), + _ => Err("unknown identifier"), + } + // TODO: should we validate that the parser is at eof? + } else { + Err("unknown color syntax") + } +} + +const fn get_4bit_hex_channels(hex_str: &str) -> Result<[u8; 8], Error> { + let mut four_bit_channels = match *hex_str.as_bytes() { + [r, g, b] => [r, r, g, g, b, b, b'f', b'f'], + [r, g, b, a] => [r, r, g, g, b, b, a, a], + [r0, r1, g0, g1, b0, b1] => [r0, r1, g0, g1, b0, b1, b'f', b'f'], + [r0, r1, g0, g1, b0, b1, a0, a1] => [r0, r1, g0, g1, b0, b1, a0, a1], + _ => return Err("wrong number of hex digits"), + }; + + // convert to hex in-place + // this is written without a for loop to satisfy `const` + let mut i = 0; + while i < four_bit_channels.len() { + let ascii = four_bit_channels[i]; + let as_hex = match hex_from_ascii_byte(ascii) { + Ok(hex) => hex, + Err(e) => return Err(e), + }; + four_bit_channels[i] = as_hex; + i += 1; + } + Ok(four_bit_channels) +} + +const fn hex_from_ascii_byte(b: u8) -> Result { + match b { + b'0'..=b'9' => Ok(b - b'0'), + b'A'..=b'F' => Ok(b - b'A' + 10), + b'a'..=b'f' => Ok(b - b'a' + 10), + _ => Err("invalid hex digit"), + } +} + +const fn color_from_4bit_hex(components: [u8; 8]) -> AlphaColor { + let [r0, r1, g0, g1, b0, b1, a0, a1] = components; + AlphaColor::from_rgba8(r0 << 4 | r1, g0 << 4 | g1, b0 << 4 | b1, a0 << 4 | a1) +} + +impl FromStr for ColorspaceTag { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + match s { + "srgb" => Ok(Self::Srgb), + "srgb-linear" => Ok(Self::LinearSrgb), + "lab" => Ok(Self::Lab), + "lch" => Ok(Self::Lch), + "oklab" => Ok(Self::Oklab), + "oklch" => Ok(Self::Oklch), + "display-p3" => Ok(Self::DisplayP3), + "xyz" | "xyz-d65" => Ok(Self::XyzD65), + _ => Err("unknown colorspace name"), + } + } +} diff --git a/color/src/serialize.rs b/color/src/serialize.rs new file mode 100644 index 0000000..f1fd98a --- /dev/null +++ b/color/src/serialize.rs @@ -0,0 +1,87 @@ +// Copyright 2024 the Color Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! CSS-compatible string serializations of colors. + +use core::fmt::{Formatter, Result}; + +use crate::{ColorspaceTag, CssColor}; + +fn write_scaled_component( + color: &CssColor, + ix: usize, + f: &mut Formatter<'_>, + scale: f32, +) -> Result { + if color.missing.contains(ix) { + // According to the serialization rules (§15.2), missing should be converted to 0. + // However, it seems useful to preserve these. Perhaps we want to talk about whether + // we want string formatting to strictly follow the serialization spec. + + write!(f, "none") + } else { + write!(f, "{}", color.components[ix] * scale) + } +} + +fn write_modern_function(color: &CssColor, name: &str, f: &mut Formatter<'_>) -> Result { + write!(f, "{name}(")?; + write_scaled_component(color, 0, f, 1.0)?; + write!(f, " ")?; + write_scaled_component(color, 1, f, 1.0)?; + write!(f, " ")?; + write_scaled_component(color, 2, f, 1.0)?; + if color.components[3] < 1.0 { + write!(f, " / ")?; + // TODO: clamp negative values + write_scaled_component(color, 3, f, 1.0)?; + } + write!(f, ")") +} + +fn write_color_function(color: &CssColor, name: &str, f: &mut Formatter<'_>) -> Result { + write!(f, "color({name} ")?; + write_scaled_component(color, 0, f, 1.0)?; + write!(f, " ")?; + write_scaled_component(color, 1, f, 1.0)?; + write!(f, " ")?; + write_scaled_component(color, 2, f, 1.0)?; + 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 CssColor { + 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, ")") + } + ColorspaceTag::LinearSrgb => write_color_function(self, "srgb-linear", f), + ColorspaceTag::DisplayP3 => write_color_function(self, "display-p3", 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), + ColorspaceTag::Oklab => write_modern_function(self, "oklab", f), + ColorspaceTag::Oklch => write_modern_function(self, "oklch", f), + _ => todo!(), + } + } +} diff --git a/color/src/tagged.rs b/color/src/tagged.rs new file mode 100644 index 0000000..f35e216 --- /dev/null +++ b/color/src/tagged.rs @@ -0,0 +1,208 @@ +// Copyright 2024 the Color Authors +// SPDX-License-Identifier: Apache-2.0 OR MIT + +//! Colors with runtime choice of colorspace. + +use crate::{ + color::{add_alpha, split_alpha}, + AlphaColor, Bitset, Colorspace, ColorspaceLayout, DisplayP3, LinearSrgb, Oklab, Oklch, Srgb, + XyzD65, +}; + +/// The colorspace tag for tagged colors. +/// +/// This represents a fixed set of known colorspaces. The set is +/// based on the CSS Color 4 spec, but might also extend to a small +/// set of colorspaces used in 3D graphics. +/// +/// Note: this has some tags not yet implemented. +/// +/// Note: when adding an RGB-like colorspace, add to `same_analogous`. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +#[non_exhaustive] +pub enum ColorspaceTag { + Srgb, + LinearSrgb, + Lab, + Lch, + Hsl, + Hwb, + Oklab, + Oklch, + DisplayP3, + XyzD65, +} + +/// A color with a runtime colorspace tag. This type will likely get merged with +/// [`CssColor`]. +#[derive(Clone, Copy, Debug)] +pub struct TaggedColor { + pub cs: ColorspaceTag, + pub components: [f32; 4], +} + +impl ColorspaceTag { + pub(crate) fn layout(self) -> ColorspaceLayout { + match self { + Self::Lch | Self::Oklch => ColorspaceLayout::HueThird, + Self::Hsl | Self::Hwb => ColorspaceLayout::HueFirst, + _ => ColorspaceLayout::Rectangular, + } + } + + // Note: if colorspaces are the same, then they're also analogous, but + // in that case we wouldn't do the conversion, so this function is not + // guaranteed to return the correct answer in those cases. + pub(crate) fn same_analogous(self, other: Self) -> bool { + use ColorspaceTag::*; + matches!( + (self, other), + ( + Srgb | LinearSrgb | DisplayP3 | XyzD65, + Srgb | LinearSrgb | DisplayP3 | XyzD65 + ) | (Lab | Oklab, Lab | Oklab) + | (Lch | Oklch, Lch | Oklch) + ) + } + + pub(crate) fn l_missing(self, missing: Bitset) -> bool { + use ColorspaceTag::*; + match self { + Lab | Lch | Oklab | Oklch => missing.contains(0), + Hsl => missing.contains(2), + _ => false, + } + } + + pub(crate) fn set_l_missing(self, missing: &mut Bitset, components: &mut [f32; 4]) { + use ColorspaceTag::*; + match self { + Lab | Lch | Oklab | Oklch => { + missing.set(0); + components[0] = 0.0; + } + Hsl => { + missing.set(2); + components[2] = 0.0; + } + _ => (), + } + } + + pub(crate) fn c_missing(self, missing: Bitset) -> bool { + use ColorspaceTag::*; + match self { + Lab | Lch | Oklab | Oklch | Hsl => missing.contains(1), + _ => false, + } + } + + pub(crate) fn set_c_missing(self, missing: &mut Bitset, components: &mut [f32; 4]) { + use ColorspaceTag::*; + match self { + Lab | Lch | Oklab | Oklch | Hsl => { + missing.set(1); + components[1] = 0.0; + } + _ => (), + } + } + + pub(crate) fn h_missing(self, missing: Bitset) -> bool { + self.layout() + .hue_channel() + .is_some_and(|ix| missing.contains(ix)) + } + + pub(crate) fn set_h_missing(self, missing: &mut Bitset, components: &mut [f32; 4]) { + if let Some(ix) = self.layout().hue_channel() { + missing.set(ix); + components[ix] = 0.0; + } + } + + pub fn from_linear_srgb(self, rgb: [f32; 3]) -> [f32; 3] { + match self { + Self::Srgb => Srgb::from_linear_srgb(rgb), + Self::LinearSrgb => rgb, + Self::Oklab => Oklab::from_linear_srgb(rgb), + Self::Oklch => Oklch::from_linear_srgb(rgb), + Self::DisplayP3 => DisplayP3::from_linear_srgb(rgb), + Self::XyzD65 => XyzD65::from_linear_srgb(rgb), + _ => todo!(), + } + } + + pub fn to_linear_srgb(self, src: [f32; 3]) -> [f32; 3] { + match self { + Self::Srgb => Srgb::to_linear_srgb(src), + Self::LinearSrgb => src, + Self::Oklab => Oklab::to_linear_srgb(src), + Self::Oklch => Oklch::to_linear_srgb(src), + Self::DisplayP3 => DisplayP3::to_linear_srgb(src), + Self::XyzD65 => XyzD65::to_linear_srgb(src), + _ => todo!(), + } + } + + /// Scale the chroma by the given amount. + /// + /// See [`Colorspace::scale_chroma`] for more details. + pub fn scale_chroma(self, src: [f32; 3], scale: f32) -> [f32; 3] { + match self { + Self::LinearSrgb => LinearSrgb::scale_chroma(src, scale), + Self::Oklab | Self::Lab => Oklab::scale_chroma(src, scale), + Self::Oklch | Self::Lch | Self::Hsl => Oklch::scale_chroma(src, scale), + _ => { + let rgb = self.to_linear_srgb(src); + let scaled = LinearSrgb::scale_chroma(rgb, scale); + self.from_linear_srgb(scaled) + } + } + } +} + +impl TaggedColor { + pub fn from_linear_srgb(rgba: [f32; 4], cs: ColorspaceTag) -> Self { + let (rgb, alpha) = split_alpha(rgba); + let opaque = cs.from_linear_srgb(rgb); + let components = add_alpha(opaque, alpha); + Self { cs, components } + } + + pub fn from_alpha_color(color: AlphaColor) -> Self { + if let Some(cs) = T::CS_TAG { + Self { + cs, + components: color.components, + } + } else { + let components = color.convert::().components; + Self { + cs: ColorspaceTag::LinearSrgb, + components, + } + } + } + + pub fn to_alpha_color(&self) -> AlphaColor { + if T::CS_TAG == Some(self.cs) { + AlphaColor::new(self.components) + } else { + let (opaque, alpha) = split_alpha(self.components); + let rgb = self.cs.to_linear_srgb(opaque); + let components = add_alpha(T::from_linear_srgb(rgb), alpha); + AlphaColor::new(components) + } + } + + #[must_use] + pub fn convert(self, cs: ColorspaceTag) -> Self { + if self.cs == cs { + self + } else { + let linear = self.to_alpha_color::(); + Self::from_linear_srgb(linear.components, cs) + } + } +} diff --git a/color_operations/Cargo.toml b/color_operations/Cargo.toml index 4f3d1ae..a654368 100644 --- a/color_operations/Cargo.toml +++ b/color_operations/Cargo.toml @@ -14,8 +14,8 @@ publish = false [features] default = ["std"] -std = [] -libm = [] +std = ["color/std"] +libm = ["color/libm"] [package.metadata.docs.rs] all-features = true @@ -24,7 +24,7 @@ default-target = "x86_64-unknown-linux-gnu" targets = [] [dependencies] -color = { workspace = true } +color = { workspace = true, default-features = false } [lints] workspace = true