Skip to main content

style/color/
mix.rs

1/* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
5//! Color mixing/interpolation.
6
7use super::{AbsoluteColor, ColorFlags, ColorSpace};
8use crate::color::ColorMixItemList;
9use crate::derives::*;
10use crate::parser::{Parse, ParserContext};
11use crate::values::generics::color::ColorMixFlags;
12use cssparser::Parser;
13use std::fmt::{self, Write};
14use style_traits::{CssWriter, ParseError, ToCss};
15
16/// A hue-interpolation-method as defined in [1].
17///
18/// [1]: https://drafts.csswg.org/css-color-4/#typedef-hue-interpolation-method
19#[derive(
20    Clone,
21    Copy,
22    Debug,
23    Eq,
24    MallocSizeOf,
25    Parse,
26    PartialEq,
27    ToAnimatedValue,
28    ToComputedValue,
29    ToCss,
30    ToResolvedValue,
31    ToShmem,
32)]
33#[repr(u8)]
34pub enum HueInterpolationMethod {
35    /// https://drafts.csswg.org/css-color-4/#shorter
36    Shorter,
37    /// https://drafts.csswg.org/css-color-4/#longer
38    Longer,
39    /// https://drafts.csswg.org/css-color-4/#increasing
40    Increasing,
41    /// https://drafts.csswg.org/css-color-4/#decreasing
42    Decreasing,
43    /// https://drafts.csswg.org/css-color-4/#specified
44    Specified,
45}
46
47/// https://drafts.csswg.org/css-color-4/#color-interpolation-method
48#[derive(
49    Clone,
50    Copy,
51    Debug,
52    Eq,
53    MallocSizeOf,
54    PartialEq,
55    ToShmem,
56    ToAnimatedValue,
57    ToComputedValue,
58    ToResolvedValue,
59)]
60#[repr(C)]
61pub struct ColorInterpolationMethod {
62    /// The color-space the interpolation should be done in.
63    pub space: ColorSpace,
64    /// The hue interpolation method.
65    pub hue: HueInterpolationMethod,
66}
67
68impl ColorInterpolationMethod {
69    /// Returns the srgb interpolation method.
70    pub const fn srgb() -> Self {
71        Self {
72            space: ColorSpace::Srgb,
73            hue: HueInterpolationMethod::Shorter,
74        }
75    }
76
77    /// Return the oklab interpolation method used for default color
78    /// interpolcation.
79    pub const fn oklab() -> Self {
80        Self {
81            space: ColorSpace::Oklab,
82            hue: HueInterpolationMethod::Shorter,
83        }
84    }
85
86    /// Return true if the this is the default method.
87    pub fn is_default(&self) -> bool {
88        self.space == ColorSpace::Oklab
89    }
90
91    /// Decides the best method for interpolating between the given colors.
92    /// https://drafts.csswg.org/css-color-4/#interpolation-space
93    pub fn best_interpolation_between(left: &AbsoluteColor, right: &AbsoluteColor) -> Self {
94        // The default color space to use for interpolation is Oklab. However,
95        // if either of the colors are in legacy rgb(), hsl() or hwb(), then
96        // interpolation is done in sRGB.
97        if !left.is_legacy_syntax() || !right.is_legacy_syntax() {
98            Self::default()
99        } else {
100            Self::srgb()
101        }
102    }
103}
104
105impl Default for ColorInterpolationMethod {
106    fn default() -> Self {
107        Self::oklab()
108    }
109}
110
111impl Parse for ColorInterpolationMethod {
112    fn parse<'i, 't>(
113        _: &ParserContext,
114        input: &mut Parser<'i, 't>,
115    ) -> Result<Self, ParseError<'i>> {
116        input.expect_ident_matching("in")?;
117        let space = ColorSpace::parse(input)?;
118        // https://drafts.csswg.org/css-color-4/#hue-interpolation
119        //     Unless otherwise specified, if no specific hue interpolation
120        //     algorithm is selected by the host syntax, the default is shorter.
121        let hue = if space.is_polar() {
122            input
123                .try_parse(|input| -> Result<_, ParseError<'i>> {
124                    let hue = HueInterpolationMethod::parse(input)?;
125                    input.expect_ident_matching("hue")?;
126                    Ok(hue)
127                })
128                .unwrap_or(HueInterpolationMethod::Shorter)
129        } else {
130            HueInterpolationMethod::Shorter
131        };
132        Ok(Self { space, hue })
133    }
134}
135
136impl ToCss for ColorInterpolationMethod {
137    fn to_css<W>(&self, dest: &mut CssWriter<W>) -> fmt::Result
138    where
139        W: Write,
140    {
141        dest.write_str("in ")?;
142        self.space.to_css(dest)?;
143        if self.hue != HueInterpolationMethod::Shorter {
144            dest.write_char(' ')?;
145            self.hue.to_css(dest)?;
146            dest.write_str(" hue")?;
147        }
148        Ok(())
149    }
150}
151
152/// A color and its weight for use in a color mix.
153pub struct ColorMixItem {
154    /// The color being mixed.
155    pub color: AbsoluteColor,
156    /// How much this color contributes to the final mix.
157    pub weight: f32,
158}
159
160impl ColorMixItem {
161    /// Create a new color item for mixing.
162    #[inline]
163    pub fn new(color: AbsoluteColor, weight: f32) -> Self {
164        Self { color, weight }
165    }
166}
167
168/// Mix N colors into one (left-to-right fold).
169pub fn mix_many(
170    interpolation: ColorInterpolationMethod,
171    items: impl IntoIterator<Item = ColorMixItem>,
172    flags: ColorMixFlags,
173) -> AbsoluteColor {
174    let items = items.into_iter().collect::<ColorMixItemList<_>>();
175
176    // Match the behavior when the sum of weights equal 0.
177    if items.is_empty() {
178        return AbsoluteColor::TRANSPARENT_BLACK.to_color_space(interpolation.space);
179    }
180
181    let normalize = flags.contains(ColorMixFlags::NORMALIZE_WEIGHTS);
182    let mut weight_scale = 1.0;
183    let mut alpha_multiplier = 1.0;
184    if normalize {
185        // https://drafts.csswg.org/css-color-5/#color-mix-percent-norm
186        let sum: f32 = items.iter().map(|item| item.weight).sum();
187        if sum == 0.0 {
188            return AbsoluteColor::TRANSPARENT_BLACK.to_color_space(interpolation.space);
189        }
190        if (sum - 1.0).abs() > f32::EPSILON {
191            weight_scale = 1.0 / sum;
192            if sum < 1.0 {
193                alpha_multiplier = sum;
194            }
195        }
196    }
197
198    // We can unwrap here, because we already checked for no items.
199    let (first, rest) = items.split_first().unwrap();
200    let mut accumulated_color = convert_for_mix(&first.color, interpolation.space);
201    let mut accumulated_weight = first.weight * weight_scale;
202
203    for item in rest {
204        let weight = item.weight * weight_scale;
205        let combined = accumulated_weight + weight;
206        if combined == 0.0 {
207            // If both are 0, this fold doesn't contribute anything to the result.
208            continue;
209        }
210        let right = convert_for_mix(&item.color, interpolation.space);
211
212        let (left_weight, right_weight) = if normalize {
213            (accumulated_weight / combined, weight / combined)
214        } else {
215            (accumulated_weight, weight)
216        };
217
218        accumulated_color = mix_with_weights(
219            &accumulated_color,
220            left_weight,
221            &right,
222            right_weight,
223            interpolation.hue,
224        );
225        accumulated_weight = combined;
226    }
227
228    let components = accumulated_color.raw_components();
229    let alpha = components[3] * alpha_multiplier;
230
231    // FIXME: In rare cases we end up with 0.999995 in the alpha channel,
232    //        so we reduce the precision to avoid serializing to
233    //        rgba(?, ?, ?, 1).  This is not ideal, so we should look into
234    //        ways to avoid it. Maybe pre-multiply all color components and
235    //        then divide after calculations?
236    let alpha = (alpha.clamp(0.0, 1.0) * 1000.0).round() / 1000.0;
237
238    let mut result = AbsoluteColor::new(
239        interpolation.space,
240        components[0],
241        components[1],
242        components[2],
243        alpha,
244    );
245    result.flags = accumulated_color.flags;
246
247    if flags.contains(ColorMixFlags::RESULT_IN_MODERN_SYNTAX) {
248        // If the result *MUST* be in modern syntax, then make sure it is in a
249        // color space that allows the modern syntax. So hsl and hwb will be
250        // converted to srgb.
251        if result.is_legacy_syntax() {
252            result.to_color_space(ColorSpace::Srgb)
253        } else {
254            result
255        }
256    } else if items.iter().all(|item| item.color.is_legacy_syntax()) {
257        // If both sides of the mix is legacy then convert the result back into
258        // legacy.
259        result.into_srgb_legacy()
260    } else {
261        result
262    }
263}
264
265/// What the outcome of each component should be in a mix result.
266#[derive(Clone, Copy, PartialEq)]
267#[repr(u8)]
268enum ComponentMixOutcome {
269    /// Mix the left and right sides to give the result.
270    Mix,
271    /// Carry the left side forward to the result.
272    UseLeft,
273    /// Carry the right side forward to the result.
274    UseRight,
275    /// The resulting component should also be none.
276    None,
277}
278
279impl ComponentMixOutcome {
280    fn from_colors(
281        left: &AbsoluteColor,
282        right: &AbsoluteColor,
283        flags_to_check: ColorFlags,
284    ) -> Self {
285        match (
286            left.flags.contains(flags_to_check),
287            right.flags.contains(flags_to_check),
288        ) {
289            (true, true) => Self::None,
290            (true, false) => Self::UseRight,
291            (false, true) => Self::UseLeft,
292            (false, false) => Self::Mix,
293        }
294    }
295}
296
297impl AbsoluteColor {
298    /// Calculate the flags that should be carried forward a color before converting
299    /// it to the interpolation color space according to:
300    /// <https://drafts.csswg.org/css-color-4/#interpolation-missing>
301    fn carry_forward_analogous_missing_components(&mut self, source: &AbsoluteColor) {
302        use ColorFlags as F;
303        use ColorSpace as S;
304
305        if source.color_space == self.color_space {
306            return;
307        }
308
309        // Reds             r, x
310        // Greens           g, y
311        // Blues            b, z
312        if source.color_space.is_rgb_or_xyz_like() && self.color_space.is_rgb_or_xyz_like() {
313            return;
314        }
315
316        // Lightness        L
317        if matches!(source.color_space, S::Lab | S::Lch | S::Oklab | S::Oklch) {
318            if matches!(self.color_space, S::Lab | S::Lch | S::Oklab | S::Oklch) {
319                self.flags |= source.flags & F::C0_IS_NONE;
320            } else if matches!(self.color_space, S::Hsl) {
321                if source.flags.contains(F::C0_IS_NONE) {
322                    self.flags.insert(F::C2_IS_NONE)
323                }
324            }
325        } else if matches!(source.color_space, S::Hsl)
326            && matches!(self.color_space, S::Lab | S::Lch | S::Oklab | S::Oklch)
327        {
328            if source.flags.contains(F::C2_IS_NONE) {
329                self.flags.insert(F::C0_IS_NONE)
330            }
331        }
332
333        // Colorfulness     C, S
334        if matches!(source.color_space, S::Hsl | S::Lch | S::Oklch)
335            && matches!(self.color_space, S::Hsl | S::Lch | S::Oklch)
336        {
337            self.flags |= source.flags & F::C1_IS_NONE;
338        }
339
340        // Hue              H
341        if matches!(source.color_space, S::Hsl | S::Hwb) {
342            if matches!(self.color_space, S::Hsl | S::Hwb) {
343                self.flags |= source.flags & F::C0_IS_NONE;
344            } else if matches!(self.color_space, S::Lch | S::Oklch) {
345                if source.flags.contains(F::C0_IS_NONE) {
346                    self.flags.insert(F::C2_IS_NONE)
347                }
348            }
349        } else if matches!(source.color_space, S::Lch | S::Oklch) {
350            if matches!(self.color_space, S::Hsl | S::Hwb) {
351                if source.flags.contains(F::C2_IS_NONE) {
352                    self.flags.insert(F::C0_IS_NONE)
353                }
354            } else if matches!(self.color_space, S::Lch | S::Oklch) {
355                self.flags |= source.flags & F::C2_IS_NONE;
356            }
357        }
358
359        // Opponent         a, a
360        // Opponent         b, b
361        if matches!(source.color_space, S::Lab | S::Oklab)
362            && matches!(self.color_space, S::Lab | S::Oklab)
363        {
364            self.flags |= source.flags & F::C1_IS_NONE;
365            self.flags |= source.flags & F::C2_IS_NONE;
366        }
367    }
368}
369
370/// Mix two colors already in the interpolation color space.
371fn mix_with_weights(
372    left: &AbsoluteColor,
373    left_weight: f32,
374    right: &AbsoluteColor,
375    right_weight: f32,
376    hue_interpolation: HueInterpolationMethod,
377) -> AbsoluteColor {
378    debug_assert!(right.color_space == left.color_space);
379    let color_space = left.color_space;
380
381    let outcomes = [
382        ComponentMixOutcome::from_colors(&left, &right, ColorFlags::C0_IS_NONE),
383        ComponentMixOutcome::from_colors(&left, &right, ColorFlags::C1_IS_NONE),
384        ComponentMixOutcome::from_colors(&left, &right, ColorFlags::C2_IS_NONE),
385        ComponentMixOutcome::from_colors(&left, &right, ColorFlags::ALPHA_IS_NONE),
386    ];
387
388    // Convert both sides into just components.
389    let left = left.raw_components();
390    let right = right.raw_components();
391
392    let (result, result_flags) = interpolate_premultiplied(
393        &left,
394        left_weight,
395        &right,
396        right_weight,
397        color_space.hue_index(),
398        hue_interpolation,
399        &outcomes,
400    );
401
402    let mut result = AbsoluteColor::new(color_space, result[0], result[1], result[2], result[3]);
403    result.flags = result_flags;
404    result
405}
406
407fn convert_for_mix(color: &AbsoluteColor, color_space: ColorSpace) -> AbsoluteColor {
408    let mut converted = color.to_color_space(color_space);
409    converted.carry_forward_analogous_missing_components(color);
410    converted
411}
412
413fn interpolate_premultiplied_component(
414    left: f32,
415    left_weight: f32,
416    left_alpha: f32,
417    right: f32,
418    right_weight: f32,
419    right_alpha: f32,
420) -> f32 {
421    left * left_weight * left_alpha + right * right_weight * right_alpha
422}
423
424// Normalize hue into [0, 360)
425#[inline]
426fn normalize_hue(v: f32) -> f32 {
427    v - 360. * (v / 360.).floor()
428}
429
430fn adjust_hue(left: &mut f32, right: &mut f32, hue_interpolation: HueInterpolationMethod) {
431    // Adjust the hue angle as per
432    // https://drafts.csswg.org/css-color/#hue-interpolation.
433    //
434    // If both hue angles are NAN, they should be set to 0. Otherwise, if a
435    // single hue angle is NAN, it should use the other hue angle.
436    if left.is_nan() {
437        if right.is_nan() {
438            *left = 0.;
439            *right = 0.;
440        } else {
441            *left = *right;
442        }
443    } else if right.is_nan() {
444        *right = *left;
445    }
446
447    if hue_interpolation == HueInterpolationMethod::Specified {
448        // Angles are not adjusted. They are interpolated like any other
449        // component.
450        return;
451    }
452
453    *left = normalize_hue(*left);
454    *right = normalize_hue(*right);
455
456    match hue_interpolation {
457        // https://drafts.csswg.org/css-color/#shorter
458        HueInterpolationMethod::Shorter => {
459            let delta = *right - *left;
460
461            if delta > 180. {
462                *left += 360.;
463            } else if delta < -180. {
464                *right += 360.;
465            }
466        },
467        // https://drafts.csswg.org/css-color/#longer
468        HueInterpolationMethod::Longer => {
469            let delta = *right - *left;
470            if 0. < delta && delta < 180. {
471                *left += 360.;
472            } else if -180. < delta && delta <= 0. {
473                *right += 360.;
474            }
475        },
476        // https://drafts.csswg.org/css-color/#increasing
477        HueInterpolationMethod::Increasing => {
478            if *right < *left {
479                *right += 360.;
480            }
481        },
482        // https://drafts.csswg.org/css-color/#decreasing
483        HueInterpolationMethod::Decreasing => {
484            if *left < *right {
485                *left += 360.;
486            }
487        },
488        HueInterpolationMethod::Specified => unreachable!("Handled above"),
489    }
490}
491
492fn interpolate_hue(
493    mut left: f32,
494    left_weight: f32,
495    mut right: f32,
496    right_weight: f32,
497    hue_interpolation: HueInterpolationMethod,
498) -> f32 {
499    adjust_hue(&mut left, &mut right, hue_interpolation);
500    left * left_weight + right * right_weight
501}
502
503struct InterpolatedAlpha {
504    /// The adjusted left alpha value.
505    left: f32,
506    /// The adjusted right alpha value.
507    right: f32,
508    /// The interpolated alpha value.
509    interpolated: f32,
510    /// Whether the alpha component should be `none`.
511    is_none: bool,
512}
513
514fn interpolate_alpha(
515    left: f32,
516    left_weight: f32,
517    right: f32,
518    right_weight: f32,
519    outcome: ComponentMixOutcome,
520) -> InterpolatedAlpha {
521    // <https://drafts.csswg.org/css-color-4/#interpolation-missing>
522    let mut result = match outcome {
523        ComponentMixOutcome::Mix => {
524            let interpolated = left * left_weight + right * right_weight;
525            InterpolatedAlpha {
526                left,
527                right,
528                interpolated,
529                is_none: false,
530            }
531        },
532        ComponentMixOutcome::UseLeft => InterpolatedAlpha {
533            left,
534            right: left,
535            interpolated: left,
536            is_none: false,
537        },
538        ComponentMixOutcome::UseRight => InterpolatedAlpha {
539            left: right,
540            right,
541            interpolated: right,
542            is_none: false,
543        },
544        ComponentMixOutcome::None => InterpolatedAlpha {
545            left: 1.0,
546            right: 1.0,
547            interpolated: 0.0,
548            is_none: true,
549        },
550    };
551
552    // Clip all alpha values to [0.0..1.0].
553    result.left = result.left.clamp(0.0, 1.0);
554    result.right = result.right.clamp(0.0, 1.0);
555    result.interpolated = result.interpolated.clamp(0.0, 1.0);
556
557    result
558}
559
560fn interpolate_premultiplied(
561    left: &[f32; 4],
562    left_weight: f32,
563    right: &[f32; 4],
564    right_weight: f32,
565    hue_index: Option<usize>,
566    hue_interpolation: HueInterpolationMethod,
567    outcomes: &[ComponentMixOutcome; 4],
568) -> ([f32; 4], ColorFlags) {
569    let alpha = interpolate_alpha(left[3], left_weight, right[3], right_weight, outcomes[3]);
570    let mut flags = if alpha.is_none {
571        ColorFlags::ALPHA_IS_NONE
572    } else {
573        ColorFlags::empty()
574    };
575
576    let mut result = [0.; 4];
577
578    for i in 0..3 {
579        match outcomes[i] {
580            ComponentMixOutcome::Mix => {
581                let is_hue = hue_index == Some(i);
582                result[i] = if is_hue {
583                    normalize_hue(interpolate_hue(
584                        left[i],
585                        left_weight,
586                        right[i],
587                        right_weight,
588                        hue_interpolation,
589                    ))
590                } else {
591                    let interpolated = interpolate_premultiplied_component(
592                        left[i],
593                        left_weight,
594                        alpha.left,
595                        right[i],
596                        right_weight,
597                        alpha.right,
598                    );
599
600                    if alpha.interpolated == 0.0 {
601                        interpolated
602                    } else {
603                        interpolated / alpha.interpolated
604                    }
605                };
606            },
607            ComponentMixOutcome::UseLeft | ComponentMixOutcome::UseRight => {
608                let used_component = if outcomes[i] == ComponentMixOutcome::UseLeft {
609                    left[i]
610                } else {
611                    right[i]
612                };
613                result[i] = if hue_interpolation == HueInterpolationMethod::Longer
614                    && hue_index == Some(i)
615                {
616                    // If "longer hue" interpolation is required, we have to actually do
617                    // the computation even if we're using the same value at both ends,
618                    // so that interpolating from the starting hue back to the same value
619                    // produces a full cycle, rather than a constant hue.
620                    normalize_hue(interpolate_hue(
621                        used_component,
622                        left_weight,
623                        used_component,
624                        right_weight,
625                        hue_interpolation,
626                    ))
627                } else {
628                    used_component
629                };
630            },
631            ComponentMixOutcome::None => {
632                result[i] = 0.0;
633                match i {
634                    0 => flags.insert(ColorFlags::C0_IS_NONE),
635                    1 => flags.insert(ColorFlags::C1_IS_NONE),
636                    2 => flags.insert(ColorFlags::C2_IS_NONE),
637                    _ => unreachable!(),
638                }
639            },
640        }
641    }
642    result[3] = alpha.interpolated;
643
644    (result, flags)
645}