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, ¤t_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, ¤t_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}