color/dynamic.rs
1// Copyright 2024 the Color Authors
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! CSS colors and syntax.
5
6use crate::{
7 cache_key::{BitEq, BitHash},
8 color::{add_alpha, fixup_hues_for_interpolate, split_alpha, InterpolationAlphaSpace},
9 AlphaColor, Chromaticity, ColorSpace, ColorSpaceLayout, ColorSpaceTag, Flags, HueDirection,
10 LinearSrgb, Missing,
11};
12use core::hash::{Hash, Hasher};
13
14/// A color with a [color space tag] decided at runtime.
15///
16/// This type is roughly equivalent to [`AlphaColor`] except with a tag
17/// for color space as opposed being determined at compile time. It can
18/// also represent missing components, which are a feature of the CSS
19/// Color 4 spec.
20///
21/// Missing components are mostly useful for interpolation, and in that
22/// context take the value of the other color being interpolated. For
23/// example, interpolating a color in [Oklch] with `oklch(none 0 none)`
24/// fades the color saturation, ending in a gray with the same lightness.
25///
26/// In other contexts, missing colors are interpreted as a zero value.
27/// When manipulating components directly, setting them nonzero when the
28/// corresponding missing flag is set may yield unexpected results.
29///
30/// [color space tag]: ColorSpaceTag
31/// [Oklch]: crate::Oklch
32#[derive(Clone, Copy, Debug)]
33#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
34pub struct DynamicColor {
35 /// The color space.
36 pub cs: ColorSpaceTag,
37 /// The state of this color, tracking whether it has missing components and how it was
38 /// constructed. See the documentation of [`Flags`] for more information.
39 pub flags: Flags,
40 /// The components.
41 ///
42 /// The first three components are interpreted according to the
43 /// color space tag. The fourth component is alpha, interpreted
44 /// as separate alpha.
45 pub components: [f32; 4],
46}
47
48/// An intermediate struct used for interpolating between colors.
49///
50/// This is the return value of [`DynamicColor::interpolate`].
51#[derive(Clone, Copy)]
52#[expect(
53 missing_debug_implementations,
54 reason = "it's an intermediate struct, only used for eval"
55)]
56pub struct Interpolator {
57 color1: [f32; 3],
58 alpha1: f32,
59 delta_color: [f32; 3],
60 delta_alpha: f32,
61 cs: ColorSpaceTag,
62 missing: Missing,
63}
64
65/// An intermediate struct used for interpolating between colors.
66///
67/// This is the return value of [`DynamicColor::interpolate_unpremultiplied`].
68#[derive(Clone, Copy)]
69#[expect(
70 missing_debug_implementations,
71 reason = "it's an intermediate struct, only used for eval"
72)]
73pub struct UnpremultipliedInterpolator {
74 color1: [f32; 3],
75 alpha1: f32,
76 delta_color: [f32; 3],
77 delta_alpha: f32,
78 cs: ColorSpaceTag,
79 missing: Missing,
80}
81
82impl DynamicColor {
83 /// Convert to `AlphaColor` with a static color space.
84 ///
85 /// Missing components are interpreted as 0.
86 #[must_use]
87 pub fn to_alpha_color<CS: ColorSpace>(self) -> AlphaColor<CS> {
88 if let Some(cs) = CS::TAG {
89 AlphaColor::new(self.convert(cs).components)
90 } else {
91 self.to_alpha_color::<LinearSrgb>().convert()
92 }
93 }
94
95 /// Convert from `AlphaColor`.
96 #[must_use]
97 pub fn from_alpha_color<CS: ColorSpace>(color: AlphaColor<CS>) -> Self {
98 if let Some(cs) = CS::TAG {
99 Self {
100 cs,
101 flags: Flags::default(),
102 components: color.components,
103 }
104 } else {
105 Self::from_alpha_color(color.convert::<LinearSrgb>())
106 }
107 }
108
109 /// The const-generic parameter `ABSOLUTE` indicates whether the conversion performs chromatic
110 /// adaptation. When `ABSOLUTE` is `true`, no chromatic adaptation is performed.
111 fn convert_impl<const ABSOLUTE: bool>(self, cs: ColorSpaceTag) -> Self {
112 if self.cs == cs {
113 // Note: §12 suggests that changing powerless to missing happens
114 // even when the color is already in the interpolation color space,
115 // but Chrome and color.js don't seem do to that.
116 self
117 } else {
118 let (opaque, alpha) = split_alpha(self.components);
119 let mut components = if ABSOLUTE {
120 add_alpha(self.cs.convert_absolute(cs, opaque), alpha)
121 } else {
122 add_alpha(self.cs.convert(cs, opaque), alpha)
123 };
124 // Reference: §12.2 of Color 4 spec
125 let missing = if !self.flags.missing().is_empty() {
126 if self.cs.same_analogous(cs) {
127 for (i, component) in components.iter_mut().enumerate() {
128 if self.flags.missing().contains(i) {
129 *component = 0.0;
130 }
131 }
132 self.flags.missing()
133 } else {
134 let mut missing = self.flags.missing() & Missing::single(3);
135 if self.cs.h_missing(self.flags.missing()) {
136 cs.set_h_missing(&mut missing, &mut components);
137 }
138 if self.cs.c_missing(self.flags.missing()) {
139 cs.set_c_missing(&mut missing, &mut components);
140 }
141 if self.cs.l_missing(self.flags.missing()) {
142 cs.set_l_missing(&mut missing, &mut components);
143 }
144 missing
145 }
146 } else {
147 Missing::default()
148 };
149 let mut result = Self {
150 cs,
151 flags: Flags::from_missing(missing),
152 components,
153 };
154 result.powerless_to_missing();
155 result
156 }
157 }
158
159 #[must_use]
160 /// Convert to a different color space.
161 pub fn convert(self, cs: ColorSpaceTag) -> Self {
162 self.convert_impl::<false>(cs)
163 }
164
165 #[must_use]
166 /// Convert to a different color space, without chromatic adaptation.
167 ///
168 /// For most use-cases you should consider using the chromatically-adapting
169 /// [`DynamicColor::convert`] instead. See the documentation on
170 /// [`ColorSpace::convert_absolute`] for more information.
171 pub fn convert_absolute(self, cs: ColorSpaceTag) -> Self {
172 self.convert_impl::<true>(cs)
173 }
174
175 #[must_use]
176 /// Chromatically adapt the color between the given white point chromaticities.
177 ///
178 /// The color is assumed to be under a reference white point of `from` and is chromatically
179 /// adapted to the given white point `to`. The linear Bradford transform is used to perform the
180 /// chromatic adaptation.
181 pub fn chromatically_adapt(self, from: Chromaticity, to: Chromaticity) -> Self {
182 if from == to {
183 return self;
184 }
185
186 // Treat missing components as zero, as per CSS Color Module Level 4 § 4.4.
187 let (opaque, alpha) = split_alpha(self.zero_missing_components().components);
188 let components = add_alpha(self.cs.chromatically_adapt(opaque, from, to), alpha);
189 Self {
190 cs: self.cs,
191 // After chromatically adapting the color, components may no longer be missing. Don't
192 // forward the flags.
193 flags: Flags::default(),
194 components,
195 }
196 }
197
198 /// Set any missing components to zero.
199 ///
200 /// We have a soft invariant that any bit set in the missing bitflag has
201 /// a corresponding component which is 0. This method restores that
202 /// invariant after manipulation which might invalidate it.
203 fn zero_missing_components(mut self) -> Self {
204 if !self.flags.missing().is_empty() {
205 for (i, component) in self.components.iter_mut().enumerate() {
206 if self.flags.missing().contains(i) {
207 *component = 0.0;
208 }
209 }
210 }
211 self
212 }
213
214 /// Multiply alpha by the given factor.
215 ///
216 /// If the alpha channel is missing, then the new alpha channel
217 /// will be ignored and the color returned unchanged.
218 #[must_use]
219 pub const fn multiply_alpha(self, rhs: f32) -> Self {
220 if self.flags.missing().contains(3) {
221 self
222 } else {
223 let (opaque, alpha) = split_alpha(self.components);
224 Self {
225 cs: self.cs,
226 flags: Flags::from_missing(self.flags.missing()),
227 components: add_alpha(opaque, alpha * rhs),
228 }
229 }
230 }
231
232 /// Set the alpha channel.
233 ///
234 /// This replaces the existing alpha channel. To scale or
235 /// or otherwise modify the existing alpha channel, use
236 /// [`DynamicColor::multiply_alpha`] or [`DynamicColor::map`].
237 ///
238 /// If the alpha channel is missing, then the new alpha channel
239 /// will be ignored and the color returned unchanged.
240 ///
241 /// ```
242 /// # use color::{parse_color, Srgb};
243 /// let c = parse_color("lavenderblush").unwrap().with_alpha(0.7);
244 /// assert_eq!(0.7, c.to_alpha_color::<Srgb>().split().1);
245 /// ```
246 #[must_use]
247 pub const fn with_alpha(self, alpha: f32) -> Self {
248 if self.flags.missing().contains(3) {
249 self
250 } else {
251 let (opaque, _alpha) = split_alpha(self.components);
252 Self {
253 cs: self.cs,
254 flags: Flags::from_missing(self.flags.missing()),
255 components: add_alpha(opaque, alpha),
256 }
257 }
258 }
259
260 /// Scale the chroma by the given amount.
261 ///
262 /// See [`ColorSpace::scale_chroma`] for more details.
263 #[must_use]
264 pub fn scale_chroma(self, scale: f32) -> Self {
265 let (opaque, alpha) = split_alpha(self.components);
266 let components = self.cs.scale_chroma(opaque, scale);
267
268 let mut flags = self.flags;
269 flags.discard_name();
270 Self {
271 cs: self.cs,
272 flags,
273 components: add_alpha(components, alpha),
274 }
275 .zero_missing_components()
276 }
277
278 /// Clip the color's components to fit within the natural gamut of the color space, and clamp
279 /// the color's alpha to be in the range `[0, 1]`.
280 ///
281 /// See [`ColorSpace::clip`] for more details.
282 #[must_use]
283 pub fn clip(self) -> Self {
284 let (opaque, alpha) = split_alpha(self.components);
285 let components = self.cs.clip(opaque);
286 let alpha = alpha.clamp(0., 1.);
287 Self {
288 cs: self.cs,
289 flags: self.flags,
290 components: add_alpha(components, alpha),
291 }
292 }
293
294 fn split(self, alpha_type: InterpolationAlphaSpace) -> ([f32; 3], f32) {
295 // Reference: §12.3 of Color 4 spec
296 let (opaque, alpha) = split_alpha(self.components);
297 let color = if alpha == 1.0
298 || self.flags.missing().contains(3)
299 || alpha_type.is_unpremultiplied()
300 {
301 opaque
302 } else {
303 self.cs.layout().scale(opaque, alpha)
304 };
305 (color, alpha)
306 }
307
308 fn powerless_to_missing(&mut self) {
309 // Note: the spec seems vague on the details of what this should do,
310 // and there is some controversy in discussion threads. For example,
311 // in Lab-like spaces, if L is 0 do the other components become powerless?
312
313 // Note: we use hard-coded epsilons to check for approximate equality here, but these do
314 // not account for the normal value range of components. It might be somewhat more correct
315 // to, e.g., consider `0.000_01` approximately equal to `0` for a component with the
316 // natural range `0-100`, but not for a component with the natural range `0-0.5`.
317
318 match self.cs {
319 // See CSS Color Module level 4 § 7, § 9.3, and § 9.4 (HSL, LCH, Oklch).
320 ColorSpaceTag::Hsl | ColorSpaceTag::Lch | ColorSpaceTag::Oklch
321 if self.components[1] < 1e-6 =>
322 {
323 let mut missing = self.flags.missing();
324 self.cs.set_h_missing(&mut missing, &mut self.components);
325 self.flags.set_missing(missing);
326 }
327
328 // See CSS Color Module level 4 § 8 (HWB).
329 ColorSpaceTag::Hwb if self.components[1] + self.components[2] > 100. - 1e-4 => {
330 let mut missing = self.flags.missing();
331 self.cs.set_h_missing(&mut missing, &mut self.components);
332 self.flags.set_missing(missing);
333 }
334 _ => {}
335 }
336 }
337
338 /// Interpolate two colors.
339 ///
340 /// The colors are interpolated linearly from `self` to `other` in the color space given by
341 /// `cs`. When interpolating in a cylindrical color space, the hue can be interpolated in
342 /// multiple ways. The [`direction`](`HueDirection`) parameter controls the way in which the
343 /// hue is interpolated.
344 ///
345 /// The interpolation proceeds according to [CSS Color Module Level 4 § 12][css-sec].
346 ///
347 /// This method does a bunch of precomputation, resulting in an [`Interpolator`] object that
348 /// can be evaluated at various `t` values.
349 ///
350 /// [css-sec]: https://www.w3.org/TR/css-color-4/#interpolation
351 ///
352 /// # Example
353 ///
354 /// ```rust
355 /// use color::{AlphaColor, ColorSpaceTag, DynamicColor, HueDirection, Srgb};
356 ///
357 /// let start = DynamicColor::from_alpha_color(AlphaColor::<Srgb>::new([1., 0., 0., 1.]));
358 /// let end = DynamicColor::from_alpha_color(AlphaColor::<Srgb>::new([0., 1., 0., 1.]));
359 ///
360 /// let interp = start.interpolate(end, ColorSpaceTag::Hsl, HueDirection::Increasing);
361 /// let mid = interp.eval(0.5);
362 /// assert_eq!(mid.cs, ColorSpaceTag::Hsl);
363 /// assert!((mid.components[0] - 60.).abs() < 0.01);
364 /// ```
365 pub fn interpolate(
366 self,
367 other: Self,
368 cs: ColorSpaceTag,
369 direction: HueDirection,
370 ) -> Interpolator {
371 let mut a = self.convert(cs);
372 let mut b = other.convert(cs);
373 let a_missing = a.flags.missing();
374 let b_missing = b.flags.missing();
375 let missing = a_missing & b_missing;
376 if a_missing != b_missing {
377 for i in 0..4 {
378 if (a_missing & !b_missing).contains(i) {
379 a.components[i] = b.components[i];
380 } else if (!a_missing & b_missing).contains(i) {
381 b.components[i] = a.components[i];
382 }
383 }
384 }
385 let (color1, alpha1) = a.split(InterpolationAlphaSpace::Premultiplied);
386 let (mut color2, alpha2) = b.split(InterpolationAlphaSpace::Premultiplied);
387 fixup_hues_for_interpolate(color1, &mut color2, cs.layout(), direction);
388 let delta_color = [
389 color2[0] - color1[0],
390 color2[1] - color1[1],
391 color2[2] - color1[2],
392 ];
393 Interpolator {
394 color1,
395 alpha1,
396 delta_color,
397 delta_alpha: alpha2 - alpha1,
398 cs,
399 missing,
400 }
401 }
402
403 /// Interpolate two colors without alpha premultiplication.
404 ///
405 /// Similar to [`DynamicColor::interpolate`], but colors are interpolated without premultiplying
406 /// their color channels by the alpha channel. This is almost never what you want.
407 ///
408 /// This causes color information to leak out of transparent colors. For example, when
409 /// interpolating from a fully transparent red to a fully opaque blue in sRGB, this
410 /// method will go through an intermediate purple.
411 ///
412 /// This matches behavior of gradients in the HTML `canvas` element.
413 /// See [The 2D rendering context § Fill and stroke styles][HTML 2D Canvas] of the
414 /// HTML 2D Canvas specification.
415 ///
416 /// [HTML 2D Canvas]: https://html.spec.whatwg.org/multipage/#interpolation
417 /// The colors are interpolated linearly from `self` to `other` in the color space given by
418 /// `cs`. When interpolating in a cylindrical color space, the hue can be interpolated in
419 /// multiple ways. The [`direction`](`HueDirection`) parameter controls the way in which the
420 /// hue is interpolated.
421 ///
422 /// The interpolation proceeds according to [CSS Color Module Level 4 § 12][css-sec].
423 ///
424 /// This method does a bunch of precomputation, resulting in an [`UnpremultipliedInterpolator`] object that
425 /// can be evaluated at various `t` values.
426 ///
427 /// [css-sec]: https://www.w3.org/TR/css-color-4/#interpolation
428 ///
429 /// # Example
430 ///
431 /// ```rust
432 /// use color::{AlphaColor, ColorSpaceTag, DynamicColor, HueDirection, Srgb};
433 ///
434 /// let start = DynamicColor::from_alpha_color(AlphaColor::<Srgb>::new([1., 0., 0., 1.]));
435 /// let end = DynamicColor::from_alpha_color(AlphaColor::<Srgb>::new([0., 1., 0., 1.]));
436 ///
437 /// let interp = start.interpolate_unpremultiplied(end, ColorSpaceTag::Hsl, HueDirection::Increasing);
438 /// let mid = interp.eval(0.5);
439 /// assert_eq!(mid.cs, ColorSpaceTag::Hsl);
440 /// assert!((mid.components[0] - 60.).abs() < 0.01);
441 /// ```
442 pub fn interpolate_unpremultiplied(
443 self,
444 other: Self,
445 cs: ColorSpaceTag,
446 direction: HueDirection,
447 ) -> UnpremultipliedInterpolator {
448 let interpolation_alpha_space = InterpolationAlphaSpace::Unpremultiplied;
449 let mut a = self.convert(cs);
450 let mut b = other.convert(cs);
451 let a_missing = a.flags.missing();
452 let b_missing = b.flags.missing();
453 let missing = a_missing & b_missing;
454 if a_missing != b_missing {
455 for i in 0..4 {
456 if (a_missing & !b_missing).contains(i) {
457 a.components[i] = b.components[i];
458 } else if (!a_missing & b_missing).contains(i) {
459 b.components[i] = a.components[i];
460 }
461 }
462 }
463 let (color1, alpha1) = a.split(interpolation_alpha_space);
464 let (mut color2, alpha2) = b.split(interpolation_alpha_space);
465 fixup_hues_for_interpolate(color1, &mut color2, cs.layout(), direction);
466 let delta_color = [
467 color2[0] - color1[0],
468 color2[1] - color1[1],
469 color2[2] - color1[2],
470 ];
471 UnpremultipliedInterpolator {
472 color1,
473 alpha1,
474 delta_color,
475 delta_alpha: alpha2 - alpha1,
476 cs,
477 missing,
478 }
479 }
480
481 /// Compute the relative luminance of the color.
482 ///
483 /// This can be useful for choosing contrasting colors, and follows the
484 /// [WCAG 2.1 spec].
485 ///
486 /// Note that this method only considers the opaque color, not the alpha.
487 /// Blending semi-transparent colors will reduce contrast, and that
488 /// should also be taken into account.
489 ///
490 /// [WCAG 2.1 spec]: https://www.w3.org/TR/WCAG21/#dfn-relative-luminance
491 #[must_use]
492 pub fn relative_luminance(self) -> f32 {
493 let [r, g, b, _] = self.convert(ColorSpaceTag::LinearSrgb).components;
494 0.2126 * r + 0.7152 * g + 0.0722 * b
495 }
496
497 /// Map components.
498 #[must_use]
499 pub fn map(self, f: impl Fn(f32, f32, f32, f32) -> [f32; 4]) -> Self {
500 let [x, y, z, a] = self.components;
501
502 let mut flags = self.flags;
503 flags.discard_name();
504 Self {
505 cs: self.cs,
506 flags,
507 components: f(x, y, z, a),
508 }
509 .zero_missing_components()
510 }
511
512 /// Map components in a given color space.
513 #[must_use]
514 pub fn map_in(self, cs: ColorSpaceTag, f: impl Fn(f32, f32, f32, f32) -> [f32; 4]) -> Self {
515 self.convert(cs).map(f).convert(self.cs)
516 }
517
518 /// Map the lightness of the color.
519 ///
520 /// In a color space that naturally has a lightness component, map that value.
521 /// Otherwise, do the mapping in [Oklab]. The lightness range is normalized so
522 /// that 1.0 is white. That is the normal range for Oklab but differs from the
523 /// range in [Lab], [Lch], and [Hsl].
524 ///
525 /// [Oklab]: crate::Oklab
526 /// [Lab]: crate::Lab
527 /// [Lch]: crate::Lch
528 /// [Hsl]: crate::Hsl
529 #[must_use]
530 pub fn map_lightness(self, f: impl Fn(f32) -> f32) -> Self {
531 match self.cs {
532 ColorSpaceTag::Lab | ColorSpaceTag::Lch => {
533 self.map(|l, c1, c2, a| [100.0 * f(l * 0.01), c1, c2, a])
534 }
535 ColorSpaceTag::Oklab | ColorSpaceTag::Oklch => {
536 self.map(|l, c1, c2, a| [f(l), c1, c2, a])
537 }
538 ColorSpaceTag::Hsl => self.map(|h, s, l, a| [h, s, 100.0 * f(l * 0.01), a]),
539 _ => self.map_in(ColorSpaceTag::Oklab, |l, a, b, alpha| [f(l), a, b, alpha]),
540 }
541 }
542
543 /// Map the hue of the color.
544 ///
545 /// In a color space that naturally has a hue component, map that value.
546 /// Otherwise, do the mapping in [Oklch]. The hue is in degrees.
547 ///
548 /// [Oklch]: crate::Oklch
549 #[must_use]
550 pub fn map_hue(self, f: impl Fn(f32) -> f32) -> Self {
551 match self.cs.layout() {
552 ColorSpaceLayout::HueFirst => self.map(|h, c1, c2, a| [f(h), c1, c2, a]),
553 ColorSpaceLayout::HueThird => self.map(|c0, c1, h, a| [c0, c1, f(h), a]),
554 _ => self.map_in(ColorSpaceTag::Oklch, |l, c, h, a| [l, c, f(h), a]),
555 }
556 }
557}
558
559impl PartialEq for DynamicColor {
560 /// Equality is not perceptual, but requires the component values to be equal.
561 ///
562 /// See also [`CacheKey`](crate::cache_key::CacheKey).
563 fn eq(&self, other: &Self) -> bool {
564 // Same as the derive implementation, but we want a doc comment.
565 self.cs == other.cs && self.flags == other.flags && self.components == other.components
566 }
567}
568
569impl BitEq for DynamicColor {
570 fn bit_eq(&self, other: &Self) -> bool {
571 self.cs == other.cs
572 && self.flags == other.flags
573 && self.components.bit_eq(&other.components)
574 }
575}
576
577impl BitHash for DynamicColor {
578 fn bit_hash<H: Hasher>(&self, state: &mut H) {
579 self.cs.hash(state);
580 self.flags.hash(state);
581 self.components.bit_hash(state);
582 }
583}
584
585/// Note that the conversion is only lossless for color spaces that have a corresponding [tag](ColorSpaceTag).
586/// This is why we have this additional trait bound. See also
587/// <https://github.com/linebender/color/pull/155> for more discussion.
588impl<CS: ColorSpace> From<AlphaColor<CS>> for DynamicColor
589where
590 ColorSpaceTag: From<CS>,
591{
592 fn from(value: AlphaColor<CS>) -> Self {
593 const {
594 assert!(
595 CS::TAG.is_some(),
596 "this trait can only be implemented for colors with a tag"
597 );
598 }
599
600 Self::from_alpha_color(value)
601 }
602}
603
604impl Interpolator {
605 /// Evaluate the color ramp at the given point.
606 ///
607 /// Typically `t` ranges between 0 and 1, but that is not enforced,
608 /// so extrapolation is also possible.
609 pub fn eval(&self, t: f32) -> DynamicColor {
610 let color = [
611 self.color1[0] + t * self.delta_color[0],
612 self.color1[1] + t * self.delta_color[1],
613 self.color1[2] + t * self.delta_color[2],
614 ];
615 let alpha = self.alpha1 + t * self.delta_alpha;
616 let opaque = if alpha == 0.0 || alpha == 1.0 {
617 color
618 } else {
619 self.cs.layout().scale(color, 1.0 / alpha)
620 };
621 let components = add_alpha(opaque, alpha);
622 DynamicColor {
623 cs: self.cs,
624 flags: Flags::from_missing(self.missing),
625 components,
626 }
627 }
628}
629
630impl UnpremultipliedInterpolator {
631 /// Evaluate the color ramp at the given point.
632 ///
633 /// Typically `t` ranges between 0 and 1, but that is not enforced,
634 /// so extrapolation is also possible.
635 pub fn eval(&self, t: f32) -> DynamicColor {
636 let color = [
637 self.color1[0] + t * self.delta_color[0],
638 self.color1[1] + t * self.delta_color[1],
639 self.color1[2] + t * self.delta_color[2],
640 ];
641 let alpha = self.alpha1 + t * self.delta_alpha;
642 let components = add_alpha(color, alpha);
643 DynamicColor {
644 cs: self.cs,
645 flags: Flags::from_missing(self.missing),
646 components,
647 }
648 }
649}
650
651#[cfg(test)]
652mod tests {
653 use crate::{parse_color, ColorSpaceTag, DynamicColor, Missing};
654
655 // `DynamicColor` was carefully packed. Ensure its size doesn't accidentally change.
656 const _: () = if size_of::<DynamicColor>() != 20 {
657 panic!("`DynamicColor` size changed");
658 };
659
660 #[test]
661 fn missing_alpha() {
662 let c = parse_color("oklab(0.5 0.2 0 / none)").unwrap();
663 assert_eq!(0., c.components[3]);
664 assert_eq!(Missing::single(3), c.flags.missing());
665
666 // Alpha is missing, so we shouldn't be able to get an alpha added.
667 let c2 = c.with_alpha(0.5);
668 assert_eq!(0., c2.components[3]);
669 assert_eq!(Missing::single(3), c2.flags.missing());
670
671 let c3 = c.multiply_alpha(0.2);
672 assert_eq!(0., c3.components[3]);
673 assert_eq!(Missing::single(3), c3.flags.missing());
674 }
675
676 #[test]
677 fn preserves_rgb_missingness() {
678 let c = parse_color("color(srgb 0.5 none 0)").unwrap();
679 assert_eq!(
680 c.convert(ColorSpaceTag::XyzD65).flags.missing(),
681 Missing::single(1)
682 );
683 }
684
685 #[test]
686 fn drops_missingness_when_not_analogous() {
687 let c = parse_color("oklab(none 0.2 -0.3)").unwrap();
688 assert!(c.convert(ColorSpaceTag::Srgb).flags.missing().is_empty());
689 }
690
691 #[test]
692 fn preserves_hue_missingness() {
693 let c = parse_color("oklch(0.2 0.3 none)").unwrap();
694 assert_eq!(
695 c.convert(ColorSpaceTag::Hsl).flags.missing(),
696 Missing::single(0)
697 );
698 }
699
700 #[test]
701 fn preserves_lightness_missingness() {
702 let c = parse_color("oklab(none 0.2 -0.3)").unwrap();
703 assert_eq!(
704 c.convert(ColorSpaceTag::Hsl).flags.missing(),
705 Missing::single(2)
706 );
707 }
708
709 #[test]
710 fn preserves_saturation_missingness() {
711 let c = parse_color("oklch(0.2 none 240)").unwrap();
712 assert_eq!(c.flags.missing(), Missing::single(1));
713
714 // As saturation is missing, it is effectively 0, meaning the color is achromatic and hue
715 // is powerless. § 4.4.1 says hue must be set missing after conversion.
716 assert_eq!(
717 c.convert(ColorSpaceTag::Hsl).flags.missing(),
718 Missing::single(0) | Missing::single(1)
719 );
720 }
721
722 #[test]
723 fn achromatic_sets_hue_powerless() {
724 let c = parse_color("oklab(0.2 0 0)").unwrap();
725
726 // As the color is achromatic, the hue is powerless. § 4.4.1 says hue must be set missing
727 // after conversion.
728 assert_eq!(
729 c.convert(ColorSpaceTag::Hsl).flags.missing(),
730 Missing::single(0)
731 );
732 }
733
734 #[test]
735 fn powerless_components() {
736 static COLORS_AND_POWERLESS: &[(&str, &[usize])] = &[
737 // Grayscale HWB results in powerless hue...
738 ("hwb(240 80 20)", &[0]),
739 ("hwb(240 79.9999999 19.9999999)", &[0]),
740 // ... also if the grayscale is specified out of gamut...
741 ("hwb(240 120 200)", &[0]),
742 // ... but near-grayscale HWB does not result in powerless hue...
743 ("hwb(240 79.99 20)", &[]),
744 // ... and colorful colors don't either.
745 ("hwb(240 20 15)", &[]),
746 // Unsaturated hue-saturation-lightness-like colors result in powerless hue...
747 ("hsl(240 0 50)", &[0]),
748 ("hsl(240 0.0000001 50)", &[0]),
749 // ... also if the saturation is negative...
750 ("hsl(240 -0.2 50)", &[0]),
751 // ... but near-unsaturated hue-saturation-lightness-like colors do not result
752 // in powerless hue...
753 ("hsl(240 0.01 50)", &[]),
754 // ... and colorful colors don't either.
755 ("hsl(240 0.6 50)", &[]),
756 // In lab-like spaces, zero lightness does not (currently) result in powerless
757 // components.
758 ("lab(0 0.4 -0.3)", &[]),
759 ("oklab(0 0.4 -0.3)", &[]),
760 // sRGB (and in other rectangular spaces) never have powerless components.
761 ("color(srgb 0 0 0)", &[]),
762 ("color(srgb 1 1 1)", &[]),
763 ("color(srgb 500 -200 20)", &[]),
764 ];
765
766 for (color, powerless) in COLORS_AND_POWERLESS {
767 let mut c = parse_color(color).unwrap();
768 c.powerless_to_missing();
769 for idx in *powerless {
770 assert!(
771 c.flags.missing().contains(*idx),
772 "Expected color `{color}` to have the following powerless components: {powerless:?}"
773 );
774 }
775 }
776 }
777
778 #[test]
779 fn premultiplied_rectangular_interpolation() {
780 use crate::HueDirection;
781
782 // This interpolates in a rectangular color space from a fully transparent color to a fully
783 // opaque color (with premultiplied color channels). Only the fully opaque color should be
784 // contributing color information.
785 let start = parse_color("oklab(0.5 0.2 -0.1 / 0.0)").unwrap();
786 let end = parse_color("oklab(0.3 0.1 0.1 / 1.0)").unwrap();
787 let interp = start.interpolate(end, ColorSpaceTag::Oklab, HueDirection::Increasing);
788 let mid = interp.eval(0.5);
789
790 assert!((mid.components[0] - 0.3).abs() < 1e-4);
791 assert!((mid.components[1] - 0.1).abs() < 1e-4);
792 assert!((mid.components[2] - 0.1).abs() < 1e-4);
793 assert!((mid.components[3] - 0.5).abs() < 1e-4);
794 }
795
796 #[test]
797 fn unpremultiplied_rectangular_interpolation() {
798 use crate::HueDirection;
799
800 // This interpolates in a rectangular color space from a fully transparent color to a fully
801 // opaque color (with unpremultiplied color channels). Both colors should be contributing
802 // color information.
803 let start = parse_color("oklab(0.5 0.2 -0.1 / 0.0)").unwrap();
804 let end = parse_color("oklab(0.3 0.1 0.1 / 1.0)").unwrap();
805 let interp =
806 start.interpolate_unpremultiplied(end, ColorSpaceTag::Oklab, HueDirection::Increasing);
807 let mid = interp.eval(0.5);
808
809 assert!((mid.components[0] - 0.4).abs() < 1e-4);
810 assert!((mid.components[1] - 0.15).abs() < 1e-4);
811 assert!((mid.components[2] - 0.0).abs() < 1e-4);
812 assert!((mid.components[3] - 0.5).abs() < 1e-4);
813 }
814
815 #[test]
816 fn premultiplied_cylindrical_interpolation() {
817 use crate::HueDirection;
818
819 // This interpolates in a cylandrical color space from a fully transparent color to a fully
820 // opaque color (with premultiplied color channels). The hue is not premultiplied, see
821 // [`crate::PremulColor`]. For the premultiplied channels, only the fully opaque color
822 // should be contributing color information.
823 let start = parse_color("oklch(0.5 0.2 100 / 0.0)").unwrap();
824 let end = parse_color("oklch(0.3 0.1 200 / 1.0)").unwrap();
825 let interp = start.interpolate(end, ColorSpaceTag::Oklch, HueDirection::Increasing);
826 let mid = interp.eval(0.5);
827
828 assert!((mid.components[0] - 0.3).abs() < 1e-4);
829 assert!((mid.components[1] - 0.1).abs() < 1e-4);
830 assert!((mid.components[2] - 150.).abs() < 1e-4);
831 assert!((mid.components[3] - 0.5).abs() < 1e-4);
832 }
833
834 #[test]
835 fn unpremultiplied_cylindrical_interpolation() {
836 use crate::HueDirection;
837
838 // This interpolates in a cylindrical color space from a fully transparent color to a fully
839 // opaque color (with unpremultiplied color channels). Both color should be contributing
840 // color information.
841 let start = parse_color("oklch(0.5 0.2 100 / 0.0)").unwrap();
842 let end = parse_color("oklch(0.3 0.1 200 / 1.0)").unwrap();
843 let interp =
844 start.interpolate_unpremultiplied(end, ColorSpaceTag::Oklch, HueDirection::Increasing);
845 let mid = interp.eval(0.5);
846
847 assert!((mid.components[0] - 0.4).abs() < 1e-4);
848 assert!((mid.components[1] - 0.15).abs() < 1e-4);
849 assert!((mid.components[2] - 150.).abs() < 1e-4);
850 assert!((mid.components[3] - 0.5).abs() < 1e-4);
851 }
852}