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