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