Skip to main content

style/color/
gamut.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//! Gamut mapping.
6//! <https://drafts.csswg.org/css-color-4/#gamut-mapping>
7
8use super::{AbsoluteColor, ColorSpace};
9
10/// Gamut-mapping min precision
11const MIN_PRECISION: f32 = 1.0 / (i32::MAX as f32);
12
13pub mod raytrace;
14
15impl AbsoluteColor {
16    /// 13.2.1. Binary Search Gamut Mapping with Local MINDE
17    /// <https://drafts.csswg.org/css-color-4/#GMA-Binary-local-MINDE>
18    pub fn gamut_map_binary_search(&self, dest_color_space: ColorSpace) -> Self {
19        const MIN_L: f32 = MIN_PRECISION;
20        const MAX_L: f32 = 1.0;
21
22        // 1. if destination has no gamut limits (XYZ-D65, XYZ-D50, Lab, LCH,
23        //    Oklab, OkLCh) convert origin to destination and return it as the
24        //    gamut mapped color
25        if matches!(
26            dest_color_space,
27            ColorSpace::Lab
28                | ColorSpace::Lch
29                | ColorSpace::Oklab
30                | ColorSpace::Oklch
31                | ColorSpace::XyzD50
32                | ColorSpace::XyzD65
33        ) {
34            return self.to_color_space(dest_color_space);
35        }
36
37        // 2. let origin_OkLCh be origin converted from origin color space to
38        //    the OkLCh color space
39        let origin_oklch = self.to_color_space(ColorSpace::Oklch);
40
41        // 3. if the Lightness of origin_OkLCh is greater than or equal to
42        //    100%, convert `oklab(1 0 0 / origin.alpha)` to destination and
43        //    return it as the gamut mapped color
44        if origin_oklch.components.0 >= MAX_L {
45            return AbsoluteColor::new(ColorSpace::Oklab, 1.0, 0.0, 0.0, self.alpha)
46                .to_color_space(dest_color_space);
47        }
48
49        // 4. if the Lightness of origin_OkLCh is less than than or equal to
50        //    0%, convert `oklab(0 0 0 / origin.alpha)` to destination and
51        //    return it as the gamut mapped color
52        if origin_oklch.components.0 <= MIN_L {
53            return AbsoluteColor::new(ColorSpace::Oklab, 0.0, 0.0, 0.0, self.alpha)
54                .to_color_space(dest_color_space);
55        }
56
57        // 5. let inGamut(color) be a function which returns true if, when
58        //    passed a color, that color is inside the gamut of destination.
59        //    For HSL and HWB, it returns true if the color is inside the
60        //    gamut of sRGB.
61        //    See [`Self::in_gamut`] and [`Self::in_gamut_for_dest_space`].
62
63        // 6. if inGamut(origin_OkLCh) is true, convert origin_OkLCh to
64        //    destination and return it as the gamut mapped color
65        if origin_oklch.in_gamut_for_dest_space(dest_color_space) {
66            return origin_oklch.to_color_space(dest_color_space);
67        }
68
69        // 7. otherwise, let delta(one, two) be a function which returns the
70        //    deltaEOK of color one compared to color two
71        //    See the [`delta_eok`] function.
72
73        // 8. let JND be 0.02
74        const JND: f32 = 0.02;
75
76        // 9. let epsilon be 0.0001
77        const EPSILON: f32 = 0.0001;
78
79        // 10. let clip(color) be a function which converts color to
80        //     destination, clamps each component to the bounds of the
81        //     reference range for that component, and returns the result.
82        //     See [`Self::clip`] and [`Self::clip_to_dest_space`].
83
84        // 11. set current to origin_OkLCh
85        let mut current_oklch = origin_oklch.clone();
86
87        // 12. set clipped to clip(current)
88        let mut clipped = current_oklch.clip_to_dest_space(dest_color_space);
89
90        // 13. set E to delta(clipped, current)
91        let mut e = delta_eok(&clipped, &current_oklch);
92
93        // 14. if E < JND, return clipped as the gamut mapped color
94        if e < JND {
95            return clipped;
96        }
97
98        // 15. set min to zero
99        let mut min = 0.0;
100
101        // 16. set max to the Oklch chroma of origin_Oklch.
102        let mut max = origin_oklch.components.1;
103
104        // 17. let min_inGamut be a boolean that represents when min is still
105        //     in gamut, and set it to true
106        let mut min_in_gamut = true;
107
108        // 18. while (max - min is greater than epsilon) repeat the following
109        //     steps.
110        while max - min > EPSILON {
111            // 18.1. set chroma to (min + max) / 2
112            let chroma = (min + max) / 2.0;
113
114            // 18.2. set the chroma component of current to chroma
115            current_oklch.components.1 = chroma;
116
117            // 18.3. if min_inGamut is true and also if inGamut(current) is
118            //       true, set min to chroma and continue to repeat these steps.
119            if min_in_gamut && current_oklch.in_gamut_for_dest_space(dest_color_space) {
120                min = chroma;
121                continue;
122            }
123
124            // 18.4. otherwise, if inGamut(current) is false carry out these
125            //       steps:
126            // 18.4.1. set clipped to clip(current)
127            clipped = current_oklch.clip_to_dest_space(dest_color_space);
128
129            // 18.4.2. set E to delta(clipped, current)
130            e = delta_eok(&clipped, &current_oklch);
131
132            // 18.4.3. if E < JND
133            if e < JND {
134                // 18.4.3.1. if (JND - E < epsilon), return clipped as the
135                //           gamut mapped color
136                if JND - e < EPSILON {
137                    return clipped;
138                }
139
140                // 18.4.3.2. otherwise:
141                // 18.4.3.2.1. set min_inGamut to false
142                min_in_gamut = false;
143
144                // 18.4.3.2.2. set min to chroma
145                min = chroma;
146            } else {
147                // 18.4.4. otherwise, set max to chroma and continue to repeat
148                //         these steps
149                max = chroma;
150            }
151        }
152
153        // 19. return clipped as the gamut mapped color
154        clipped
155    }
156
157    /// Clamp this color to within the [0..1] range.
158    /// NOTE this assumes RGB ranges and will not work for Lab, Oklab, or
159    ///      other color spaces with different ranges, or limitless ranges
160    fn clip(&self) -> Self {
161        let mut result = self.clone();
162        result.components = result.components.map(|c| c.clamp(0.0, 1.0));
163        result
164    }
165
166    /// ^10. let clip(color) be a function which converts color to destination,
167    ///      clamps each component to the bounds of the reference range for
168    ///      that component, and returns the result
169    /// Clip/clamp this color to the supplied destination color space
170    fn clip_to_dest_space(&self, dest_color_space: ColorSpace) -> Self {
171        self.to_color_space(dest_color_space).clip()
172    }
173
174    /// Returns true if this color is within its gamut limits.
175    fn in_gamut(&self) -> bool {
176        macro_rules! in_range {
177            ($c:expr) => {{
178                $c >= MIN_PRECISION && $c <= 1.0
179            }};
180        }
181
182        match self.color_space {
183            ColorSpace::Hsl | ColorSpace::Hwb => self.to_color_space(ColorSpace::Srgb).in_gamut(),
184            ColorSpace::Srgb
185            | ColorSpace::SrgbLinear
186            | ColorSpace::DisplayP3
187            | ColorSpace::DisplayP3Linear
188            | ColorSpace::A98Rgb
189            | ColorSpace::ProphotoRgb
190            | ColorSpace::Rec2020 => {
191                in_range!(self.components.0)
192                    && in_range!(self.components.1)
193                    && in_range!(self.components.2)
194            },
195            ColorSpace::Lab
196            | ColorSpace::Lch
197            | ColorSpace::Oklab
198            | ColorSpace::Oklch
199            | ColorSpace::XyzD50
200            | ColorSpace::XyzD65 => true,
201        }
202    }
203
204    /// ^5. let inGamut(color) be a function which returns true if, when passed
205    ///     a color, that color is inside the gamut of destination. For HSL and
206    ///     HWB, it returns true if the color is inside the gamut of sRGB.
207    /// Check if this color is in-gamut for the destination color space
208    fn in_gamut_for_dest_space(&self, dest_color_space: ColorSpace) -> bool {
209        if self.color_space == ColorSpace::Hsl || self.color_space == ColorSpace::Hwb {
210            self.to_color_space(ColorSpace::Srgb).in_gamut()
211        } else {
212            self.to_color_space(dest_color_space).in_gamut()
213        }
214    }
215}
216
217/// Calculate deltaE OK (simple root sum of squares).
218/// <https://drafts.csswg.org/css-color-4/#color-difference-OK>
219fn delta_eok(reference: &AbsoluteColor, sample: &AbsoluteColor) -> f32 {
220    // Delta is calculated in the oklab color space.
221    let reference = reference.to_color_space(ColorSpace::Oklab);
222    let sample = sample.to_color_space(ColorSpace::Oklab);
223
224    let diff = reference.components - sample.components;
225    let diff = diff * diff;
226    (diff.0 + diff.1 + diff.2).sqrt()
227}