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}