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(_: &ParserContext, input: &mut Parser) -> Result<Self, ParseError> {
113        input.expect_ident_matching("in")?;
114        let space = ColorSpace::parse(input)?;
115        // https://drafts.csswg.org/css-color-4/#hue-interpolation
116        //     Unless otherwise specified, if no specific hue interpolation
117        //     algorithm is selected by the host syntax, the default is shorter.
118        let hue = if space.is_polar() {
119            input
120                .try_parse(|input| -> Result<_, ParseError> {
121                    let hue = HueInterpolationMethod::parse(input)?;
122                    input.expect_ident_matching("hue")?;
123                    Ok(hue)
124                })
125                .unwrap_or(HueInterpolationMethod::Shorter)
126        } else {
127            HueInterpolationMethod::Shorter
128        };
129        Ok(Self { space, hue })
130    }
131}
132
133impl ToCss for ColorInterpolationMethod {
134    fn to_css<W>(&self, dest: &mut CssWriter<W>) -> fmt::Result
135    where
136        W: Write,
137    {
138        dest.write_str("in ")?;
139        self.space.to_css(dest)?;
140        if self.hue != HueInterpolationMethod::Shorter {
141            dest.write_char(' ')?;
142            self.hue.to_css(dest)?;
143            dest.write_str(" hue")?;
144        }
145        Ok(())
146    }
147}
148
149/// A color and its weight for use in a color mix.
150pub struct ColorMixItem {
151    /// The color being mixed.
152    pub color: AbsoluteColor,
153    /// How much this color contributes to the final mix.
154    pub weight: f32,
155}
156
157impl ColorMixItem {
158    /// Create a new color item for mixing.
159    #[inline]
160    pub fn new(color: AbsoluteColor, weight: f32) -> Self {
161        Self { color, weight }
162    }
163}
164
165/// Mix N colors into one (left-to-right fold).
166pub fn mix_many(
167    interpolation: ColorInterpolationMethod,
168    items: impl IntoIterator<Item = ColorMixItem>,
169    flags: ColorMixFlags,
170) -> AbsoluteColor {
171    let items = items.into_iter().collect::<ColorMixItemList<_>>();
172
173    // Convert a mix result into the syntax required by the flags:
174    // - modern syntax when requested (converting legacy spaces like hsl/hwb to srgb), otherwise;
175    // - legacy srgb when every mixed color used legacy syntax.
176    let finalize = |result: AbsoluteColor| -> AbsoluteColor {
177        if flags.contains(ColorMixFlags::RESULT_IN_MODERN_SYNTAX) {
178            if result.is_legacy_syntax() {
179                // If the result *MUST* be in modern syntax, then make sure it is in a color space
180                // that allows the modern syntax. So hsl and hwb will be converted to srgb.
181                let mut srgb = result.to_color_space(ColorSpace::Srgb);
182                srgb.flags.remove(ColorFlags::IS_LEGACY_SRGB);
183                srgb
184            } else {
185                result
186            }
187        } else if items.iter().all(|item| item.color.is_legacy_syntax()) {
188            result.into_srgb_legacy()
189        } else {
190            result
191        }
192    };
193
194    // Match the behavior when the sum of weights equal 0.
195    if items.is_empty() {
196        return finalize(AbsoluteColor::TRANSPARENT_BLACK.to_color_space(interpolation.space));
197    }
198
199    let normalize = flags.contains(ColorMixFlags::NORMALIZE_WEIGHTS);
200    let mut weight_scale = 1.0;
201    let mut alpha_multiplier = 1.0;
202    if normalize {
203        // https://drafts.csswg.org/css-color-5/#color-mix-percent-norm
204        let sum: f32 = items.iter().map(|item| item.weight).sum();
205        if sum == 0.0 {
206            return finalize(AbsoluteColor::TRANSPARENT_BLACK.to_color_space(interpolation.space));
207        }
208        if (sum - 1.0).abs() > f32::EPSILON {
209            weight_scale = 1.0 / sum;
210            if sum < 1.0 {
211                alpha_multiplier = sum;
212            }
213        }
214    }
215
216    // We can unwrap here, because we already checked for no items.
217    let (first, rest) = items.split_first().unwrap();
218    let mut accumulated_color = convert_for_mix(&first.color, interpolation.space);
219    let mut accumulated_weight = first.weight * weight_scale;
220
221    for item in rest {
222        let weight = item.weight * weight_scale;
223        let combined = accumulated_weight + weight;
224        if combined == 0.0 {
225            // If both are 0, this fold doesn't contribute anything to the result.
226            continue;
227        }
228        let right = convert_for_mix(&item.color, interpolation.space);
229
230        let (left_weight, right_weight) = if normalize {
231            (accumulated_weight / combined, weight / combined)
232        } else {
233            (accumulated_weight, weight)
234        };
235
236        accumulated_color = mix_with_weights(
237            &accumulated_color,
238            left_weight,
239            &right,
240            right_weight,
241            interpolation.hue,
242        );
243        accumulated_weight = combined;
244    }
245
246    let components = accumulated_color.raw_components();
247    let alpha = components[3] * alpha_multiplier;
248
249    // FIXME: In rare cases we end up with 0.999995 in the alpha channel,
250    //        so we reduce the precision to avoid serializing to
251    //        rgba(?, ?, ?, 1).  This is not ideal, so we should look into
252    //        ways to avoid it. Maybe pre-multiply all color components and
253    //        then divide after calculations?
254    let alpha = (alpha.clamp(0.0, 1.0) * 1000.0).round() / 1000.0;
255
256    let mut result = AbsoluteColor::new(
257        interpolation.space,
258        components[0],
259        components[1],
260        components[2],
261        alpha,
262    );
263    result.flags = accumulated_color.flags;
264
265    finalize(result)
266}
267
268/// What the outcome of each component should be in a mix result.
269#[derive(Clone, Copy, PartialEq)]
270#[repr(u8)]
271enum ComponentMixOutcome {
272    /// Mix the left and right sides to give the result.
273    Mix,
274    /// Carry the left side forward to the result.
275    UseLeft,
276    /// Carry the right side forward to the result.
277    UseRight,
278    /// The resulting component should also be none.
279    None,
280}
281
282impl ComponentMixOutcome {
283    fn from_colors(
284        left: &AbsoluteColor,
285        right: &AbsoluteColor,
286        flags_to_check: ColorFlags,
287    ) -> Self {
288        match (
289            left.flags.contains(flags_to_check),
290            right.flags.contains(flags_to_check),
291        ) {
292            (true, true) => Self::None,
293            (true, false) => Self::UseRight,
294            (false, true) => Self::UseLeft,
295            (false, false) => Self::Mix,
296        }
297    }
298}
299
300impl AbsoluteColor {
301    /// Calculate the flags that should be carried forward a color before converting
302    /// it to the interpolation color space according to:
303    /// <https://drafts.csswg.org/css-color-4/#interpolation-missing>
304    fn carry_forward_analogous_missing_components(&mut self, source: &AbsoluteColor) {
305        use ColorFlags as F;
306        use ColorSpace as S;
307
308        if source.color_space == self.color_space {
309            return;
310        }
311
312        // Reds             r, x
313        // Greens           g, y
314        // Blues            b, z
315        if source.color_space.is_rgb_or_xyz_like() && self.color_space.is_rgb_or_xyz_like() {
316            return;
317        }
318
319        // Lightness        L
320        if matches!(source.color_space, S::Lab | S::Lch | S::Oklab | S::Oklch) {
321            if matches!(self.color_space, S::Lab | S::Lch | S::Oklab | S::Oklch) {
322                self.flags |= source.flags & F::C0_IS_NONE;
323            } else if matches!(self.color_space, S::Hsl) && source.flags.contains(F::C0_IS_NONE) {
324                self.flags.insert(F::C2_IS_NONE)
325            }
326        } else if matches!(source.color_space, S::Hsl)
327            && matches!(self.color_space, S::Lab | S::Lch | S::Oklab | S::Oklch)
328            && source.flags.contains(F::C2_IS_NONE)
329        {
330            self.flags.insert(F::C0_IS_NONE)
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                && source.flags.contains(F::C0_IS_NONE)
346            {
347                self.flags.insert(F::C2_IS_NONE)
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}