1use 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}
440pub 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}