Skip to main content

nx_value/
color.rs

1//! Renderer-independent color validation and transformations.
2use crate::NxValue;
3
4pub const MODULE: &str = "@nx/color/color.nx";
5pub const COLOR: &str = "@nx/color/color.nx::Color";
6pub const NAMES: &[&str] = &[
7    "rgb",
8    "rgba",
9    "srgb",
10    "linearRgb",
11    "hsl",
12    "hsv",
13    "hwb",
14    "oklab",
15    "oklch",
16    "parse",
17    "tryParse",
18    "validate",
19    "toHex",
20    "toCss",
21    "toRgb",
22    "toHsl",
23    "toHsv",
24    "toHwb",
25    "toLinearRgb",
26    "toOklab",
27    "toOklch",
28    "withAlpha",
29    "fadeIn",
30    "fadeOut",
31    "lighten",
32    "darken",
33    "saturate",
34    "desaturate",
35    "rotateHue",
36    "complement",
37    "grayscale",
38    "invert",
39    "mix",
40    "composite",
41    "relativeLuminance",
42    "contrastRatio",
43    "bestTextColor",
44];
45pub const ARITIES: &[usize] = &[
46    3, 4, 4, 4, 4, 4, 4, 4, 4, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 2,
47    4, 2, 1, 2, 3,
48];
49#[derive(Debug, Clone)]
50pub struct Error {
51    pub code: &'static str,
52    pub message: String,
53}
54fn invalid(field: &str) -> Error {
55    Error {
56        code: "color-invalid-channel",
57        message: format!("invalid color channel or parameter: {field}"),
58    }
59}
60fn finite(x: f64, field: &str) -> Result<f64, Error> {
61    if x.is_finite() {
62        Ok(x)
63    } else {
64        Err(invalid(field))
65    }
66}
67fn unit(x: f64, field: &str) -> Result<f64, Error> {
68    finite(x, field)?;
69    if (0.0..=1.0).contains(&x) {
70        Ok(x)
71    } else {
72        Err(invalid(field))
73    }
74}
75fn decode(x: f64) -> f64 {
76    if x <= 0.04045 {
77        x / 12.92
78    } else {
79        ((x + 0.055) / 1.055).powf(2.4)
80    }
81}
82fn encode(x: f64) -> f64 {
83    if x <= 0.0031308 {
84        x * 12.92
85    } else {
86        1.055 * x.powf(1.0 / 2.4) - 0.055
87    }
88}
89fn record(name: &str, fields: impl IntoIterator<Item = (String, NxValue)>) -> NxValue {
90    NxValue::Record {
91        type_name: Some(name.to_string()),
92        properties: fields.into_iter().collect(),
93    }
94}
95fn number(v: &NxValue) -> Result<f64, Error> {
96    match v {
97        NxValue::Float(x) => Ok(*x),
98        NxValue::Float32(x) => Ok(*x as f64),
99        NxValue::Int(x) => Ok(*x as f64),
100        NxValue::Int32(x) => Ok(*x as f64),
101        _ => Err(invalid("number")),
102    }
103}
104pub fn make(c: [f64; 4]) -> Result<NxValue, Error> {
105    for (i, x) in c.iter().enumerate() {
106        unit(*x, ["r", "g", "b", "a"][i])?;
107    }
108    Ok(record(
109        "Color",
110        ["r", "g", "b", "a"].into_iter().zip(c).map(|(k, x)| {
111            (
112                k.into(),
113                NxValue::Float32(if x == 0.0 { 0.0 } else { x as f32 }),
114            )
115        }),
116    ))
117}
118pub fn channels(v: &NxValue) -> Result<[f64; 4], Error> {
119    let NxValue::Record { properties, .. } = v else {
120        return Err(invalid("Color"));
121    };
122    let mut out = [0.0; 4];
123    for (i, k) in ["r", "g", "b", "a"].iter().enumerate() {
124        let x = match properties.get(*k) {
125            Some(value) => number(value)?,
126            None if *k == "a" => 1.0,
127            None => return Err(invalid(k)),
128        };
129        unit(x, k)?;
130        if (x as f32) as f64 != x {
131            return Err(invalid(k));
132        }
133        out[i] = x;
134    }
135    Ok(out)
136}
137pub fn parse(text: &str) -> Result<NxValue, Error> {
138    let text = text.trim_matches(|c: char| c.is_ascii_whitespace());
139    let lower = text.to_ascii_lowercase();
140    let bad = || Error {
141        code: "color-invalid-text",
142        message: format!("invalid standalone color: {text}"),
143    };
144    let Some((name, tail)) = lower.split_once('(') else {
145        if lower == "transparent" {
146            return make([0.0; 4]);
147        }
148        if let Some(bytes) = csscolorparser::NAMED_COLORS.get(lower.as_str()) {
149            return make([
150                bytes[0] as f64 / 255.0,
151                bytes[1] as f64 / 255.0,
152                bytes[2] as f64 / 255.0,
153                1.0,
154            ]);
155        }
156        let digits = lower.strip_prefix('#').ok_or_else(bad)?;
157        if !matches!(digits.len(), 3 | 4 | 6 | 8) || !digits.bytes().all(|b| b.is_ascii_hexdigit())
158        {
159            return Err(bad());
160        }
161        let mut hex = if digits.len() <= 4 {
162            digits.chars().flat_map(|c| [c, c]).collect::<String>()
163        } else {
164            digits.to_string()
165        };
166        if hex.len() == 6 {
167            hex.push_str("ff");
168        }
169        let mut channels = [0.0; 4];
170        for (i, channel) in channels.iter_mut().enumerate() {
171            *channel =
172                u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16).map_err(|_| bad())? as f64 / 255.0;
173        }
174        return make(channels);
175    };
176    let mut body = tail.strip_suffix(')').ok_or_else(bad)?;
177    let mut name = name;
178    if name == "color" {
179        body = body.trim_ascii();
180        let split = body
181            .find(|c: char| c.is_ascii_whitespace())
182            .ok_or_else(bad)?;
183        name = &body[..split];
184        body = body[split..].trim_ascii();
185        if !["srgb", "srgb-linear"].contains(&name) {
186            return Err(bad());
187        }
188    }
189    if ![
190        "rgb",
191        "rgba",
192        "hsl",
193        "hsla",
194        "hwb",
195        "oklab",
196        "oklch",
197        "srgb",
198        "srgb-linear",
199    ]
200    .contains(&name)
201    {
202        return Err(bad());
203    }
204    let comma = body.contains(',');
205    let parts: Vec<&str>;
206    let alpha: &str;
207    if comma {
208        if !["rgb", "rgba", "hsl", "hsla"].contains(&name) || body.contains('/') {
209            return Err(bad());
210        }
211        let all: Vec<_> = body.split(',').map(str::trim_ascii).collect();
212        if all.len() != 3 && all.len() != 4 {
213            return Err(bad());
214        }
215        alpha = all.get(3).copied().unwrap_or("1");
216        parts = all[..3].to_vec();
217    } else {
218        let mut split = body.split('/');
219        let channels = split.next().unwrap();
220        alpha = split.next().unwrap_or("1").trim_ascii();
221        if split.next().is_some() {
222            return Err(bad());
223        }
224        parts = channels.split_ascii_whitespace().collect();
225        if parts.len() != 3 {
226            return Err(bad());
227        }
228    }
229    let num = |token: &str, percent: f64, hue: bool| -> Result<f64, Error> {
230        let (number, scale) = if let Some(n) = token.strip_suffix('%') {
231            (n, percent / 100.0)
232        } else if hue {
233            if let Some(n) = token.strip_suffix("turn") {
234                (n, 360.0)
235            } else if let Some(n) = token.strip_suffix("grad") {
236                (n, 0.9)
237            } else if let Some(n) = token.strip_suffix("rad") {
238                (n, 180.0 / std::f64::consts::PI)
239            } else if let Some(n) = token.strip_suffix("deg") {
240                (n, 1.0)
241            } else {
242                (token, 1.0)
243            }
244        } else {
245            (token, 1.0)
246        };
247        if number.is_empty()
248            || number
249                .split('e')
250                .next()
251                .is_some_and(|mantissa| mantissa.ends_with('.'))
252            || number
253                .chars()
254                .any(|c| !c.is_ascii_digit() && !['+', '-', '.', 'e'].contains(&c))
255        {
256            return Err(bad());
257        }
258        let n = number.parse::<f64>().map_err(|_| bad())? * scale;
259        if n.is_finite() {
260            Ok(n)
261        } else {
262            Err(bad())
263        }
264    };
265    let alpha = num(alpha, 1.0, false)?.clamp(0.0, 1.0);
266    let mut c = match name {
267        "rgb" | "rgba" => {
268            if comma
269                && !(parts.iter().all(|s| s.ends_with('%'))
270                    || parts.iter().all(|s| !s.ends_with('%')))
271            {
272                return Err(bad());
273            }
274            [
275                num(parts[0], 255.0, false)?.clamp(0.0, 255.0) / 255.0,
276                num(parts[1], 255.0, false)?.clamp(0.0, 255.0) / 255.0,
277                num(parts[2], 255.0, false)?.clamp(0.0, 255.0) / 255.0,
278                alpha,
279            ]
280        }
281        "srgb" | "srgb-linear" => {
282            let mut c = [
283                num(parts[0], 1.0, false)?.clamp(0.0, 1.0),
284                num(parts[1], 1.0, false)?.clamp(0.0, 1.0),
285                num(parts[2], 1.0, false)?.clamp(0.0, 1.0),
286                alpha,
287            ];
288            if name == "srgb-linear" {
289                for v in &mut c[..3] {
290                    *v = encode(*v);
291                }
292            }
293            c
294        }
295        "hsl" | "hsla" | "hwb" => {
296            if (comma && (!parts[1].ends_with('%') || !parts[2].ends_with('%')))
297                || parts[0].ends_with('%')
298            {
299                return Err(bad());
300            }
301            let h = num(parts[0], 1.0, true)?.rem_euclid(360.0);
302            let a = (num(parts[1], 100.0, false)? / 100.0).clamp(0.0, 1.0);
303            let b = (num(parts[2], 100.0, false)? / 100.0).clamp(0.0, 1.0);
304            if name == "hwb" {
305                let sum = a + b;
306                let rgb = if sum >= 1.0 {
307                    [a / sum; 3]
308                } else {
309                    hsv(h, 1.0, 1.0).map(|x| x * (1.0 - sum) + a)
310                };
311                [rgb[0], rgb[1], rgb[2], alpha]
312            } else {
313                from_hsl([h, a, b, alpha])
314            }
315        }
316        _ => {
317            let l = num(parts[0], 1.0, false)?.clamp(0.0, 1.0);
318            let x = num(parts[1], 0.4, false)?;
319            let y = num(
320                parts[2],
321                if name == "oklab" { 0.4 } else { 1.0 },
322                name == "oklch",
323            )?;
324            if name == "oklch" {
325                if parts[2].ends_with('%') {
326                    return Err(bad());
327                }
328                let h = y.rem_euclid(360.0).to_radians();
329                from_lab([l, x.max(0.0) * h.cos(), x.max(0.0) * h.sin(), alpha])
330            } else {
331                from_lab([l, x, y, alpha])
332            }
333        }
334    };
335    for v in &mut c {
336        if !v.is_finite() {
337            return Err(bad());
338        }
339        *v = v.clamp(0.0, 1.0);
340    }
341    make(c).map_err(|_| bad())
342}
343
344fn wrapped_hue(h: f64) -> f64 {
345    let h = h.rem_euclid(360.0);
346    if h >= 360.0 || h == 0.0 {
347        0.0
348    } else {
349        h
350    }
351}
352fn hsv(h: f64, s: f64, v: f64) -> [f64; 3] {
353    let h = wrapped_hue(h) / 60.0;
354    let f = h - h.floor();
355    let p = v * (1.0 - s);
356    let q = v * (1.0 - s * f);
357    let t = v * (1.0 - s * (1.0 - f));
358    match h as u8 {
359        0 => [v, t, p],
360        1 => [q, v, p],
361        2 => [p, v, t],
362        3 => [p, q, v],
363        4 => [t, p, v],
364        _ => [v, p, q],
365    }
366}
367fn hsl(c: [f64; 4]) -> [f64; 4] {
368    let max = c[0].max(c[1]).max(c[2]);
369    let min = c[0].min(c[1]).min(c[2]);
370    let d = max - min;
371    let l = (max + min) / 2.0;
372    if d == 0.0 {
373        return [0.0, 0.0, l, c[3]];
374    }
375    let s = d / (1.0 - (2.0 * l - 1.0).abs());
376    let h = 60.0
377        * if max == c[0] {
378            ((c[1] - c[2]) / d).rem_euclid(6.0)
379        } else if max == c[1] {
380            (c[2] - c[0]) / d + 2.0
381        } else {
382            (c[0] - c[1]) / d + 4.0
383        };
384    [wrapped_hue(h), s, l, c[3]]
385}
386
387fn from_hsl(c: [f64; 4]) -> [f64; 4] {
388    let v = c[2] + c[1] * c[2].min(1.0 - c[2]);
389    let s = if v == 0.0 {
390        0.0
391    } else {
392        2.0 * (1.0 - c[2] / v)
393    };
394    let rgb = hsv(c[0], s, v);
395    [rgb[0], rgb[1], rgb[2], c[3]]
396}
397fn lab(c: [f64; 4]) -> [f64; 4] {
398    let [r, g, b] = [decode(c[0]), decode(c[1]), decode(c[2])];
399    let l = (0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b).cbrt();
400    let m = (0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b).cbrt();
401    let s = (0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b).cbrt();
402    [
403        0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
404        1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
405        0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
406        c[3],
407    ]
408}
409fn from_lab(c: [f64; 4]) -> [f64; 4] {
410    let [ll, a, b, alpha] = c;
411    let l = (ll + 0.3963377774 * a + 0.2158037573 * b).powi(3);
412    let m = (ll - 0.1055613458 * a - 0.0638541728 * b).powi(3);
413    let s = (ll - 0.0894841775 * a - 1.291485548 * b).powi(3);
414    [
415        encode(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s),
416        encode(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s),
417        encode(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s),
418        alpha,
419    ]
420}
421
422fn clip(mut c: [f64; 4]) -> Result<NxValue, Error> {
423    for x in &mut c {
424        finite(*x, "result")?;
425        *x = x.clamp(0.0, 1.0);
426    }
427    make(c)
428}
429fn luminance(c: [f64; 4]) -> Result<f64, Error> {
430    if c[3] != 1.0 {
431        return Err(invalid("alpha must be opaque"));
432    }
433    Ok(0.2126 * decode(c[0]) + 0.7152 * decode(c[1]) + 0.0722 * decode(c[2]))
434}
435fn contrast(a: [f64; 4], b: [f64; 4]) -> Result<f64, Error> {
436    let a = luminance(a)?;
437    let b = luminance(b)?;
438    Ok((a.max(b) + 0.05) / (a.min(b) + 0.05))
439}
440/// Evaluates a closed standard-color operation on ordinary host values.
441pub fn evaluate(op: u32, args: &[NxValue]) -> Result<NxValue, Error> {
442    let name = *NAMES.get(op as usize).ok_or_else(|| invalid("operation"))?;
443    if args.len() != ARITIES[op as usize] {
444        return Err(invalid("arity"));
445    }
446    let n = |i: usize| number(&args[i]);
447    let c = |i: usize| channels(&args[i]);
448    match name {
449        "parse" | "tryParse" => {
450            let NxValue::String(text) = &args[0] else {
451                return Err(invalid("text"));
452            };
453            let value = parse(text);
454            if name == "parse" {
455                value
456            } else {
457                Ok(match value {
458                    Ok(v) => record("ParseResult.parsed", [("value".into(), v)]),
459                    Err(e) => record(
460                        "ParseResult.invalid",
461                        [("message".into(), NxValue::String(e.message))],
462                    ),
463                })
464            }
465        }
466        "rgb" | "rgba" | "srgb" | "linearRgb" | "hsl" | "hsv" | "hwb" | "oklab" | "oklch" => {
467            let mut v = [
468                n(0)?,
469                n(1)?,
470                n(2)?,
471                if args.len() == 4 { n(3)? } else { 1.0 },
472            ];
473            unit(v[3], "alpha")?;
474            for x in v {
475                finite(x, "channel")?;
476            }
477            match name {
478                "rgb" | "rgba" => {
479                    for (index, x) in v[..3].iter_mut().enumerate() {
480                        *x = unit(*x / 255.0, ["r", "g", "b"][index])?;
481                    }
482                }
483                "srgb" => {}
484                "linearRgb" => {
485                    for x in &mut v[..3] {
486                        *x = encode(unit(*x, "linearRgb")?);
487                    }
488                }
489                "hsl" => {
490                    unit(v[1], "s")?;
491                    unit(v[2], "l")?;
492                    v = from_hsl(v);
493                }
494                "hsv" => {
495                    unit(v[1], "s")?;
496                    unit(v[2], "v")?;
497                    let rgb = hsv(v[0], v[1], v[2]);
498                    v = [rgb[0], rgb[1], rgb[2], v[3]];
499                }
500                "hwb" => {
501                    unit(v[1], "w")?;
502                    unit(v[2], "b")?;
503                    let sum = v[1] + v[2];
504                    if sum >= 1.0 {
505                        let x = v[1] / sum;
506                        v = [x, x, x, v[3]];
507                    } else {
508                        let rgb = hsv(v[0], 1.0, 1.0).map(|x| x * (1.0 - sum) + v[1]);
509                        v = [rgb[0], rgb[1], rgb[2], v[3]];
510                    }
511                }
512                "oklab" => {
513                    unit(v[0], "l")?;
514                    v = from_lab(v);
515                }
516                "oklch" => {
517                    unit(v[0], "l")?;
518                    if v[1] < 0.0 {
519                        return Err(invalid("chroma"));
520                    }
521                    let h = v[2].rem_euclid(360.0).to_radians();
522                    v = from_lab([v[0], v[1] * h.cos(), v[1] * h.sin(), v[3]]);
523                }
524                _ => {}
525            }
526            if ["oklab", "oklch"].contains(&name) {
527                clip(v)
528            } else {
529                make(v)
530            }
531        }
532        "validate" => make(c(0)?),
533        "toHex" => {
534            let c = c(0)?;
535            Ok(NxValue::String(format!(
536                "#{:02x}{:02x}{:02x}{:02x}",
537                (c[0] * 255.0 + 0.5).floor() as u8,
538                (c[1] * 255.0 + 0.5).floor() as u8,
539                (c[2] * 255.0 + 0.5).floor() as u8,
540                (c[3] * 255.0 + 0.5).floor() as u8
541            )))
542        }
543        "toCss" => {
544            let c = c(0)?;
545            Ok(NxValue::String(format!(
546                "rgba({}, {}, {}, {})",
547                c[0] * 255.0,
548                c[1] * 255.0,
549                c[2] * 255.0,
550                c[3]
551            )))
552        }
553        "toRgb" | "toHsl" | "toHsv" | "toHwb" | "toLinearRgb" | "toOklab" | "toOklch" => {
554            let c = c(0)?;
555            let (ty, keys, v) = match name {
556                "toRgb" => (
557                    "RgbChannels",
558                    ["r", "g", "b", "alpha"],
559                    [c[0] * 255.0, c[1] * 255.0, c[2] * 255.0, c[3]],
560                ),
561                "toHsl" => ("HslChannels", ["h", "s", "l", "alpha"], hsl(c)),
562                "toHsv" => ("HsvChannels", ["h", "s", "v", "alpha"], {
563                    let max = c[0].max(c[1]).max(c[2]);
564                    let min = c[0].min(c[1]).min(c[2]);
565                    [
566                        hsl(c)[0],
567                        if max == 0.0 { 0.0 } else { (max - min) / max },
568                        max,
569                        c[3],
570                    ]
571                }),
572                "toHwb" => (
573                    "HwbChannels",
574                    ["h", "w", "b", "alpha"],
575                    [
576                        hsl(c)[0],
577                        c[0].min(c[1]).min(c[2]),
578                        1.0 - c[0].max(c[1]).max(c[2]),
579                        c[3],
580                    ],
581                ),
582                "toLinearRgb" => (
583                    "LinearRgbChannels",
584                    ["r", "g", "b", "alpha"],
585                    [decode(c[0]), decode(c[1]), decode(c[2]), c[3]],
586                ),
587                "toOklab" => ("OklabChannels", ["l", "a", "b", "alpha"], lab(c)),
588                _ => ("OklchChannels", ["l", "c", "h", "alpha"], {
589                    let lab = lab(c);
590                    let chroma = lab[1].hypot(lab[2]);
591                    if c[0] == c[1] && c[1] == c[2] {
592                        [lab[0], 0.0, 0.0, c[3]]
593                    } else {
594                        [
595                            lab[0],
596                            chroma,
597                            lab[2].atan2(lab[1]).to_degrees().rem_euclid(360.0),
598                            c[3],
599                        ]
600                    }
601                }),
602            };
603            Ok(record(
604                ty,
605                keys.into_iter()
606                    .zip(v)
607                    .map(|(k, v)| (k.into(), NxValue::Float(v))),
608            ))
609        }
610        "withAlpha" | "fadeIn" | "fadeOut" | "invert" => {
611            let mut v = c(0)?;
612            let amount = unit(n(1)?, "amount")?;
613            match name {
614                "withAlpha" => v[3] = amount,
615                "fadeIn" => v[3] = (v[3] + amount).min(1.0),
616                "fadeOut" => v[3] = (v[3] - amount).max(0.0),
617                _ => {
618                    for x in &mut v[..3] {
619                        *x = *x * (1.0 - amount) + (1.0 - *x) * amount;
620                    }
621                }
622            }
623            make(v)
624        }
625        "lighten" | "darken" | "saturate" | "desaturate" | "rotateHue" | "complement"
626        | "grayscale" => {
627            let mut v = hsl(c(0)?);
628            match name {
629                "complement" => v[0] = (v[0] + 180.0).rem_euclid(360.0),
630                "grayscale" => v[1] = 0.0,
631                "rotateHue" => v[0] = (v[0] + finite(n(1)?, "degrees")?).rem_euclid(360.0),
632                _ => {
633                    let amount = unit(n(1)?, "amount")?;
634                    let i = if name == "lighten" || name == "darken" {
635                        2
636                    } else {
637                        1
638                    };
639                    v[i] = (v[i]
640                        + if name == "darken" || name == "desaturate" {
641                            -amount
642                        } else {
643                            amount
644                        })
645                    .clamp(0.0, 1.0);
646                }
647            }
648            make(from_hsl(v))
649        }
650        "relativeLuminance" => Ok(NxValue::Float(luminance(c(0)?)?)),
651        "contrastRatio" => Ok(NxValue::Float(contrast(c(0)?, c(1)?)?)),
652        "bestTextColor" => {
653            let bg = c(0)?;
654            let a = c(1)?;
655            let b = c(2)?;
656            make(if contrast(bg, a)? >= contrast(bg, b)? {
657                a
658            } else {
659                b
660            })
661        }
662        "mix" | "composite" => {
663            let mut a = c(0)?;
664            let mut b = c(1)?;
665            let (weight, space) = if name == "mix" {
666                (
667                    unit(n(2)?, "weight")?,
668                    match &args[3] {
669                        NxValue::String(s) => s.as_str(),
670                        _ => return Err(invalid("space")),
671                    },
672                )
673            } else {
674                (0.0, "linearSrgb")
675            };
676            if name == "mix" && weight == 0.0 {
677                return make(a);
678            }
679            if name == "mix" && weight == 1.0 {
680                return make(b);
681            }
682            if !["srgb", "linearSrgb", "oklab"].contains(&space) {
683                return Err(invalid("space"));
684            }
685            if space == "oklab" {
686                a = lab(a);
687                b = lab(b);
688            } else if space == "linearSrgb" {
689                for x in &mut a[..3] {
690                    *x = decode(*x);
691                }
692                for x in &mut b[..3] {
693                    *x = decode(*x);
694                }
695            }
696            let (wa, wb) = if name == "mix" {
697                (1.0 - weight, weight)
698            } else {
699                (1.0, 1.0 - a[3])
700            };
701            let alpha = a[3] * wa + b[3] * wb;
702            let mut out = [0.0, 0.0, 0.0, alpha];
703            if alpha > 0.0 {
704                for i in 0..3 {
705                    out[i] = (a[i] * a[3] * wa + b[i] * b[3] * wb) / alpha;
706                }
707                if space == "oklab" {
708                    out = from_lab(out);
709                } else if space == "linearSrgb" {
710                    for x in &mut out[..3] {
711                        *x = encode(*x);
712                    }
713                }
714            }
715            clip(out)
716        }
717        _ => Err(invalid("operation")),
718    }
719}
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724    fn call(name: &str, args: &[NxValue]) -> NxValue {
725        evaluate(NAMES.iter().position(|n| *n == name).unwrap() as u32, args).unwrap()
726    }
727    #[test]
728    fn parsing_and_float32_roundtrip() {
729        for text in [
730            "red",
731            "#f008",
732            "#12345678",
733            "rgb(255, 0, 0)",
734            "hsl(180 100% 50%)",
735        ] {
736            let v = parse(text).unwrap();
737            let NxValue::String(css) = call("toCss", std::slice::from_ref(&v)) else {
738                panic!()
739            };
740            assert_eq!(v, parse(&css).unwrap(), "{text}");
741        }
742        for text in ["#12", "#gg0000", "currentColor", "var(--accent)", "none"] {
743            assert_eq!(parse(text).unwrap_err().code, "color-invalid-text");
744        }
745        assert!(make([f64::NAN, 0.0, 0.0, 1.0]).is_err());
746    }
747    #[test]
748    fn mixing_and_contrast_reference() {
749        let black = parse("black").unwrap();
750        let white = parse("white").unwrap();
751        assert_eq!(
752            call("contrastRatio", &[black.clone(), white.clone()]),
753            NxValue::Float(21.0)
754        );
755        let out = call(
756            "mix",
757            &[
758                black,
759                white,
760                NxValue::Float(0.5),
761                NxValue::String("linearSrgb".into()),
762            ],
763        );
764        assert!((channels(&out).unwrap()[0] - 0.735356983).abs() < 1e-6);
765        let out = call(
766            "mix",
767            &[
768                parse("red").unwrap(),
769                parse("#0000ff00").unwrap(),
770                NxValue::Float(0.5),
771                NxValue::String("srgb".into()),
772            ],
773        );
774        assert_eq!(channels(&out).unwrap(), [1.0, 0.0, 0.0, 0.5]);
775    }
776}