Skip to main content

xgpui_color/
lib.rs

1//! CPU-only, unclipped color conversions. Named RGB spaces and XyzD65 use D65.
2//! Custom D50 colorants can be explicitly adapted to D65.
3//! Matrices are row-major and multiply column vectors. Alpha is not involved.
4#![doc = include_str!("../README.md")]
5
6use std::fmt;
7mod adaptation;
8#[cfg(feature = "icc")]
9pub mod icc;
10pub use adaptation::{BradfordAdaptation, WhitePoint};
11mod definition;
12pub use definition::{ParametricCurve, RgbCurve, RgbDefinition, SampledCurve};
13mod gradient;
14mod mapping;
15pub use gradient::{ColorInterpolation, HueInterpolation, LinearGradient, LinearRgba};
16pub use mapping::GamutMapping;
17
18/// Invalid input or a conversion whose result cannot be represented as finite f64.
19#[derive(Clone, Copy, Debug, PartialEq, Eq)]
20pub struct ColorError;
21impl fmt::Display for ColorError {
22    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23        f.write_str("invalid color input or non-finite conversion result")
24    }
25}
26impl std::error::Error for ColorError {}
27type Result<T> = std::result::Result<T, ColorError>;
28fn finite(v: [f64; 3]) -> Result<[f64; 3]> {
29    if v.iter().all(|v| v.is_finite()) {
30        Ok(v)
31    } else {
32        Err(ColorError)
33    }
34}
35
36/// SDR RGB primaries and transfer function. Display P3 uses the sRGB transfer.
37#[derive(Clone, Copy, Debug, PartialEq, Eq)]
38pub enum RgbSpace {
39    Srgb,
40    DisplayP3,
41    Rec2020,
42    /// Adobe RGB (1998), D65, with the 563/256 decoding exponent.
43    AdobeRgb1998,
44}
45impl RgbSpace {
46    /// Linear RGB to relative XYZ/D65 (Y = 1 at reference white).
47    /// Constants follow CSS Color 4 sample conversions (rational primary matrices).
48    pub fn linear_to_xyz_matrix(self) -> [[f64; 3]; 3] {
49        match self {
50            Self::Srgb => [
51                [506752. / 1228815., 87881. / 245763., 12673. / 70218.],
52                [87098. / 409605., 175762. / 245763., 12673. / 175545.],
53                [7918. / 409605., 87881. / 737289., 1001167. / 1053270.],
54            ],
55            Self::DisplayP3 => [
56                [608311. / 1250200., 189793. / 714400., 198249. / 1000160.],
57                [35783. / 156275., 247089. / 357200., 198249. / 2500400.],
58                [0., 32229. / 714400., 5220557. / 5000800.],
59            ],
60            Self::AdobeRgb1998 => [
61                [573536. / 994567., 263643. / 1420810., 187206. / 994567.],
62                [591459. / 1989134., 6239551. / 9945670., 374412. / 4972835.],
63                [53769. / 1989134., 351524. / 4972835., 4929758. / 4972835.],
64            ],
65            Self::Rec2020 => [
66                [
67                    63426534. / 99577255.,
68                    20160776. / 139408157.,
69                    47086771. / 278816314.,
70                ],
71                [
72                    26158966. / 99577255.,
73                    472592308. / 697040785.,
74                    8267143. / 139408157.,
75                ],
76                [0., 19567812. / 697040785., 295819943. / 278816314.],
77            ],
78        }
79    }
80    /// Relative XYZ/D65 to linear RGB, without clipping or adaptation.
81    pub fn xyz_to_linear_matrix(self) -> [[f64; 3]; 3] {
82        inverse(self.linear_to_xyz_matrix())
83    }
84    /// Linear RGB primary conversion only; does not decode encoded RGB.
85    pub fn linear_to_linear_matrix(self, destination: Self) -> [[f64; 3]; 3] {
86        if self == destination {
87            return [[1., 0., 0.], [0., 1., 0.], [0., 0., 1.]];
88        }
89        let a = destination.xyz_to_linear_matrix();
90        let b = self.linear_to_xyz_matrix();
91        std::array::from_fn(|i| std::array::from_fn(|j| (0..3).map(|k| a[i][k] * b[k][j]).sum()))
92    }
93    fn transfer(self, value: f64, decode: bool) -> f64 {
94        let x = value.abs();
95        let y = match (self, decode) {
96            (Self::AdobeRgb1998, true) => x.powf(563. / 256.),
97            (Self::AdobeRgb1998, false) => x.powf(256. / 563.),
98            (Self::Rec2020, true) => {
99                let a = 1.09929682680944;
100                if x < 0.018053968510807 * 4.5 {
101                    x / 4.5
102                } else {
103                    ((x + a - 1.) / a).powf(1. / 0.45)
104                }
105            }
106            (Self::Rec2020, false) => {
107                if x < 0.018053968510807 {
108                    4.5 * x
109                } else {
110                    1.09929682680944 * x.powf(0.45) - 0.09929682680944
111                }
112            }
113            (_, true) => {
114                if x <= 0.04045 {
115                    x / 12.92
116                } else {
117                    ((x + 0.055) / 1.055).powf(2.4)
118                }
119            }
120            (_, false) => {
121                if x <= 0.0031308 {
122                    12.92 * x
123                } else {
124                    1.055 * x.powf(1. / 2.4) - 0.055
125                }
126            }
127        };
128        y.copysign(value)
129    }
130}
131fn mul(m: [[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] {
132    m.map(|r| r[0] * v[0] + r[1] * v[1] + r[2] * v[2])
133}
134const fn inverse(m: [[f64; 3]; 3]) -> [[f64; 3]; 3] {
135    let [[a, b, c], [d, e, f], [g, h, i]] = m;
136    let det = a * (e * i - f * h) - b * (d * i - f * g) + c * (d * h - e * g);
137    [
138        [
139            (e * i - f * h) / det,
140            (c * h - b * i) / det,
141            (b * f - c * e) / det,
142        ],
143        [
144            (f * g - d * i) / det,
145            (a * i - c * g) / det,
146            (c * d - a * f) / det,
147        ],
148        [
149            (d * h - e * g) / det,
150            (b * g - a * h) / det,
151            (a * e - b * d) / det,
152        ],
153    ]
154}
155
156// Björn Ottosson's published OkLab matrices (10 decimals). The published
157// inverses are not exact inverses of these rounded values, which made the
158// OkLab round trip drift by about 1e-7, so the inverses are derived here.
159/// Linear sRGB to LMS.
160const OKLAB_M1: [[f64; 3]; 3] = [
161    [0.4122214708, 0.5363325363, 0.0514459929],
162    [0.2119034982, 0.6806995451, 0.1073969566],
163    [0.0883024619, 0.2817188376, 0.6299787005],
164];
165/// Cube-root LMS to OkLab.
166const OKLAB_M2: [[f64; 3]; 3] = [
167    [0.2104542553, 0.7936177850, -0.0040720468],
168    [1.9779984951, -2.4285922050, 0.4505937099],
169    [0.0259040371, 0.7827717662, -0.8086757660],
170];
171const OKLAB_M1_INV: [[f64; 3]; 3] = inverse(OKLAB_M1);
172const OKLAB_M2_INV: [[f64; 3]; 3] = inverse(OKLAB_M2);
173
174macro_rules! rgb {
175    ($name:ident, $doc:literal) => {
176        #[doc = $doc]
177        #[derive(Clone, Copy, Debug, PartialEq)]
178        pub struct $name {
179            components: [f64; 3],
180            space: RgbSpace,
181        }
182        impl $name {
183            pub fn new(space: RgbSpace, components: [f64; 3]) -> Result<Self> {
184                Ok(Self {
185                    space,
186                    components: finite(components)?,
187                })
188            }
189            pub fn components(self) -> [f64; 3] {
190                self.components
191            }
192            pub fn space(self) -> RgbSpace {
193                self.space
194            }
195        }
196    };
197}
198rgb!(
199    EncodedRgb,
200    "Finite encoded RGB, possibly outside [0,1]; no implicit gamut mapping."
201);
202rgb!(
203    LinearRgb,
204    "Finite linear RGB, possibly outside [0,1]; no implicit gamut mapping."
205);
206impl EncodedRgb {
207    /// Sign-preserving extended transfer decoding, including negative channels.
208    pub fn decode(self) -> Result<LinearRgb> {
209        LinearRgb::new(
210            self.space,
211            self.components.map(|v| self.space.transfer(v, true)),
212        )
213    }
214}
215impl LinearRgb {
216    pub fn encode(self) -> Result<EncodedRgb> {
217        EncodedRgb::new(
218            self.space,
219            self.components.map(|v| self.space.transfer(v, false)),
220        )
221    }
222    pub fn convert(self, destination: RgbSpace) -> Result<Self> {
223        Self::new(
224            destination,
225            mul(
226                self.space.linear_to_linear_matrix(destination),
227                self.components,
228            ),
229        )
230    }
231    pub fn to_xyz(self) -> Result<XyzD65> {
232        XyzD65::new(mul(self.space.linear_to_xyz_matrix(), self.components))
233    }
234    pub fn to_oklab(self) -> Result<Oklab> {
235        let lms = mul(OKLAB_M1, self.convert(RgbSpace::Srgb)?.components);
236        Oklab::new(mul(OKLAB_M2, lms.map(f64::cbrt)))
237    }
238}
239/// Relative XYZ with D65 white; components are unclipped.
240#[derive(Clone, Copy, Debug, PartialEq)]
241pub struct XyzD65([f64; 3]);
242impl XyzD65 {
243    pub fn new(v: [f64; 3]) -> Result<Self> {
244        Ok(Self(finite(v)?))
245    }
246    pub fn components(self) -> [f64; 3] {
247        self.0
248    }
249    pub fn to_linear(self, space: RgbSpace) -> Result<LinearRgb> {
250        LinearRgb::new(space, mul(space.xyz_to_linear_matrix(), self.0))
251    }
252}
253/// OkLab [L, a, b], with nominal SDR L in 0..1; extended coordinates are retained.
254#[derive(Clone, Copy, Debug, PartialEq)]
255pub struct Oklab([f64; 3]);
256impl Oklab {
257    pub fn new(v: [f64; 3]) -> Result<Self> {
258        Ok(Self(finite(v)?))
259    }
260    pub fn components(self) -> [f64; 3] {
261        self.0
262    }
263    /// Inverts [`LinearRgb::to_oklab`]; the round trip is exact to about 1e-15.
264    pub fn to_linear(self, space: RgbSpace) -> Result<LinearRgb> {
265        let lms = mul(OKLAB_M2_INV, self.0).map(|x| x * x * x);
266        LinearRgb::new(RgbSpace::Srgb, mul(OKLAB_M1_INV, lms))?.convert(space)
267    }
268    pub fn to_oklch(self) -> Result<Oklch> {
269        let [l, a, b] = self.0;
270        Oklch::new(l, a.hypot(b), b.atan2(a).to_degrees())
271    }
272}
273/// OkLCh: lightness, nonnegative chroma, hue in degrees normalized to [0,360).
274/// Exact zero chroma has canonical hue zero. Near-neutral hues are retained.
275#[derive(Clone, Copy, Debug, PartialEq)]
276pub struct Oklch([f64; 3]);
277impl Oklch {
278    pub fn new(l: f64, c: f64, h: f64) -> Result<Self> {
279        finite([l, c, h])?;
280        if c < 0. {
281            return Err(ColorError);
282        }
283        let hue = h.rem_euclid(360.);
284        // A tiny negative angle can round up to exactly 360 in floating point.
285        Ok(Self([l, c, if c == 0. || hue >= 360. { 0. } else { hue }]))
286    }
287    pub fn components(self) -> [f64; 3] {
288        self.0
289    }
290    pub fn to_oklab(self) -> Result<Oklab> {
291        let [l, c, h] = self.0;
292        Oklab::new([l, c * h.to_radians().cos(), c * h.to_radians().sin()])
293    }
294}
295
296#[cfg(test)]
297mod tests;
298
299/// Resource-free WGSL helpers; see the crate guide for IDs and input contracts.
300pub const WGSL_COLOR_MATH: &str = include_str!("math.wgsl");