diff --git a/CHANGELOG.md b/CHANGELOG.md index 31d004f..c3b7ad3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,25 @@ and this project adheres to [Break Versioning](https://www.taoensso.com/break-ve ## [Unreleased] +### Added + +- Add alpha/opacity support to all color spaces (`Xyz`, `Lms`, `Rgb`, `LinearRgb`, `Hsl`, `Hsv`, `Hwb`, `Cmy`, `Cmyk`) + with `alpha()`, `set_alpha()`, `with_alpha()`, `opacity()`, `set_opacity()`, `with_opacity()`, and + increment/decrement/scale variants on the `ColorSpace` trait +- Add alpha compositing on `Rgb` via `flatten_alpha()`, `flatten_alpha_against()`, and their `with_*` builder variants +- Add `Rgb::BLACK` and `Rgb::WHITE` associated constants +- Preserve alpha through all color space conversions and chromatic adaptation transforms + +### Changed + +- **BREAKING:** `ColorSpace` trait now requires `alpha()`, `set_alpha()` implementations +- **BREAKING:** `PartialEq` on all color spaces now includes the alpha channel in comparisons +- `Display` formatting on all color spaces now includes opacity percentage when alpha is below 1.0 + +### Fixed + +- Chromatic adaptation transforms now preserve the alpha channel of the input color + ## [v0.2.0] - 2026-02-08 ### Added diff --git a/src/chromatic_adaptation_transform.rs b/src/chromatic_adaptation_transform.rs index adc61d1..f0e6ccb 100644 --- a/src/chromatic_adaptation_transform.rs +++ b/src/chromatic_adaptation_transform.rs @@ -22,7 +22,7 @@ use std::fmt::{Display, Formatter, Result as FmtResult}; use crate::{ matrix::Matrix3, - space::{Lms, Xyz}, + space::{ColorSpace, Lms, Xyz}, }; /// Shorthand alias for [`ChromaticAdaptationTransform`]. @@ -82,6 +82,7 @@ impl ChromaticAdaptationTransform { ]) .to_xyz() .with_context(target_white.context().with_cat(*self)) + .with_alpha(color.alpha()) } /// Returns the inverse of the transformation matrix. @@ -149,6 +150,17 @@ mod test { assert!((adapted.y() - color.y()).abs() < 1e-10); assert!((adapted.z() - color.z()).abs() < 1e-10); } + + #[test] + fn it_preserves_alpha() { + let cat = Cat::default(); + let d65 = Xyz::new(0.95047, 1.0, 1.08883); + let d50 = Xyz::new(0.96422, 1.0, 0.82521); + let color = Xyz::new(0.4, 0.2, 0.1).with_alpha(0.7); + let adapted = cat.adapt(color, d65, d50); + + assert!((adapted.alpha() - 0.7).abs() < 1e-10); + } } mod default { diff --git a/src/space.rs b/src/space.rs index 3fd4652..d7736d8 100644 --- a/src/space.rs +++ b/src/space.rs @@ -21,6 +21,9 @@ use crate::{chromaticity::Xy, component::Component}; /// Provides conversions between spaces, luminance operations, and component access. /// All color spaces can convert to [`Xyz`], which serves as the universal hub. pub trait ColorSpace: Copy + Clone + From { + /// Returns the alpha (transparency) of the color on a 0.0 to 1.0 scale. + fn alpha(&self) -> f64; + /// Returns a new color with all components scaled by the given factor. fn amplified_by(&self, factor: impl Into) -> Self { Self::from(self.to_xyz().amplified_by(factor)) @@ -60,21 +63,68 @@ pub trait ColorSpace: Copy + Clone + From { self.to_cmyk().cyan() } + /// Decreases alpha in place by the given amount on a 0.0 to 1.0 scale. + fn decrement_alpha(&mut self, amount: impl Into) { + self.set_alpha(self.with_alpha_decremented_by(amount).alpha()) + } + /// Decreases luminance in place by the given amount. fn decrement_luminance(&mut self, amount: impl Into) { self.set_components(self.with_luminance_decremented_by(amount).components()) } + /// Decreases opacity in place by the given percentage amount (0-100%). + fn decrement_opacity(&mut self, amount: impl Into) { + self.set_alpha(self.with_opacity_decremented_by(amount).alpha()) + } + + /// Flattens the alpha channel against black, compositing the color. + fn flatten_alpha(&mut self) { + let rgb = self.to_rgb::().with_alpha(self.alpha()).with_alpha_flattened(); + self.set_components(Self::from(rgb.to_xyz()).components()); + self.set_alpha(1.0); + } + + /// Flattens the alpha channel against the given background color. + fn flatten_alpha_against(&mut self, background: impl Into>) { + let rgb = self + .to_rgb::() + .with_alpha(self.alpha()) + .with_alpha_flattened_against(background); + self.set_components(Self::from(rgb.to_xyz()).components()); + self.set_alpha(1.0); + } + + /// Alias for [`Self::flatten_alpha`]. + fn flatten_opacity(&mut self) { + self.flatten_alpha() + } + + /// Alias for [`Self::flatten_alpha_against`]. + fn flatten_opacity_against(&mut self, background: impl Into>) { + self.flatten_alpha_against(background) + } + /// Returns the sRGB green channel as a u8 (0-255). fn green(&self) -> u8 { self.to_rgb::().green() } + /// Increases alpha in place by the given amount on a 0.0 to 1.0 scale. + fn increment_alpha(&mut self, amount: impl Into) { + self.set_alpha(self.with_alpha_incremented_by(amount).alpha()) + } + /// Increases luminance in place by the given amount. fn increment_luminance(&mut self, amount: impl Into) { self.set_components(self.with_luminance_incremented_by(amount).components()) } + /// Increases opacity in place by the given percentage amount (0-100%). + fn increment_opacity(&mut self, amount: impl Into) { + self.set_alpha(self.with_opacity_incremented_by(amount).alpha()) + } + /// Returns the relative luminance (CIE Y). fn luminance(&self) -> f64 { self.to_xyz().luminance() @@ -86,16 +136,34 @@ pub trait ColorSpace: Copy + Clone + From { self.to_cmyk().magenta() } + /// Returns the opacity as a percentage (0-100%). + fn opacity(&self) -> f64 { + self.alpha() * 100.0 + } + /// Returns the sRGB red channel as a u8 (0-255). fn red(&self) -> u8 { self.to_rgb::().red() } + /// Scales alpha in place by the given factor. + fn scale_alpha(&mut self, factor: impl Into) { + self.set_alpha(self.with_alpha_scaled_by(factor).alpha()) + } + /// Scales luminance in place by the given factor. fn scale_luminance(&mut self, factor: impl Into) { self.set_components(self.with_luminance_scaled_by(factor).components()) } + /// Scales opacity in place by the given percentage factor (0-100%). + fn scale_opacity(&mut self, factor: impl Into) { + self.set_alpha(self.with_opacity_scaled_by(factor).alpha()) + } + + /// Sets the alpha value in place on a 0.0 to 1.0 scale. + fn set_alpha(&mut self, alpha: impl Into); + /// Sets the color's components from an array. fn set_components(&mut self, components: [impl Into + Clone; N]); @@ -104,45 +172,50 @@ pub trait ColorSpace: Copy + Clone + From { self.set_components(self.with_luminance(luminance).components()) } + /// Sets the opacity to the given percentage value (0-100%) in place. + fn set_opacity(&mut self, opacity: impl Into) { + self.set_alpha(opacity.into() / 100.0) + } + #[cfg(feature = "space-cmy")] /// Converts to the CMY color space with sRGB encoding. fn to_cmy(&self) -> Cmy { - self.to_rgb::().to_cmy() + self.to_rgb::().to_cmy().with_alpha(self.alpha()) } #[cfg(feature = "space-cmyk")] /// Converts to the CMYK color space with sRGB encoding. fn to_cmyk(&self) -> Cmyk { - self.to_rgb::().to_cmyk() + self.to_rgb::().to_cmyk().with_alpha(self.alpha()) } #[cfg(feature = "space-hsv")] /// Converts to the HSB color space with sRGB encoding. fn to_hsb(&self) -> Hsb { - self.to_rgb::().to_hsb() + self.to_rgb::().to_hsb().with_alpha(self.alpha()) } #[cfg(feature = "space-hsl")] /// Converts to the HSL color space with sRGB encoding. fn to_hsl(&self) -> Hsl { - self.to_rgb::().to_hsl() + self.to_rgb::().to_hsl().with_alpha(self.alpha()) } #[cfg(feature = "space-hsv")] /// Converts to the HSV color space with sRGB encoding. fn to_hsv(&self) -> Hsv { - self.to_rgb::().to_hsv() + self.to_rgb::().to_hsv().with_alpha(self.alpha()) } #[cfg(feature = "space-hwb")] /// Converts to the HWB color space with sRGB encoding. fn to_hwb(&self) -> Hwb { - self.to_rgb::().to_hwb() + self.to_rgb::().to_hwb().with_alpha(self.alpha()) } /// Converts to the LMS cone response space. fn to_lms(&self) -> Lms { - self.to_xyz().to_lms() + self.to_xyz().to_lms().with_alpha(self.alpha()) } /// Converts to the specified RGB color space. @@ -150,12 +223,48 @@ pub trait ColorSpace: Copy + Clone + From { where S: RgbSpec, { - self.to_xyz().to_rgb::() + self.to_xyz().to_rgb::().with_alpha(self.alpha()) } /// Converts to CIE XYZ. fn to_xyz(&self) -> Xyz; + /// Returns a new color with the given alpha value on a 0.0 to 1.0 scale. + fn with_alpha(&self, alpha: impl Into) -> Self { + let mut color = *self; + color.set_alpha(alpha); + color + } + + /// Returns a new color with alpha decreased by the given amount. + fn with_alpha_decremented_by(&self, amount: impl Into) -> Self { + self.with_alpha(Component::new((self.alpha() - amount.into().0).clamp(0.0, 1.0))) + } + + /// Returns a new color with the alpha channel flattened against black. + fn with_alpha_flattened(&self) -> Self { + let mut color = *self; + color.flatten_alpha(); + color + } + + /// Returns a new color with the alpha channel flattened against the given background. + fn with_alpha_flattened_against(&self, background: impl Into>) -> Self { + let mut color = *self; + color.flatten_alpha_against(background); + color + } + + /// Returns a new color with alpha increased by the given amount. + fn with_alpha_incremented_by(&self, amount: impl Into) -> Self { + self.with_alpha(Component::new((self.alpha() + amount.into().0).clamp(0.0, 1.0))) + } + + /// Returns a new color with alpha scaled by the given factor. + fn with_alpha_scaled_by(&self, factor: impl Into) -> Self { + self.with_alpha(Component::new((self.alpha() * factor.into().0).clamp(0.0, 1.0))) + } + /// Returns a new color with the given luminance, preserving chromaticity. fn with_luminance(&self, luminance: impl Into) -> Self { Self::from(self.to_xyz().with_luminance(luminance)) @@ -176,6 +285,36 @@ pub trait ColorSpace: Copy + Clone + From { Self::from(self.to_xyz().with_luminance_scaled_by(factor)) } + /// Returns a new color with the given opacity percentage (0-100%). + fn with_opacity(&self, opacity: impl Into) -> Self { + self.with_alpha(opacity.into() / 100.0) + } + + /// Returns a new color with opacity decreased by the given percentage amount. + fn with_opacity_decremented_by(&self, amount: impl Into) -> Self { + self.with_alpha_decremented_by(amount.into() / 100.0) + } + + /// Alias for [`Self::with_alpha_flattened`]. + fn with_opacity_flattened(&self) -> Self { + self.with_alpha_flattened() + } + + /// Alias for [`Self::with_alpha_flattened_against`]. + fn with_opacity_flattened_against(&self, background: impl Into>) -> Self { + self.with_alpha_flattened_against(background) + } + + /// Returns a new color with opacity increased by the given percentage amount. + fn with_opacity_incremented_by(&self, amount: impl Into) -> Self { + self.with_alpha_incremented_by(amount.into() / 100.0) + } + + /// Returns a new color with opacity scaled by the given percentage factor. + fn with_opacity_scaled_by(&self, factor: impl Into) -> Self { + self.with_alpha_scaled_by(factor.into() / 100.0) + } + #[cfg(feature = "space-cmyk")] /// Returns the sRGB yellow component as a percentage (0-100%). fn yellow(&self) -> f64 { diff --git a/src/space/cie/xyz.rs b/src/space/cie/xyz.rs index 5e1611e..ebc42e9 100644 --- a/src/space/cie/xyz.rs +++ b/src/space/cie/xyz.rs @@ -26,6 +26,7 @@ use crate::{ /// Y represents relative luminance, while X and Z carry chromaticity information. #[derive(Clone, Copy, Debug)] pub struct Xyz { + alpha: Component, context: ColorimetricContext, x: Component, y: Component, @@ -36,6 +37,7 @@ impl Xyz { /// Creates a new XYZ color with the default viewing context. pub fn new(x: impl Into, y: impl Into, z: impl Into) -> Self { Self { + alpha: Component::new(1.0), context: ColorimetricContext::default(), x: x.into(), y: y.into(), @@ -46,6 +48,7 @@ impl Xyz { /// Creates a new XYZ color in a const context. pub const fn new_const(x: f64, y: f64, z: f64) -> Self { Self { + alpha: Component::new_const(1.0), context: ColorimetricContext::DEFAULT, x: Component::new_const(x), y: Component::new_const(y), @@ -225,7 +228,9 @@ impl Xyz { /// Converts to the LMS cone response space using the context's CAT matrix. pub fn to_lms(&self) -> Lms { - Lms::from(self.context.cat().matrix() * self.components()).with_context(self.context) + Lms::from(self.context.cat().matrix() * self.components()) + .with_context(self.context) + .with_alpha(self.alpha) } /// Converts to the specified RGB color space. @@ -235,7 +240,9 @@ impl Xyz { { let adapted = self.adapt_to(S::CONTEXT); let [r, g, b] = *S::inversed_xyz_matrix() * adapted.components(); - LinearRgb::::from_normalized(r, g, b).to_encoded() + LinearRgb::::from_normalized(r, g, b) + .to_encoded() + .with_alpha(self.alpha) } /// Returns this color with a different viewing context (without adaptation). @@ -403,10 +410,18 @@ where } impl ColorSpace<3> for Xyz { + fn alpha(&self) -> f64 { + self.alpha.0 + } + fn components(&self) -> [f64; 3] { self.components() } + fn set_alpha(&mut self, alpha: impl Into) { + self.alpha = alpha.into().clamp(0.0, 1.0) + } + fn set_components(&mut self, components: [impl Into + Clone; 3]) { self.set_components(components) } @@ -418,14 +433,23 @@ impl ColorSpace<3> for Xyz { impl Display for Xyz { fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - write!( - f, - "XYZ({:.precision$}, {:.precision$}, {:.precision$})", - self.x, - self.y, - self.z, - precision = f.precision().unwrap_or(4) - ) + let precision = f.precision().unwrap_or(4); + if self.alpha.0 < 1.0 { + write!( + f, + "XYZ({:.precision$}, {:.precision$}, {:.precision$}, {:.0}%)", + self.x, + self.y, + self.z, + self.opacity() + ) + } else { + write!( + f, + "XYZ({:.precision$}, {:.precision$}, {:.precision$})", + self.x, self.y, self.z + ) + } } } @@ -531,7 +555,7 @@ where { fn eq(&self, other: &T) -> bool { let other = (*other).into(); - self.x == other.x && self.y == other.y && self.z == other.z + self.alpha == other.alpha && self.x == other.x && self.y == other.y && self.z == other.z } } @@ -858,6 +882,20 @@ mod test { assert_eq!(format!("{:.2}", xyz), "XYZ(0.12, 0.23, 0.35)"); assert_eq!(format!("{:.6}", xyz), "XYZ(0.123457, 0.234568, 0.345679)"); } + + #[test] + fn it_includes_opacity_when_alpha_below_one() { + let xyz = Xyz::new(0.5, 0.5, 0.5).with_alpha(0.5); + + assert_eq!(format!("{}", xyz), "XYZ(0.5000, 0.5000, 0.5000, 50%)"); + } + + #[test] + fn it_omits_opacity_when_fully_opaque() { + let xyz = Xyz::new(0.5, 0.5, 0.5); + + assert_eq!(format!("{}", xyz), "XYZ(0.5000, 0.5000, 0.5000)"); + } } mod increment_luminance { @@ -955,6 +993,14 @@ mod test { assert_eq!(xyz, [0.1, 0.2, 0.3]); } + + #[test] + fn it_compares_unequal_when_alpha_differs() { + let a = Xyz::new(0.1, 0.2, 0.3).with_alpha(0.5); + let b = Xyz::new(0.1, 0.2, 0.3); + + assert_ne!(a, b); + } } mod scale_x { @@ -1285,6 +1331,14 @@ mod test { assert_eq!(lms.context().cat().name(), "XYZ Scaling"); } + + #[test] + fn it_preserves_alpha() { + let xyz = Xyz::new(0.5, 0.5, 0.5).with_alpha(0.3); + let lms = xyz.to_lms(); + + assert!((lms.alpha() - 0.3).abs() < 1e-10); + } } mod to_rgb { @@ -1322,6 +1376,14 @@ mod test { assert_eq!(back.green(), original.green()); assert_eq!(back.blue(), original.blue()); } + + #[test] + fn it_preserves_alpha() { + let xyz = Xyz::new(0.5, 0.5, 0.5).with_alpha(0.7); + let rgb: Rgb = xyz.to_rgb(); + + assert!((rgb.alpha() - 0.7).abs() < 1e-10); + } } mod with_context { diff --git a/src/space/cylindrical/hsl.rs b/src/space/cylindrical/hsl.rs index f8534d3..efe032e 100644 --- a/src/space/cylindrical/hsl.rs +++ b/src/space/cylindrical/hsl.rs @@ -29,6 +29,7 @@ pub struct Hsl where S: RgbSpec, { + alpha: Component, context: ColorimetricContext, h: Component, l: Component, @@ -43,6 +44,7 @@ where /// Creates a new HSL color from hue (0-360°), saturation (0-100%), and lightness (0-100%). pub fn new(h: impl Into, s: impl Into, l: impl Into) -> Self { Self { + alpha: Component::new(1.0), context: S::CONTEXT, h: Component::new((h.into().0 / 360.0).rem_euclid(1.0)), l: l.into() / 100.0, @@ -56,6 +58,7 @@ where let r = (h / 360.0) % 1.0; Self { + alpha: Component::new_const(1.0), context: S::CONTEXT, h: Component::new_const(if r < 0.0 { r + 1.0 } else { r }), l: Component::new_const(l / 100.0), @@ -245,7 +248,7 @@ where let v = l + (s * l.min(1.0 - l)); let ns = if v == 0.0 { 0.0 } else { 2.0 * (1.0 - (l / v)) }; - Hsv::::new(h, ns, v) + Hsv::::new(h, ns, v).with_alpha(self.alpha) } #[cfg(feature = "space-hwb")] @@ -256,14 +259,14 @@ where let v = if l <= 0.5 { l * (1.0 + s) } else { (l + s) - (l * s) }; if v == 0.0 { - return Hwb::::new(h * 360.0, 0.0, 100.0); + return Hwb::::new(h * 360.0, 0.0, 100.0).with_alpha(self.alpha); } let sv = 2.0 * (v - l) / v; let w = v * (1.0 - sv); let b = 1.0 - v; - Hwb::::new(h * 360.0, w * 100.0, b * 100.0) + Hwb::::new(h * 360.0, w * 100.0, b * 100.0).with_alpha(self.alpha) } /// Converts this HSL color to an [`Rgb`] color in the specified output space. @@ -276,13 +279,17 @@ where let l = self.l.0; if s <= 0.0 { - return Rgb::::from_normalized(l, l, l).to_rgb::(); + return Rgb::::from_normalized(l, l, l).to_rgb::().with_alpha(self.alpha); } if l <= 0.0 { - return Rgb::::from_normalized(0.0, 0.0, 0.0).to_rgb::(); + return Rgb::::from_normalized(0.0, 0.0, 0.0) + .to_rgb::() + .with_alpha(self.alpha); } if l >= 1.0 { - return Rgb::::from_normalized(1.0, 1.0, 1.0).to_rgb::(); + return Rgb::::from_normalized(1.0, 1.0, 1.0) + .to_rgb::() + .with_alpha(self.alpha); } let c = (1.0 - ((2.0 * l) - 1.0).abs()) * s; @@ -300,7 +307,9 @@ where _ => unreachable!(), }; - Rgb::::from_normalized(r1 + m, g1 + m, b1 + m).to_rgb::() + Rgb::::from_normalized(r1 + m, g1 + m, b1 + m) + .to_rgb::() + .with_alpha(self.alpha) } /// Returns this color with a different viewing context (without adaptation). @@ -496,10 +505,18 @@ impl ColorSpace<3> for Hsl where S: RgbSpec, { + fn alpha(&self) -> f64 { + self.alpha.0 + } + fn components(&self) -> [f64; 3] { self.components() } + fn set_alpha(&mut self, alpha: impl Into) { + self.alpha = alpha.into().clamp(0.0, 1.0) + } + fn set_components(&mut self, components: [impl Into + Clone; 3]) { self.set_components(components) } @@ -514,14 +531,25 @@ where S: RgbSpec, { fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - write!( - f, - "HSL({:.precision$}°, {:.precision$}%, {:.precision$}%)", - self.hue(), - self.saturation(), - self.lightness(), - precision = f.precision().unwrap_or(2) - ) + let precision = f.precision().unwrap_or(2); + if self.alpha.0 < 1.0 { + write!( + f, + "HSL({:.precision$}°, {:.precision$}%, {:.precision$}%, {:.0}%)", + self.hue(), + self.saturation(), + self.lightness(), + self.opacity() + ) + } else { + write!( + f, + "HSL({:.precision$}°, {:.precision$}%, {:.precision$}%)", + self.hue(), + self.saturation(), + self.lightness() + ) + } } } @@ -638,7 +666,7 @@ where { fn eq(&self, other: &T) -> bool { let other = (*other).into(); - self.h == other.h && self.s == other.s && self.l == other.l + self.alpha == other.alpha && self.h == other.h && self.s == other.s && self.l == other.l } } @@ -781,6 +809,20 @@ mod test { assert_eq!(format!("{:.4}", hsl), "HSL(120.1235°, 50.6789%, 75.4321%)"); } + + #[test] + fn it_includes_opacity_when_alpha_below_one() { + let hsl = Hsl::::new(120.0, 50.0, 75.0).with_alpha(0.5); + + assert_eq!(format!("{}", hsl), "HSL(120.00°, 50.00%, 75.00%, 50%)"); + } + + #[test] + fn it_omits_opacity_when_fully_opaque() { + let hsl = Hsl::::new(120.0, 50.0, 75.0); + + assert_eq!(format!("{}", hsl), "HSL(120.00°, 50.00%, 75.00%)"); + } } mod div { @@ -1065,6 +1107,14 @@ mod test { assert_ne!(a, b); } + + #[test] + fn it_compares_unequal_when_alpha_differs() { + let a = Hsl::::new(180.0, 50.0, 50.0).with_alpha(0.5); + let b = Hsl::::new(180.0, 50.0, 50.0); + + assert_ne!(a, b); + } } mod scale_h { @@ -1204,6 +1254,14 @@ mod test { assert!((back.saturation() - original.saturation()).abs() < 1.0); assert!((back.lightness() - original.lightness()).abs() < 1.0); } + + #[test] + fn it_preserves_alpha() { + let hsl = Hsl::::new(120.0, 50.0, 50.0).with_alpha(0.3); + let rgb: Rgb = hsl.to_rgb(); + + assert!((rgb.alpha() - 0.3).abs() < 1e-10); + } } #[cfg(feature = "space-hwb")] diff --git a/src/space/cylindrical/hsv.rs b/src/space/cylindrical/hsv.rs index d43f7f8..395c52f 100644 --- a/src/space/cylindrical/hsv.rs +++ b/src/space/cylindrical/hsv.rs @@ -32,6 +32,7 @@ pub struct Hsv where S: RgbSpec, { + alpha: Component, context: ColorimetricContext, h: Component, s: Component, @@ -46,6 +47,7 @@ where /// Creates a new HSV color from hue (0-360°), saturation (0-100%), and value (0-100%). pub fn new(h: impl Into, s: impl Into, v: impl Into) -> Self { Self { + alpha: Component::new(1.0), context: S::CONTEXT, h: Component::new((h.into().0 / 360.0).rem_euclid(1.0)), s: s.into() / 100.0, @@ -59,6 +61,7 @@ where let r = (h / 360.0) % 1.0; Self { + alpha: Component::new_const(1.0), context: S::CONTEXT, h: Component::new_const(if r < 0.0 { r + 1.0 } else { r }), s: Component::new_const(s / 100.0), @@ -296,7 +299,7 @@ where (v - nl) / nl.min(1.0 - nl) }; - Hsl::::new(h, ns, nl) + Hsl::::new(h, ns, nl).with_alpha(self.alpha) } #[cfg(feature = "space-hwb")] @@ -307,7 +310,7 @@ where let nw = (1.0 - s) * v; let nb = 1.0 - v; - Hwb::::new(h * 360.0, nw * 100.0, nb * 100.0) + Hwb::::new(h * 360.0, nw * 100.0, nb * 100.0).with_alpha(self.alpha) } /// Converts this HSV color to an [`Rgb`] color in the specified output space. @@ -320,10 +323,12 @@ where let v = self.v.0; if s <= 0.0 { - return Rgb::::from_normalized(v, v, v).to_rgb::(); + return Rgb::::from_normalized(v, v, v).to_rgb::().with_alpha(self.alpha); } if v <= 0.0 { - return Rgb::::from_normalized(0.0, 0.0, 0.0).to_rgb::(); + return Rgb::::from_normalized(0.0, 0.0, 0.0) + .to_rgb::() + .with_alpha(self.alpha); } let c = v * s; @@ -341,7 +346,9 @@ where _ => unreachable!(), }; - Rgb::::from_normalized(r1 + m, g1 + m, b1 + m).to_rgb::() + Rgb::::from_normalized(r1 + m, g1 + m, b1 + m) + .to_rgb::() + .with_alpha(self.alpha) } /// Returns this color with a different viewing context (without adaptation). @@ -577,10 +584,18 @@ impl ColorSpace<3> for Hsv where S: RgbSpec, { + fn alpha(&self) -> f64 { + self.alpha.0 + } + fn components(&self) -> [f64; 3] { self.components() } + fn set_alpha(&mut self, alpha: impl Into) { + self.alpha = alpha.into().clamp(0.0, 1.0); + } + fn set_components(&mut self, components: [impl Into + Clone; 3]) { self.set_components(components) } @@ -595,14 +610,25 @@ where S: RgbSpec, { fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - write!( - f, - "HSV({:.precision$}°, {:.precision$}%, {:.precision$}%)", - self.hue(), - self.saturation(), - self.value(), - precision = f.precision().unwrap_or(2) - ) + let precision = f.precision().unwrap_or(2); + if self.alpha.0 < 1.0 { + write!( + f, + "HSV({:.precision$}°, {:.precision$}%, {:.precision$}%, {:.0}%)", + self.hue(), + self.saturation(), + self.value(), + self.opacity() + ) + } else { + write!( + f, + "HSV({:.precision$}°, {:.precision$}%, {:.precision$}%)", + self.hue(), + self.saturation(), + self.value() + ) + } } } @@ -719,7 +745,7 @@ where { fn eq(&self, other: &T) -> bool { let other = (*other).into(); - self.h == other.h && self.s == other.s && self.v == other.v + self.alpha == other.alpha && self.h == other.h && self.s == other.s && self.v == other.v } } @@ -862,6 +888,20 @@ mod test { assert_eq!(format!("{:.4}", hsv), "HSV(120.1235°, 50.6789%, 75.4321%)"); } + + #[test] + fn it_includes_opacity_when_alpha_below_one() { + let hsv = Hsv::::new(120.0, 50.0, 75.0).with_alpha(0.5); + + assert_eq!(format!("{}", hsv), "HSV(120.00°, 50.00%, 75.00%, 50%)"); + } + + #[test] + fn it_omits_opacity_when_fully_opaque() { + let hsv = Hsv::::new(120.0, 50.0, 75.0); + + assert_eq!(format!("{}", hsv), "HSV(120.00°, 50.00%, 75.00%)"); + } } mod div { @@ -1146,6 +1186,14 @@ mod test { assert_ne!(a, b); } + + #[test] + fn it_compares_unequal_when_alpha_differs() { + let a = Hsv::::new(180.0, 50.0, 50.0).with_alpha(0.5); + let b = Hsv::::new(180.0, 50.0, 50.0); + + assert_ne!(a, b); + } } mod scale_h { @@ -1285,6 +1333,14 @@ mod test { assert!((back.saturation() - original.saturation()).abs() < 1.0); assert!((back.value() - original.value()).abs() < 1.0); } + + #[test] + fn it_preserves_alpha() { + let hsv = Hsv::::new(120.0, 50.0, 50.0).with_alpha(0.3); + let rgb: Rgb = hsv.to_rgb(); + + assert!((rgb.alpha() - 0.3).abs() < 1e-10); + } } #[cfg(feature = "space-hwb")] diff --git a/src/space/cylindrical/hwb.rs b/src/space/cylindrical/hwb.rs index 28b22bc..14f1c54 100644 --- a/src/space/cylindrical/hwb.rs +++ b/src/space/cylindrical/hwb.rs @@ -29,6 +29,7 @@ pub struct Hwb where S: RgbSpec, { + alpha: Component, context: ColorimetricContext, h: Component, w: Component, @@ -43,6 +44,7 @@ where /// Creates a new HWB color from hue (0-360°), whiteness (0-100%), and blackness (0-100%). pub fn new(h: impl Into, w: impl Into, b: impl Into) -> Self { Self { + alpha: Component::new(1.0), context: S::CONTEXT, h: Component::new((h.into().0 / 360.0).rem_euclid(1.0)), w: w.into() / 100.0, @@ -56,6 +58,7 @@ where let r = (h / 360.0) % 1.0; Self { + alpha: Component::new_const(1.0), context: S::CONTEXT, h: Component::new_const(if r < 0.0 { r + 1.0 } else { r }), w: Component::new_const(w / 100.0), @@ -246,7 +249,7 @@ where (v - l) / l.min(1.0 - l) }; - Hsl::::new(h * 360.0, sl * 100.0, l * 100.0) + Hsl::::new(h * 360.0, sl * 100.0, l * 100.0).with_alpha(self.alpha) } #[cfg(feature = "space-hsv")] @@ -257,7 +260,7 @@ where let v = 1.0 - b; let s = if v == 0.0 { 0.0 } else { 1.0 - (w / v) }; - Hsv::::new(h * 360.0, s * 100.0, v * 100.0) + Hsv::::new(h * 360.0, s * 100.0, v * 100.0).with_alpha(self.alpha) } /// Converts this HWB color to an [`Rgb`] color in the specified output space. @@ -272,7 +275,9 @@ where // When W + B >= 1, the color is a shade of gray if w + b >= 1.0 { let gray = w / (w + b); - return Rgb::::from_normalized(gray, gray, gray).to_rgb::(); + return Rgb::::from_normalized(gray, gray, gray) + .to_rgb::() + .with_alpha(self.alpha); } // Compute the pure hue RGB values @@ -292,7 +297,9 @@ where // Scale by (1 - W - B) and add W let scale = 1.0 - w - b; - Rgb::::from_normalized(r1 * scale + w, g1 * scale + w, b1 * scale + w).to_rgb::() + Rgb::::from_normalized(r1 * scale + w, g1 * scale + w, b1 * scale + w) + .to_rgb::() + .with_alpha(self.alpha) } /// Returns the normalized whiteness component (0.0-1.0). @@ -498,10 +505,18 @@ impl ColorSpace<3> for Hwb where S: RgbSpec, { + fn alpha(&self) -> f64 { + self.alpha.0 + } + fn components(&self) -> [f64; 3] { self.components() } + fn set_alpha(&mut self, alpha: impl Into) { + self.alpha = alpha.into().clamp(0.0, 1.0); + } + fn set_components(&mut self, components: [impl Into + Clone; 3]) { self.set_components(components) } @@ -516,14 +531,25 @@ where S: RgbSpec, { fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - write!( - f, - "HWB({:.precision$}°, {:.precision$}%, {:.precision$}%)", - self.hue(), - self.whiteness(), - self.blackness(), - precision = f.precision().unwrap_or(2) - ) + let precision = f.precision().unwrap_or(2); + if self.alpha.0 < 1.0 { + write!( + f, + "HWB({:.precision$}°, {:.precision$}%, {:.precision$}%, {:.0}%)", + self.hue(), + self.whiteness(), + self.blackness(), + self.opacity() + ) + } else { + write!( + f, + "HWB({:.precision$}°, {:.precision$}%, {:.precision$}%)", + self.hue(), + self.whiteness(), + self.blackness() + ) + } } } @@ -640,7 +666,7 @@ where { fn eq(&self, other: &T) -> bool { let other = (*other).into(); - self.h == other.h && self.w == other.w && self.b == other.b + self.alpha == other.alpha && self.h == other.h && self.w == other.w && self.b == other.b } } @@ -783,6 +809,20 @@ mod test { assert_eq!(format!("{:.4}", hwb), "HWB(120.1235°, 25.6789%, 50.4321%)"); } + + #[test] + fn it_includes_opacity_when_alpha_below_one() { + let hwb = Hwb::::new(120.0, 25.0, 50.0).with_alpha(0.5); + + assert_eq!(format!("{}", hwb), "HWB(120.00°, 25.00%, 50.00%, 50%)"); + } + + #[test] + fn it_omits_opacity_when_fully_opaque() { + let hwb = Hwb::::new(120.0, 25.0, 50.0); + + assert_eq!(format!("{}", hwb), "HWB(120.00°, 25.00%, 50.00%)"); + } } mod div { @@ -1097,6 +1137,14 @@ mod test { assert_ne!(a, b); } + + #[test] + fn it_compares_unequal_when_alpha_differs() { + let a = Hwb::::new(180.0, 25.0, 25.0).with_alpha(0.5); + let b = Hwb::::new(180.0, 25.0, 25.0); + + assert_ne!(a, b); + } } mod scale_b { @@ -1246,6 +1294,14 @@ mod test { assert!((back.whiteness() - original.whiteness()).abs() < 1.0); assert!((back.blackness() - original.blackness()).abs() < 1.0); } + + #[test] + fn it_preserves_alpha() { + let hwb = Hwb::::new(120.0, 25.0, 25.0).with_alpha(0.3); + let rgb: Rgb = hwb.to_rgb(); + + assert!((rgb.alpha() - 0.3).abs() < 1e-10); + } } #[cfg(feature = "space-hsl")] diff --git a/src/space/physiological/lms.rs b/src/space/physiological/lms.rs index fc6e9cd..bc538df 100644 --- a/src/space/physiological/lms.rs +++ b/src/space/physiological/lms.rs @@ -25,6 +25,7 @@ use crate::{ /// Long (L), Medium (M), and Short (S) wavelength-sensitive. #[derive(Clone, Copy, Debug)] pub struct Lms { + alpha: Component, context: ColorimetricContext, l: Component, m: Component, @@ -35,6 +36,7 @@ impl Lms { /// Creates a new LMS color with the default viewing context. pub fn new(l: impl Into, m: impl Into, s: impl Into) -> Self { Self { + alpha: Component::new(1.0), context: ColorimetricContext::default(), l: l.into(), m: m.into(), @@ -45,6 +47,7 @@ impl Lms { /// Creates a new LMS color in a const context. pub const fn new_const(l: f64, m: f64, s: f64) -> Self { Self { + alpha: Component::new_const(1.0), context: ColorimetricContext::DEFAULT, l: Component::new_const(l), m: Component::new_const(m), @@ -232,7 +235,9 @@ impl Lms { /// Converts to CIE XYZ using the inverse of the context's CAT matrix. pub fn to_xyz(&self) -> Xyz { - Xyz::from(self.context.cat().inverse() * self.components()).with_context(self.context) + Xyz::from(self.context.cat().inverse() * self.components()) + .with_context(self.context) + .with_alpha(self.alpha) } /// Returns this color with a different viewing context (without adaptation). @@ -403,10 +408,18 @@ where } impl ColorSpace<3> for Lms { + fn alpha(&self) -> f64 { + self.alpha.0 + } + fn components(&self) -> [f64; 3] { self.components() } + fn set_alpha(&mut self, alpha: impl Into) { + self.alpha = alpha.into().clamp(0.0, 1.0); + } + fn set_components(&mut self, components: [impl Into + Clone; 3]) { self.set_components(components) } @@ -422,14 +435,23 @@ impl ColorSpace<3> for Lms { impl Display for Lms { fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - write!( - f, - "LMS({:.precision$}, {:.precision$}, {:.precision$})", - self.l, - self.m, - self.s, - precision = f.precision().unwrap_or(4) - ) + let precision = f.precision().unwrap_or(4); + if self.alpha.0 < 1.0 { + write!( + f, + "LMS({:.precision$}, {:.precision$}, {:.precision$}, {:.0}%)", + self.l, + self.m, + self.s, + self.opacity() + ) + } else { + write!( + f, + "LMS({:.precision$}, {:.precision$}, {:.precision$})", + self.l, self.m, self.s + ) + } } } @@ -535,7 +557,7 @@ where { fn eq(&self, other: &T) -> bool { let other = (*other).into(); - self.l == other.l && self.m == other.m && self.s == other.s + self.alpha == other.alpha && self.l == other.l && self.m == other.m && self.s == other.s } } @@ -718,6 +740,20 @@ mod test { assert_eq!(format!("{:.2}", lms), "LMS(0.12, 0.23, 0.35)"); assert_eq!(format!("{:.6}", lms), "LMS(0.123457, 0.234568, 0.345679)"); } + + #[test] + fn it_includes_opacity_when_alpha_below_one() { + let lms = Lms::new(0.5, 0.5, 0.5).with_alpha(0.5); + + assert_eq!(format!("{}", lms), "LMS(0.5000, 0.5000, 0.5000, 50%)"); + } + + #[test] + fn it_omits_opacity_when_fully_opaque() { + let lms = Lms::new(0.5, 0.5, 0.5); + + assert_eq!(format!("{}", lms), "LMS(0.5000, 0.5000, 0.5000)"); + } } #[cfg(feature = "space-cmyk")] @@ -816,6 +852,14 @@ mod test { assert_eq!(lms, [0.1, 0.2, 0.3]); } + + #[test] + fn it_compares_unequal_when_alpha_differs() { + let a = Lms::new(0.1, 0.2, 0.3).with_alpha(0.5); + let b = Lms::new(0.1, 0.2, 0.3); + + assert_ne!(a, b); + } } mod scale_l { @@ -902,6 +946,14 @@ mod test { assert!((back.y() - original.y()).abs() < 1e-10); assert!((back.z() - original.z()).abs() < 1e-10); } + + #[test] + fn it_preserves_alpha() { + let lms = Lms::new(0.5, 0.5, 0.5).with_alpha(0.3); + let xyz = lms.to_xyz(); + + assert!((xyz.alpha() - 0.3).abs() < 1e-10); + } } mod with_l { diff --git a/src/space/rgb/linear.rs b/src/space/rgb/linear.rs index 833f3b2..9eb8a36 100644 --- a/src/space/rgb/linear.rs +++ b/src/space/rgb/linear.rs @@ -14,6 +14,7 @@ pub struct LinearRgb where S: RgbSpec, { + alpha: Component, b: Component, g: Component, r: Component, @@ -27,6 +28,7 @@ where /// Creates linear RGB from normalized (0.0-1.0) component values. pub fn from_normalized(r: impl Into, g: impl Into, b: impl Into) -> Self { Self { + alpha: Component::new(1.0), b: b.into().clamp(0.0, 1.0), g: g.into().clamp(0.0, 1.0), r: r.into().clamp(0.0, 1.0), @@ -37,6 +39,7 @@ where /// Creates linear RGB from 8-bit (0-255) component values. pub fn from_u8(r: impl Into, g: impl Into, b: impl Into) -> Self { Self { + alpha: Component::new(1.0), b: b.into() / 255.0, g: g.into() / 255.0, r: r.into() / 255.0, @@ -52,6 +55,7 @@ where /// Creates linear RGB from 8-bit values in a const context. pub const fn new_const(r: u8, g: u8, b: u8) -> Self { Self { + alpha: Component::new_const(1.0), b: Component::new_const(b as f64 / 255.0), g: Component::new_const(g as f64 / 255.0), r: Component::new_const(r as f64 / 255.0), @@ -59,6 +63,11 @@ where } } + /// Returns the alpha (transparency) value on a 0.0 to 1.0 scale. + pub fn alpha(&self) -> f64 { + self.alpha.0 + } + /// Returns the normalized blue component (0.0-1.0). pub fn b(&self) -> f64 { self.b.0 @@ -69,6 +78,7 @@ where (self.b.0 * 255.0).round() as u8 } + /// Returns the [R, G, B] components as normalized values. pub fn components(&self) -> [f64; 3] { [self.r.0, self.g.0, self.b.0] } @@ -98,7 +108,15 @@ where let r = S::TRANSFER_FUNCTION.encode(self.r); let g = S::TRANSFER_FUNCTION.encode(self.g); let b = S::TRANSFER_FUNCTION.encode(self.b); - Rgb::from_normalized(r, g, b) + Rgb::from_normalized(r, g, b).with_alpha(self.alpha) + } + + /// Returns a new color with the given alpha value on a 0.0 to 1.0 scale. + pub fn with_alpha(&self, alpha: impl Into) -> Self { + Self { + alpha: alpha.into().clamp(0.0, 1.0), + ..*self + } } } @@ -107,21 +125,33 @@ where S: RgbSpec, { fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - write!( - f, - "Linear {}({}, {}, {})", - S::NAME, - self.red(), - self.green(), - self.blue() - ) + if self.alpha.0 < 1.0 { + write!( + f, + "Linear {}({}, {}, {}, {:.0}%)", + S::NAME, + self.red(), + self.green(), + self.blue(), + self.alpha.0 * 100.0 + ) + } else { + write!( + f, + "Linear {}({}, {}, {})", + S::NAME, + self.red(), + self.green(), + self.blue() + ) + } } } #[cfg(test)] mod test { use super::*; - use crate::space::Srgb; + use crate::space::{ColorSpace, Srgb}; mod display { use pretty_assertions::assert_eq; @@ -134,6 +164,20 @@ mod test { assert_eq!(format!("{}", linear), "Linear sRGB(128, 64, 32)"); } + + #[test] + fn it_includes_opacity_when_alpha_below_one() { + let linear = LinearRgb::::new(128, 64, 32).with_alpha(0.5); + + assert_eq!(format!("{}", linear), "Linear sRGB(128, 64, 32, 50%)"); + } + + #[test] + fn it_omits_opacity_when_fully_opaque() { + let linear = LinearRgb::::new(128, 64, 32); + + assert_eq!(format!("{}", linear), "Linear sRGB(128, 64, 32)"); + } } mod to_encoded { @@ -159,5 +203,13 @@ mod test { assert!((back.g() - original.g()).abs() < 1e-10); assert!((back.b() - original.b()).abs() < 1e-10); } + + #[test] + fn it_preserves_alpha() { + let linear = LinearRgb::::from_normalized(0.5, 0.5, 0.5).with_alpha(0.3); + let encoded = linear.to_encoded(); + + assert!((encoded.alpha() - 0.3).abs() < 1e-10); + } } } diff --git a/src/space/rgb/space.rs b/src/space/rgb/space.rs index 5f9fe6c..4954410 100644 --- a/src/space/rgb/space.rs +++ b/src/space/rgb/space.rs @@ -187,6 +187,7 @@ pub struct Rgb where S: RgbSpec, { + alpha: Component, b: Component, context: ColorimetricContext, g: Component, @@ -198,6 +199,11 @@ impl Rgb where S: RgbSpec, { + /// Black (0, 0, 0). + pub const BLACK: Self = Self::new_const(0, 0, 0); + /// White (255, 255, 255). + pub const WHITE: Self = Self::new_const(255, 255, 255); + /// Parses a hex color code (e.g., "#FF5733" or "F00") into an RGB color. pub fn from_hexcode(hexcode: impl Into) -> Result { let hexcode = hexcode.into(); @@ -242,6 +248,7 @@ where /// Creates an RGB color from normalized (0.0-1.0) component values. pub fn from_normalized(r: impl Into, g: impl Into, b: impl Into) -> Self { Self { + alpha: Component::new(1.0), b: b.into().clamp(0.0, 1.0), context: S::CONTEXT, g: g.into().clamp(0.0, 1.0), @@ -253,6 +260,7 @@ where /// Creates an RGB color from 8-bit (0-255) component values. pub fn new(r: u8, g: u8, b: u8) -> Self { Self { + alpha: Component::new(1.0), b: Component::from(b) / 255.0, context: S::CONTEXT, g: Component::from(g) / 255.0, @@ -268,6 +276,7 @@ where let b = Component::new_const(b as f64 / 255.0); Self { + alpha: Component::new_const(1.0), b, context: S::CONTEXT, g, @@ -296,26 +305,6 @@ where &self.context } - /// Returns the normalized green component (0.0-1.0). - pub fn g(&self) -> f64 { - self.g.0 - } - - /// Returns the green component as a u8 (0-255). - pub fn green(&self) -> u8 { - (self.g.0 * 255.0).round() as u8 - } - - /// Returns the normalized red component (0.0-1.0). - pub fn r(&self) -> f64 { - self.r.0 - } - - /// Returns the red component as a u8 (0-255). - pub fn red(&self) -> u8 { - (self.r.0 * 255.0).round() as u8 - } - /// Decreases the blue channel by the given normalized amount (0.0-1.0). pub fn decrement_b(&mut self, amount: impl Into) { self.b = (self.b - amount.into()).clamp(0.0, 1.0); @@ -346,6 +335,43 @@ where self.r = (self.r - amount.into() / 255.0).clamp(0.0, 1.0); } + /// Flattens the alpha channel against black, compositing the color. + pub fn flatten_alpha(&mut self) { + self.flatten_alpha_against(Self::BLACK) + } + + /// Flattens the alpha channel against the given background color. + pub fn flatten_alpha_against(&mut self, background: impl Into) { + let alpha = self.alpha.0; + let [r, g, b] = self.components(); + let [br, bg, bb] = background.into().components(); + + self.r = Component::new((r * alpha) + (br * (1.0 - alpha))); + self.g = Component::new((g * alpha) + (bg * (1.0 - alpha))); + self.b = Component::new((b * alpha) + (bb * (1.0 - alpha))); + self.alpha = Component::new(1.0); + } + + /// Alias for [`Self::flatten_alpha`]. + pub fn flatten_opacity(&mut self) { + self.flatten_alpha() + } + + /// Alias for [`Self::flatten_alpha_against`]. + pub fn flatten_opacity_against(&mut self, background: impl Into) { + self.flatten_alpha_against(background) + } + + /// Returns the normalized green component (0.0-1.0). + pub fn g(&self) -> f64 { + self.g.0 + } + + /// Returns the green component as a u8 (0-255). + pub fn green(&self) -> u8 { + (self.g.0 * 255.0).round() as u8 + } + /// Increases the blue channel by the given normalized amount (0.0-1.0). pub fn increment_b(&mut self, amount: impl Into) { self.b = (self.b + amount.into()).clamp(0.0, 1.0); @@ -376,6 +402,16 @@ where self.r = (self.r + amount.into() / 255.0).clamp(0.0, 1.0); } + /// Returns the normalized red component (0.0-1.0). + pub fn r(&self) -> f64 { + self.r.0 + } + + /// Returns the red component as a u8 (0-255). + pub fn red(&self) -> u8 { + (self.r.0 * 255.0).round() as u8 + } + /// Scales the blue channel by the given factor, clamping to 0.0-1.0. pub fn scale_b(&mut self, factor: impl Into) { self.b = (self.b * factor.into()).clamp(0.0, 1.0); @@ -451,6 +487,7 @@ where (1.0 - self.g.0) * 100.0, (1.0 - self.b.0) * 100.0, ) + .with_alpha(self.alpha) } #[cfg(feature = "space-cmyk")] @@ -462,7 +499,7 @@ where let k = 1.0 - r.max(g).max(b); if (k - 1.0).abs() < f64::EPSILON { - return Cmyk::new(0.0, 0.0, 0.0, 100.0); + return Cmyk::new(0.0, 0.0, 0.0, 100.0).with_alpha(self.alpha); } Cmyk::new( @@ -471,6 +508,7 @@ where ((1.0 - b - k) / (1.0 - k)) * 100.0, k * 100.0, ) + .with_alpha(self.alpha) } #[cfg(feature = "space-hsv")] @@ -492,7 +530,7 @@ where let l = (max + min) / 2.0; if delta <= 0.0 { - return Hsl::new(0.0, 0.0, l * 100.0); + return Hsl::new(0.0, 0.0, l * 100.0).with_alpha(self.alpha); } let s = if l <= 0.5 { @@ -509,7 +547,7 @@ where (4.0 + (r - g) / delta) / 6.0 }; - Hsl::new(h * 360.0, s * 100.0, l * 100.0) + Hsl::new(h * 360.0, s * 100.0, l * 100.0).with_alpha(self.alpha) } #[cfg(feature = "space-hsv")] @@ -524,7 +562,7 @@ where let delta = max - min; if delta <= 0.0 { - return Hsv::new(0.0, 0.0, max * 100.0); + return Hsv::new(0.0, 0.0, max * 100.0).with_alpha(self.alpha); } let s = delta / max; @@ -537,7 +575,7 @@ where (4.0 + (r - g) / delta) / 6.0 }; - Hsv::new(h * 360.0, s * 100.0, max * 100.0) + Hsv::new(h * 360.0, s * 100.0, max * 100.0).with_alpha(self.alpha) } #[cfg(feature = "space-hwb")] @@ -552,7 +590,7 @@ where let delta = max - min; if delta <= 0.0 { - return Hwb::new(0.0, min * 100.0, (1.0 - max) * 100.0); + return Hwb::new(0.0, min * 100.0, (1.0 - max) * 100.0).with_alpha(self.alpha); } let h = if (max - r).abs() < f64::EPSILON { @@ -563,7 +601,7 @@ where (4.0 + (r - g) / delta) / 6.0 }; - Hwb::new(h * 360.0, min * 100.0, (1.0 - max) * 100.0) + Hwb::new(h * 360.0, min * 100.0, (1.0 - max) * 100.0).with_alpha(self.alpha) } /// Decodes to linear RGB by applying the inverse transfer function. @@ -571,7 +609,7 @@ where let r = S::TRANSFER_FUNCTION.decode(self.r); let g = S::TRANSFER_FUNCTION.decode(self.g); let b = S::TRANSFER_FUNCTION.decode(self.b); - LinearRgb::from_normalized(r, g, b) + LinearRgb::from_normalized(r, g, b).with_alpha(self.alpha) } /// Converts to a different RGB color space via XYZ. @@ -580,22 +618,39 @@ where OS: RgbSpec, { if S::NAME == OS::NAME { - Rgb::::from_normalized(self.r(), self.g(), self.b()) + Rgb::::from_normalized(self.r(), self.g(), self.b()).with_alpha(self.alpha()) } else { - self.to_xyz().to_rgb::() + self.to_xyz().to_rgb::().with_alpha(self.alpha()) } } - /// Converts to LMS via XYZ. - pub fn to_lms(&self) -> Lms { - self.to_xyz().to_lms() - } - /// Converts to CIE XYZ via linear RGB and the space's RGB-to-XYZ matrix. pub fn to_xyz(&self) -> Xyz { let linear = self.to_linear(); let [x, y, z] = *S::xyz_matrix() * linear.components(); - Xyz::new(x, y, z).with_context(self.context) + Xyz::new(x, y, z).with_context(self.context).with_alpha(self.alpha) + } + + /// Returns a new color with the given alpha value on a 0.0 to 1.0 scale. + pub fn with_alpha(&self, alpha: impl Into) -> Self { + Self { + alpha: alpha.into().clamp(0.0, 1.0), + ..*self + } + } + + /// Returns a new color with the alpha channel flattened against black. + pub fn with_alpha_flattened(&self) -> Self { + let mut rgb = *self; + rgb.flatten_alpha(); + rgb + } + + /// Returns a new color with the alpha channel flattened against the given background. + pub fn with_alpha_flattened_against(&self, background: impl Into) -> Self { + let mut rgb = *self; + rgb.flatten_alpha_against(background); + rgb } /// Returns a new color with the given normalized blue channel value (0.0-1.0). @@ -710,6 +765,16 @@ where self.with_g_scaled_by(factor) } + /// Alias for [`Self::with_alpha_flattened`]. + pub fn with_opacity_flattened(&self) -> Self { + self.with_alpha_flattened() + } + + /// Alias for [`Self::with_alpha_flattened_against`]. + pub fn with_opacity_flattened_against(&self, background: impl Into) -> Self { + self.with_alpha_flattened_against(background) + } + /// Returns a new color with the given normalized red channel value (0.0-1.0). pub fn with_r(&self, r: impl Into) -> Self { Self { @@ -779,7 +844,7 @@ where let r = (self.r + rhs.r).clamp(0.0, 1.0); let g = (self.g + rhs.g).clamp(0.0, 1.0); let b = (self.b + rhs.b).clamp(0.0, 1.0); - Self::from_normalized(r, g, b) + Self::from_normalized(r, g, b).with_alpha(self.alpha) } } @@ -787,10 +852,18 @@ impl ColorSpace<3> for Rgb where S: RgbSpec, { + fn alpha(&self) -> f64 { + self.alpha.0 + } + fn components(&self) -> [f64; 3] { self.components() } + fn set_alpha(&mut self, alpha: impl Into) { + self.alpha = alpha.into().clamp(0.0, 1.0); + } + fn set_components(&mut self, components: [impl Into + Clone; 3]) { self.set_components(components) } @@ -812,7 +885,19 @@ where S: RgbSpec, { fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - write!(f, "{}({}, {}, {})", S::NAME, self.red(), self.green(), self.blue()) + if self.alpha.0 < 1.0 { + write!( + f, + "{}({}, {}, {}, {:.0}%)", + S::NAME, + self.red(), + self.green(), + self.blue(), + self.opacity() + ) + } else { + write!(f, "{}({}, {}, {})", S::NAME, self.red(), self.green(), self.blue()) + } } } @@ -828,7 +913,7 @@ where let r = (self.r / rhs.r).clamp(0.0, 1.0); let g = (self.g / rhs.g).clamp(0.0, 1.0); let b = (self.b / rhs.b).clamp(0.0, 1.0); - Self::from_normalized(r, g, b) + Self::from_normalized(r, g, b).with_alpha(self.alpha) } } @@ -927,7 +1012,7 @@ where let r = (self.r * rhs.r).clamp(0.0, 1.0); let g = (self.g * rhs.g).clamp(0.0, 1.0); let b = (self.b * rhs.b).clamp(0.0, 1.0); - Self::from_normalized(r, g, b) + Self::from_normalized(r, g, b).with_alpha(self.alpha) } } @@ -938,7 +1023,10 @@ where { fn eq(&self, other: &T) -> bool { let other = (*other).into(); - self.red() == other.red() && self.green() == other.green() && self.blue() == other.blue() + self.alpha == other.alpha + && self.red() == other.red() + && self.green() == other.green() + && self.blue() == other.blue() } } @@ -954,7 +1042,7 @@ where let r = (self.r - rhs.r).clamp(0.0, 1.0); let g = (self.g - rhs.g).clamp(0.0, 1.0); let b = (self.b - rhs.b).clamp(0.0, 1.0); - Self::from_normalized(r, g, b) + Self::from_normalized(r, g, b).with_alpha(self.alpha) } } @@ -1041,6 +1129,15 @@ mod test { assert_eq!(result.g(), 1.0); assert_eq!(result.b(), 1.0); } + + #[test] + fn it_preserves_alpha_from_lhs() { + let a = Rgb::::from_normalized(0.2, 0.3, 0.4).with_alpha(0.5); + let b = Rgb::::from_normalized(0.1, 0.2, 0.3); + let result = a + b; + + assert!((result.alpha() - 0.5).abs() < 1e-10); + } } mod decrement_b { @@ -1180,6 +1277,20 @@ mod test { assert_eq!(format!("{}", rgb), "sRGB(255, 128, 64)"); } + + #[test] + fn it_includes_opacity_when_alpha_below_one() { + let rgb = Rgb::::new(255, 128, 64).with_alpha(0.5); + + assert_eq!(format!("{}", rgb), "sRGB(255, 128, 64, 50%)"); + } + + #[test] + fn it_omits_opacity_when_fully_opaque() { + let rgb = Rgb::::new(255, 128, 64); + + assert_eq!(format!("{}", rgb), "sRGB(255, 128, 64)"); + } } mod div { @@ -1341,6 +1452,76 @@ mod test { } } + mod flatten_alpha { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn it_composites_against_black() { + let mut rgb = Rgb::::new(255, 0, 0); + rgb.set_alpha(0.5); + rgb.flatten_alpha(); + + assert_eq!(rgb.red(), 128); + assert_eq!(rgb.green(), 0); + assert_eq!(rgb.blue(), 0); + assert!((rgb.alpha() - 1.0).abs() < 1e-10); + } + + #[test] + fn it_is_noop_for_fully_opaque() { + let mut rgb = Rgb::::new(200, 100, 50); + rgb.flatten_alpha(); + + assert_eq!(rgb.red(), 200); + assert_eq!(rgb.green(), 100); + assert_eq!(rgb.blue(), 50); + } + + #[test] + fn it_produces_black_for_fully_transparent() { + let mut rgb = Rgb::::new(255, 128, 64); + rgb.set_alpha(0.0); + rgb.flatten_alpha(); + + assert_eq!(rgb.red(), 0); + assert_eq!(rgb.green(), 0); + assert_eq!(rgb.blue(), 0); + } + } + + mod flatten_alpha_against { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn it_composites_against_background() { + let mut rgb = Rgb::::new(255, 0, 0); + rgb.set_alpha(0.5); + let bg = Rgb::::new(0, 0, 255); + rgb.flatten_alpha_against(bg); + + assert_eq!(rgb.red(), 128); + assert_eq!(rgb.green(), 0); + assert_eq!(rgb.blue(), 128); + assert!((rgb.alpha() - 1.0).abs() < 1e-10); + } + + #[test] + fn it_returns_background_for_fully_transparent() { + let mut rgb = Rgb::::new(255, 0, 0); + rgb.set_alpha(0.0); + let bg = Rgb::::new(0, 255, 0); + rgb.flatten_alpha_against(bg); + + assert_eq!(rgb.red(), 0); + assert_eq!(rgb.green(), 255); + assert_eq!(rgb.blue(), 0); + } + } + mod increment_b { use super::*; @@ -1502,6 +1683,14 @@ mod test { assert_ne!(a, b); } + + #[test] + fn it_compares_unequal_when_alpha_differs() { + let a = Rgb::::new(128, 64, 32).with_alpha(0.5); + let b = Rgb::::new(128, 64, 32); + + assert_ne!(a, b); + } } mod scale_b { @@ -1656,6 +1845,14 @@ mod test { assert_eq!(back.green(), original.green()); assert_eq!(back.blue(), original.blue()); } + + #[test] + fn it_preserves_alpha() { + let rgb = Rgb::::new(128, 64, 200).with_alpha(0.3); + let linear = rgb.to_linear(); + + assert!((linear.alpha() - 0.3).abs() < 1e-10); + } } mod to_lms { @@ -1831,6 +2028,14 @@ mod test { assert_eq!(back.green(), original.green()); assert_eq!(back.blue(), original.blue()); } + + #[test] + fn it_preserves_alpha() { + let rgb = Rgb::::new(200, 100, 50).with_alpha(0.4); + let hsl = rgb.to_hsl(); + + assert!((hsl.alpha() - 0.4).abs() < 1e-10); + } } mod to_rgb { @@ -1882,6 +2087,14 @@ mod test { assert_eq!(back.green(), original.green()); assert_eq!(back.blue(), original.blue()); } + + #[test] + fn it_preserves_alpha() { + let rgb = Rgb::::new(200, 100, 50).with_alpha(0.7); + let xyz = rgb.to_xyz(); + + assert!((xyz.alpha() - 0.7).abs() < 1e-10); + } } mod try_from_str { @@ -1921,6 +2134,61 @@ mod test { } } + mod with_alpha_flattened { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn it_returns_rgb_with_alpha_flattened() { + let mut rgb = Rgb::::new(255, 0, 0); + rgb.set_alpha(0.5); + let result = rgb.with_alpha_flattened(); + + assert_eq!(result.red(), 128); + assert_eq!(result.green(), 0); + assert_eq!(result.blue(), 0); + assert!((result.alpha() - 1.0).abs() < 1e-10); + } + + #[test] + fn it_does_not_mutate_original() { + let mut rgb = Rgb::::new(255, 0, 0); + rgb.set_alpha(0.5); + let _ = rgb.with_alpha_flattened(); + + assert!((rgb.alpha() - 0.5).abs() < 1e-10); + } + } + + mod with_alpha_flattened_against { + use pretty_assertions::assert_eq; + + use super::*; + + #[test] + fn it_returns_rgb_with_alpha_flattened_against_background() { + let mut rgb = Rgb::::new(255, 0, 0); + rgb.set_alpha(0.5); + let bg = Rgb::::new(0, 0, 255); + let result = rgb.with_alpha_flattened_against(bg); + + assert_eq!(result.red(), 128); + assert_eq!(result.green(), 0); + assert_eq!(result.blue(), 128); + } + + #[test] + fn it_does_not_mutate_original() { + let mut rgb = Rgb::::new(255, 0, 0); + rgb.set_alpha(0.5); + let bg = Rgb::::new(0, 0, 255); + let _ = rgb.with_alpha_flattened_against(bg); + + assert!((rgb.alpha() - 0.5).abs() < 1e-10); + } + } + mod with_b { use super::*; diff --git a/src/space/subtractive/cmy.rs b/src/space/subtractive/cmy.rs index 6de2514..613afd2 100644 --- a/src/space/subtractive/cmy.rs +++ b/src/space/subtractive/cmy.rs @@ -32,6 +32,7 @@ pub struct Cmy where S: RgbSpec, { + alpha: Component, context: ColorimetricContext, c: Component, m: Component, @@ -46,6 +47,7 @@ where /// Creates a new CMY color from cyan (0-100%), magenta (0-100%), and yellow (0-100%). pub fn new(c: impl Into, m: impl Into, y: impl Into) -> Self { Self { + alpha: Component::new(1.0), context: S::CONTEXT, c: c.into() / 100.0, m: m.into() / 100.0, @@ -57,6 +59,7 @@ where /// Creates a new CMY color in a const context from cyan (0-100%), magenta (0-100%), and yellow (0-100%). pub const fn new_const(c: f64, m: f64, y: f64) -> Self { Self { + alpha: Component::new_const(1.0), context: S::CONTEXT, c: Component::new_const(c / 100.0), m: Component::new_const(m / 100.0), @@ -234,7 +237,7 @@ where let k = nc.min(nm).min(ny); if (k - 1.0).abs() < f64::EPSILON { - return Cmyk::::new(0.0, 0.0, 0.0, 100.0); + return Cmyk::::new(0.0, 0.0, 0.0, 100.0).with_alpha(self.alpha); } Cmyk::::new( @@ -243,6 +246,7 @@ where ((ny - k) / (1.0 - k)) * 100.0, k * 100.0, ) + .with_alpha(self.alpha) } /// Converts this CMY color to an [`Rgb`] color in the specified output space. @@ -250,7 +254,9 @@ where where OS: RgbSpec, { - Rgb::::from_normalized(1.0 - self.c.0, 1.0 - self.m.0, 1.0 - self.y.0).to_rgb::() + Rgb::::from_normalized(1.0 - self.c.0, 1.0 - self.m.0, 1.0 - self.y.0) + .to_rgb::() + .with_alpha(self.alpha) } /// Returns this color with a different viewing context (without adaptation). @@ -456,10 +462,18 @@ impl ColorSpace<3> for Cmy where S: RgbSpec, { + fn alpha(&self) -> f64 { + self.alpha.0 + } + fn components(&self) -> [f64; 3] { self.components() } + fn set_alpha(&mut self, alpha: impl Into) { + self.alpha = alpha.into().clamp(0.0, 1.0); + } + fn set_components(&mut self, components: [impl Into + Clone; 3]) { self.set_components(components) } @@ -474,14 +488,25 @@ where S: RgbSpec, { fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - write!( - f, - "CMY({:.precision$}%, {:.precision$}%, {:.precision$}%)", - self.cyan(), - self.magenta(), - self.yellow(), - precision = f.precision().unwrap_or(2) - ) + let precision = f.precision().unwrap_or(2); + if self.alpha.0 < 1.0 { + write!( + f, + "CMY({:.precision$}%, {:.precision$}%, {:.precision$}%, {:.0}%)", + self.cyan(), + self.magenta(), + self.yellow(), + self.opacity() + ) + } else { + write!( + f, + "CMY({:.precision$}%, {:.precision$}%, {:.precision$}%)", + self.cyan(), + self.magenta(), + self.yellow() + ) + } } } @@ -598,7 +623,7 @@ where { fn eq(&self, other: &T) -> bool { let other = (*other).into(); - self.c == other.c && self.m == other.m && self.y == other.y + self.alpha == other.alpha && self.c == other.c && self.m == other.m && self.y == other.y } } @@ -723,6 +748,20 @@ mod test { assert_eq!(format!("{:.4}", cmy), "CMY(25.6789%, 50.1234%, 75.4321%)"); } + + #[test] + fn it_includes_opacity_when_alpha_below_one() { + let cmy = Cmy::::new(25.0, 50.0, 75.0).with_alpha(0.5); + + assert_eq!(format!("{}", cmy), "CMY(25.00%, 50.00%, 75.00%, 50%)"); + } + + #[test] + fn it_omits_opacity_when_fully_opaque() { + let cmy = Cmy::::new(25.0, 50.0, 75.0); + + assert_eq!(format!("{}", cmy), "CMY(25.00%, 50.00%, 75.00%)"); + } } mod div { @@ -1032,6 +1071,14 @@ mod test { assert_ne!(a, b); } + + #[test] + fn it_compares_unequal_when_alpha_differs() { + let a = Cmy::::new(25.0, 50.0, 75.0).with_alpha(0.5); + let b = Cmy::::new(25.0, 50.0, 75.0); + + assert_ne!(a, b); + } } mod scale_c { @@ -1205,6 +1252,14 @@ mod test { assert!((back.magenta() - original.magenta()).abs() < 1.0); assert!((back.yellow() - original.yellow()).abs() < 1.0); } + + #[test] + fn it_preserves_alpha() { + let cmy = Cmy::::new(25.0, 50.0, 75.0).with_alpha(0.3); + let rgb: Rgb = cmy.to_rgb(); + + assert!((rgb.alpha() - 0.3).abs() < 1e-10); + } } mod to_xyz { diff --git a/src/space/subtractive/cmyk.rs b/src/space/subtractive/cmyk.rs index 0c11c25..7822a52 100644 --- a/src/space/subtractive/cmyk.rs +++ b/src/space/subtractive/cmyk.rs @@ -32,6 +32,7 @@ pub struct Cmyk where S: RgbSpec, { + alpha: Component, context: ColorimetricContext, c: Component, k: Component, @@ -52,6 +53,7 @@ where k: impl Into, ) -> Self { Self { + alpha: Component::new(1.0), context: S::CONTEXT, c: c.into() / 100.0, k: k.into() / 100.0, @@ -64,6 +66,7 @@ where /// Creates a new CMYK color in a const context from cyan (0-100%), magenta (0-100%), yellow (0-100%), and key/black (0-100%). pub const fn new_const(c: f64, m: f64, y: f64, k: f64) -> Self { Self { + alpha: Component::new_const(1.0), context: S::CONTEXT, c: Component::new_const(c / 100.0), k: Component::new_const(k / 100.0), @@ -302,6 +305,7 @@ where ((nm * (1.0 - nk)) + nk) * 100.0, ((ny * (1.0 - nk)) + nk) * 100.0, ) + .with_alpha(self.alpha) } /// Converts this CMYK color to an [`Rgb`] color in the specified output space. @@ -320,6 +324,7 @@ where (1.0 - ny) * (1.0 - nk), ) .to_rgb::() + .with_alpha(self.alpha) } /// Returns this color with a different viewing context (without adaptation). @@ -581,10 +586,18 @@ impl ColorSpace<4> for Cmyk where S: RgbSpec, { + fn alpha(&self) -> f64 { + self.alpha.0 + } + fn components(&self) -> [f64; 4] { self.components() } + fn set_alpha(&mut self, alpha: impl Into) { + self.alpha = alpha.into().clamp(0.0, 1.0); + } + fn set_components(&mut self, components: [impl Into + Clone; 4]) { self.set_components(components) } @@ -599,15 +612,27 @@ where S: RgbSpec, { fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult { - write!( - f, - "CMYK({:.precision$}%, {:.precision$}%, {:.precision$}%, {:.precision$}%)", - self.cyan(), - self.magenta(), - self.yellow(), - self.key(), - precision = f.precision().unwrap_or(2) - ) + let precision = f.precision().unwrap_or(2); + if self.alpha.0 < 1.0 { + write!( + f, + "CMYK({:.precision$}%, {:.precision$}%, {:.precision$}%, {:.precision$}%, {:.0}%)", + self.cyan(), + self.magenta(), + self.yellow(), + self.key(), + self.opacity() + ) + } else { + write!( + f, + "CMYK({:.precision$}%, {:.precision$}%, {:.precision$}%, {:.precision$}%)", + self.cyan(), + self.magenta(), + self.yellow(), + self.key() + ) + } } } @@ -724,7 +749,7 @@ where { fn eq(&self, other: &T) -> bool { let other = (*other).into(); - self.c == other.c && self.m == other.m && self.y == other.y && self.k == other.k + self.alpha == other.alpha && self.c == other.c && self.m == other.m && self.y == other.y && self.k == other.k } } @@ -874,6 +899,20 @@ mod test { assert_eq!(format!("{:.4}", cmyk), "CMYK(25.6789%, 50.1234%, 75.4321%, 10.5678%)"); } + + #[test] + fn it_includes_opacity_when_alpha_below_one() { + let cmyk = Cmyk::::new(25.0, 50.0, 75.0, 10.0).with_alpha(0.5); + + assert_eq!(format!("{}", cmyk), "CMYK(25.00%, 50.00%, 75.00%, 10.00%, 50%)"); + } + + #[test] + fn it_omits_opacity_when_fully_opaque() { + let cmyk = Cmyk::::new(25.0, 50.0, 75.0, 10.0); + + assert_eq!(format!("{}", cmyk), "CMYK(25.00%, 50.00%, 75.00%, 10.00%)"); + } } mod div { @@ -1230,6 +1269,14 @@ mod test { assert_ne!(a, b); } + + #[test] + fn it_compares_unequal_when_alpha_differs() { + let a = Cmyk::::new(25.0, 50.0, 75.0, 10.0).with_alpha(0.5); + let b = Cmyk::::new(25.0, 50.0, 75.0, 10.0); + + assert_ne!(a, b); + } } mod scale_c { @@ -1427,6 +1474,14 @@ mod test { assert_eq!(rgb.green(), back_rgb.green()); assert_eq!(rgb.blue(), back_rgb.blue()); } + + #[test] + fn it_preserves_alpha() { + let cmyk = Cmyk::::new(25.0, 50.0, 75.0, 10.0).with_alpha(0.3); + let rgb: Rgb = cmyk.to_rgb(); + + assert!((rgb.alpha() - 0.3).abs() < 1e-10); + } } mod to_xyz {