1use crate::{
2 SrgbColor, compress_number_prefix, format_css_number, parse_basic_named_srgb_color,
3 parse_whole_function_value_arguments, parse_whole_function_value_inner,
4 shortest_named_srgb_color, split_top_level_value_arguments_owned,
5};
6
7#[derive(Debug, Clone, Copy, PartialEq)]
8pub struct StaticSrgbColorWithAlpha {
9 pub color: SrgbColor,
10 pub alpha: Option<f64>,
11}
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14enum StaticColorMixSpace {
15 Srgb,
16 SrgbLinear,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq)]
20struct StaticColorMixStop {
21 color: StaticSrgbColorWithAlpha,
22 percentage: Option<f64>,
23}
24
25#[derive(Debug, Clone, Copy, PartialEq)]
26struct StaticColorMixWeights {
27 first_weight: f64,
28 second_weight: f64,
29 alpha_multiplier: Option<f64>,
30}
31
32#[derive(Debug, Clone, Copy, PartialEq)]
33struct StaticColorMixChannelContext {
34 interpolation_space: StaticColorMixSpace,
35 first_alpha: f64,
36 second_alpha: f64,
37 first_weight: f64,
38 second_weight: f64,
39 interpolated_alpha: f64,
40}
41
42impl SrgbColor {
43 fn to_css_rgb(self) -> String {
44 format!("rgb({} {} {})", self.red, self.green, self.blue)
45 }
46
47 fn to_css_rgb_with_alpha(self, alpha: Option<f64>) -> String {
48 match alpha {
49 Some(alpha) => format!(
50 "rgb({} {} {} / {})",
51 self.red,
52 self.green,
53 self.blue,
54 format_css_alpha(alpha)
55 ),
56 None => self.to_css_rgb(),
57 }
58 }
59}
60
61pub fn relative_color_channel_names(function: &str) -> Option<&'static [&'static str]> {
69 let channels: &'static [&'static str] = match function.trim().to_ascii_lowercase().as_str() {
70 "rgb" | "rgba" => &["r", "g", "b", "alpha"],
71 "hsl" | "hsla" => &["h", "s", "l", "alpha"],
72 "hwb" => &["h", "w", "b", "alpha"],
73 "lab" | "oklab" => &["l", "a", "b", "alpha"],
74 "lch" | "oklch" => &["l", "c", "h", "alpha"],
75 _ => return None,
76 };
77 Some(channels)
78}
79
80pub fn is_relative_color_form(value: &str) -> bool {
84 let trimmed = value.trim();
85 let Some(open) = trimmed.find('(') else {
86 return false;
87 };
88 let function = trimmed[..open].trim();
89 let recognized_function =
90 relative_color_channel_names(function).is_some() || function.eq_ignore_ascii_case("color");
91 if !recognized_function {
92 return false;
93 }
94 trimmed[open + 1..]
95 .split_whitespace()
96 .next()
97 .is_some_and(|first| first.eq_ignore_ascii_case("from"))
98}
99
100pub fn parse_color_mix_value(value: &str) -> Option<String> {
101 let arguments = parse_whole_function_value_arguments(value, "color-mix")?;
102 let [space, first, second] = arguments.as_slice() else {
103 return None;
104 };
105 let interpolation_space = parse_static_color_mix_space(space)?;
106
107 let first_stop = parse_static_color_mix_stop(first)?;
108 let second_stop = parse_static_color_mix_stop(second)?;
109 let color_mix = color_mix_weights(first_stop.percentage, second_stop.percentage)?;
110 let mixed = mix_srgb_colors(
111 interpolation_space,
112 first_stop.color,
113 second_stop.color,
114 color_mix.first_weight,
115 color_mix.second_weight,
116 color_mix.alpha_multiplier,
117 );
118 Some(mixed.color.to_css_rgb_with_alpha(mixed.alpha))
119}
120
121pub fn parse_static_srgb_color(text: &str) -> Option<SrgbColor> {
122 parse_static_hex_color(text).or_else(|| parse_basic_named_srgb_color(text))
123}
124
125pub fn parse_static_srgb_color_with_alpha(text: &str) -> Option<StaticSrgbColorWithAlpha> {
126 parse_static_hex_color_with_alpha(text)
127 .or_else(|| parse_basic_named_static_color_with_alpha(text))
128}
129
130pub fn parse_basic_named_static_color_with_alpha(text: &str) -> Option<StaticSrgbColorWithAlpha> {
131 if text.eq_ignore_ascii_case("transparent") {
132 return Some(StaticSrgbColorWithAlpha {
133 color: SrgbColor {
134 red: 0,
135 green: 0,
136 blue: 0,
137 },
138 alpha: Some(0.0),
139 });
140 }
141
142 Some(StaticSrgbColorWithAlpha {
143 color: parse_basic_named_srgb_color(text)?,
144 alpha: None,
145 })
146}
147
148pub fn parse_oklab_oklch_value(value: &str) -> Option<String> {
149 parse_oklab_value(value)
150 .or_else(|| parse_oklch_value(value))
151 .map(|(color, alpha)| color.to_css_rgb_with_alpha(alpha))
152}
153
154pub fn parse_color_function_value(value: &str) -> Option<String> {
155 let inner = parse_whole_function_value_inner(value, "color")?;
156 if inner.contains(',') {
157 return None;
158 }
159 let parts = inner.split_whitespace().collect::<Vec<_>>();
160 let (space, red, green, blue, alpha) = match parts.as_slice() {
161 [space, red, green, blue] => (*space, *red, *green, *blue, None),
162 [space, red, green, blue, "/", alpha] => {
163 let alpha = non_opaque_alpha_value(alpha)?;
164 (*space, *red, *green, *blue, alpha)
165 }
166 _ => return None,
167 };
168 let color = if space.eq_ignore_ascii_case("srgb") {
169 SrgbColor {
170 red: parse_srgb_component(red)?,
171 green: parse_srgb_component(green)?,
172 blue: parse_srgb_component(blue)?,
173 }
174 } else if space.eq_ignore_ascii_case("srgb-linear") {
175 SrgbColor {
176 red: encode_srgb_channel(parse_unit_interval_component(red)?),
177 green: encode_srgb_channel(parse_unit_interval_component(green)?),
178 blue: encode_srgb_channel(parse_unit_interval_component(blue)?),
179 }
180 } else if space.eq_ignore_ascii_case("display-p3") {
181 display_p3_to_srgb(
182 parse_unit_interval_component(red)?,
183 parse_unit_interval_component(green)?,
184 parse_unit_interval_component(blue)?,
185 )?
186 } else {
187 return None;
188 };
189 Some(color.to_css_rgb_with_alpha(alpha))
190}
191
192pub fn parse_relative_color_value(value: &str) -> Option<String> {
199 let trimmed = value.trim();
204 if !is_relative_color_form(trimmed) {
205 return None;
206 }
207 let function = trimmed[..trimmed.find('(')?].trim();
208 if relative_color_channel_names(function) != Some(["r", "g", "b", "alpha"].as_slice()) {
209 return None;
210 }
211 let inner = parse_whole_function_value_inner(value, "rgb")
212 .or_else(|| parse_whole_function_value_inner(value, "rgba"))?;
213 if inner.contains(',') {
214 return None;
215 }
216 let parts = inner.split_whitespace().collect::<Vec<_>>();
217 let (origin, red, green, blue, alpha) = match parts.as_slice() {
218 [from, origin, red, green, blue] if from.eq_ignore_ascii_case("from") => {
219 (*origin, *red, *green, *blue, None)
220 }
221 [from, origin, red, green, blue, "/", alpha] if from.eq_ignore_ascii_case("from") => {
222 (*origin, *red, *green, *blue, Some(*alpha))
223 }
224 _ => return None,
225 };
226
227 let origin = parse_static_srgb_color_with_alpha(origin)?;
228 let color = SrgbColor {
229 red: resolve_relative_srgb_channel(red, &origin.color)?,
230 green: resolve_relative_srgb_channel(green, &origin.color)?,
231 blue: resolve_relative_srgb_channel(blue, &origin.color)?,
232 };
233 let alpha = match alpha {
234 Some(alpha) => resolve_relative_alpha_channel(alpha, origin.alpha)?,
235 None => origin.alpha,
236 };
237 Some(color.to_css_rgb_with_alpha(alpha))
238}
239
240fn resolve_relative_srgb_channel(channel: &str, origin: &SrgbColor) -> Option<u8> {
244 match channel {
245 "r" => Some(origin.red),
246 "g" => Some(origin.green),
247 "b" => Some(origin.blue),
248 literal => parse_rgb_component_byte(literal),
249 }
250}
251
252fn resolve_relative_alpha_channel(channel: &str, origin: Option<f64>) -> Option<Option<f64>> {
253 match channel {
254 "alpha" => Some(origin),
255 literal => non_opaque_alpha_value(literal),
256 }
257}
258
259pub fn parse_static_rgb_function_color_with_alpha(value: &str) -> Option<StaticSrgbColorWithAlpha> {
260 let inner = parse_whole_function_value_inner(value, "rgb")
261 .or_else(|| parse_whole_function_value_inner(value, "rgba"))?;
262 let (parts, alpha) = split_static_color_channels_with_optional_alpha(inner)?;
263 let [red, green, blue] = parts.as_slice() else {
264 return None;
265 };
266
267 Some(StaticSrgbColorWithAlpha {
268 color: SrgbColor {
269 red: parse_rgb_component_byte(red)?,
270 green: parse_rgb_component_byte(green)?,
271 blue: parse_rgb_component_byte(blue)?,
272 },
273 alpha,
274 })
275}
276
277pub fn parse_static_hsl_function_color_with_alpha(value: &str) -> Option<StaticSrgbColorWithAlpha> {
278 let inner = parse_whole_function_value_inner(value, "hsl")
279 .or_else(|| parse_whole_function_value_inner(value, "hsla"))?;
280 let (parts, alpha) = split_static_color_channels_with_optional_alpha(inner)?;
281 let [hue, saturation, lightness] = parts.as_slice() else {
282 return None;
283 };
284
285 Some(StaticSrgbColorWithAlpha {
286 color: hsl_to_srgb(
287 parse_hue_degrees(hue)?,
288 parse_bounded_percentage(saturation)?,
289 parse_bounded_percentage(lightness)?,
290 )?,
291 alpha,
292 })
293}
294
295pub fn parse_static_hwb_function_color_with_alpha(value: &str) -> Option<StaticSrgbColorWithAlpha> {
296 let inner = parse_whole_function_value_inner(value, "hwb")?;
297 let (parts, alpha) = split_static_color_channels_with_optional_alpha(inner)?;
298 let [hue, whiteness, blackness] = parts.as_slice() else {
299 return None;
300 };
301
302 Some(StaticSrgbColorWithAlpha {
303 color: hwb_to_srgb(
304 parse_hue_degrees(hue)?,
305 parse_bounded_percentage(whiteness)?,
306 parse_bounded_percentage(blackness)?,
307 )?,
308 alpha,
309 })
310}
311
312pub fn shortest_static_srgb_color_with_alpha_text(color: StaticSrgbColorWithAlpha) -> String {
313 match color.alpha {
314 Some(alpha) => compressed_hex_color_for_srgb_with_alpha(color.color, alpha),
315 None => shortest_static_srgb_color_text(color.color),
316 }
317}
318
319pub fn compress_hex_color_token_text(text: &str) -> Option<String> {
320 let hex = text.strip_prefix('#')?;
321 if !matches!(hex.len(), 3 | 4 | 6 | 8) || !hex.chars().all(|ch| ch.is_ascii_hexdigit()) {
322 return None;
323 }
324
325 let lower = hex.to_ascii_lowercase();
326 let compressed = match lower.len() {
327 4 if lower.ends_with('f') => lower[..3].to_string(),
328 6 if can_shorten_hex_pairs(&lower) => shorten_hex_pairs(&lower),
329 8 if lower.ends_with("ff") && can_shorten_hex_pairs(&lower[..6]) => {
330 shorten_hex_pairs(&lower[..6])
331 }
332 8 if lower.ends_with("ff") => lower[..6].to_string(),
333 8 if can_shorten_hex_pairs(&lower) => shorten_hex_pairs(&lower),
334 _ => lower.clone(),
335 };
336 let rewritten = if matches!(lower.len(), 3 | 6) {
337 parse_static_hex_color(&format!("#{lower}"))
338 .map(shortest_static_srgb_color_text)
339 .unwrap_or_else(|| format!("#{compressed}"))
340 } else {
341 format!("#{compressed}")
342 };
343 (rewritten != text).then_some(rewritten)
344}
345
346pub fn can_shorten_hex_pairs(hex: &str) -> bool {
347 hex.as_bytes()
348 .chunks_exact(2)
349 .all(|pair| pair[0] == pair[1])
350}
351
352pub fn shorten_hex_pairs(hex: &str) -> String {
353 hex.as_bytes()
354 .chunks_exact(2)
355 .map(|pair| pair[0] as char)
356 .collect()
357}
358
359fn parse_static_color_mix_space(space: &str) -> Option<StaticColorMixSpace> {
360 match normalize_ascii_whitespace(space).as_str() {
361 "in srgb" => Some(StaticColorMixSpace::Srgb),
362 "in srgb-linear" => Some(StaticColorMixSpace::SrgbLinear),
363 _ => None,
364 }
365}
366
367fn parse_static_color_mix_stop(input: &str) -> Option<StaticColorMixStop> {
368 let (color_text, percentage) = split_static_color_mix_stop(input)?;
369 Some(StaticColorMixStop {
370 color: parse_static_color_mix_operand(&color_text)?,
371 percentage,
372 })
373}
374
375fn split_static_color_mix_stop(input: &str) -> Option<(String, Option<f64>)> {
376 let input = input.trim();
377 if input.is_empty() {
378 return None;
379 }
380
381 let mut depth = 0usize;
382 let mut bracket_depth = 0usize;
383 let mut quote: Option<char> = None;
384 let mut escaped = false;
385 let mut in_top_level_whitespace = false;
386 let mut top_level_whitespace_runs = Vec::new();
387
388 for (index, ch) in input.char_indices() {
389 if let Some(active_quote) = quote {
390 if escaped {
391 escaped = false;
392 } else if ch == '\\' {
393 escaped = true;
394 } else if ch == active_quote {
395 quote = None;
396 }
397 continue;
398 }
399
400 match ch {
401 '"' | '\'' => {
402 quote = Some(ch);
403 in_top_level_whitespace = false;
404 }
405 '(' => {
406 depth += 1;
407 in_top_level_whitespace = false;
408 }
409 ')' => {
410 depth = depth.checked_sub(1)?;
411 in_top_level_whitespace = false;
412 }
413 '[' => {
414 bracket_depth += 1;
415 in_top_level_whitespace = false;
416 }
417 ']' => {
418 bracket_depth = bracket_depth.checked_sub(1)?;
419 in_top_level_whitespace = false;
420 }
421 ch if ch.is_ascii_whitespace() && depth == 0 && bracket_depth == 0 => {
422 let whitespace_end = index + ch.len_utf8();
423 if !in_top_level_whitespace {
424 top_level_whitespace_runs.push((index, whitespace_end));
425 } else if let Some((_, end)) = top_level_whitespace_runs.last_mut() {
426 *end = whitespace_end;
427 }
428 in_top_level_whitespace = true;
429 }
430 _ => in_top_level_whitespace = false,
431 }
432 }
433
434 if quote.is_some() || depth != 0 || bracket_depth != 0 {
435 return None;
436 }
437
438 if let Some((separator_start, separator_end)) = top_level_whitespace_runs.first() {
439 let percentage = input[..*separator_start].trim();
440 let color = input[*separator_end..].trim();
441 if !color.is_empty()
442 && let Some(percentage) = parse_bounded_percentage(percentage)
443 {
444 return Some((color.to_string(), Some(percentage)));
445 }
446 }
447
448 if let Some((separator_start, separator_end)) = top_level_whitespace_runs.last() {
449 let color = input[..*separator_start].trim();
450 let percentage = input[*separator_end..].trim();
451 if !color.is_empty()
452 && let Some(percentage) = parse_bounded_percentage(percentage)
453 {
454 return Some((color.to_string(), Some(percentage)));
455 }
456 }
457
458 Some((input.to_string(), None))
459}
460
461fn parse_static_color_mix_operand(text: &str) -> Option<StaticSrgbColorWithAlpha> {
462 parse_static_srgb_color_with_alpha(text)
463 .or_else(|| parse_static_rgb_function_color_with_alpha(text))
464 .or_else(|| parse_static_hsl_function_color_with_alpha(text))
465 .or_else(|| parse_static_hwb_function_color_with_alpha(text))
466}
467
468fn color_mix_weights(first: Option<f64>, second: Option<f64>) -> Option<StaticColorMixWeights> {
469 match (first, second) {
470 (None, None) => Some(StaticColorMixWeights {
471 first_weight: 0.5,
472 second_weight: 0.5,
473 alpha_multiplier: None,
474 }),
475 (Some(first), None) => Some(StaticColorMixWeights {
476 first_weight: first,
477 second_weight: 1.0 - first,
478 alpha_multiplier: None,
479 }),
480 (None, Some(second)) => Some(StaticColorMixWeights {
481 first_weight: 1.0 - second,
482 second_weight: second,
483 alpha_multiplier: None,
484 }),
485 (Some(first), Some(second)) => {
486 let sum = first + second;
487 if sum <= 0.0 {
488 return None;
489 }
490 Some(StaticColorMixWeights {
491 first_weight: first / sum,
492 second_weight: second / sum,
493 alpha_multiplier: (sum < 1.0).then_some(sum),
494 })
495 }
496 }
497}
498
499fn mix_srgb_colors(
500 interpolation_space: StaticColorMixSpace,
501 first: StaticSrgbColorWithAlpha,
502 second: StaticSrgbColorWithAlpha,
503 first_weight: f64,
504 second_weight: f64,
505 alpha_multiplier: Option<f64>,
506) -> StaticSrgbColorWithAlpha {
507 let first_alpha = first.alpha.unwrap_or(1.0);
508 let second_alpha = second.alpha.unwrap_or(1.0);
509 let interpolated_alpha = first_alpha * first_weight + second_alpha * second_weight;
510 if interpolated_alpha <= f64::EPSILON {
511 return StaticSrgbColorWithAlpha {
512 color: SrgbColor {
513 red: 0,
514 green: 0,
515 blue: 0,
516 },
517 alpha: Some(0.0),
518 };
519 }
520
521 let final_alpha = (interpolated_alpha * alpha_multiplier.unwrap_or(1.0)).clamp(0.0, 1.0);
522 let channel_mix = StaticColorMixChannelContext {
523 interpolation_space,
524 first_alpha,
525 second_alpha,
526 first_weight,
527 second_weight,
528 interpolated_alpha,
529 };
530 StaticSrgbColorWithAlpha {
531 color: SrgbColor {
532 red: mix_premultiplied_srgb_channel(first.color.red, second.color.red, channel_mix),
533 green: mix_premultiplied_srgb_channel(
534 first.color.green,
535 second.color.green,
536 channel_mix,
537 ),
538 blue: mix_premultiplied_srgb_channel(first.color.blue, second.color.blue, channel_mix),
539 },
540 alpha: non_opaque_alpha(final_alpha),
541 }
542}
543
544fn mix_premultiplied_srgb_channel(
545 first: u8,
546 second: u8,
547 context: StaticColorMixChannelContext,
548) -> u8 {
549 let value = (f64::from(first) * context.first_alpha * context.first_weight
550 + f64::from(second) * context.second_alpha * context.second_weight)
551 / context.interpolated_alpha;
552 match context.interpolation_space {
553 StaticColorMixSpace::Srgb => value.round().clamp(0.0, 255.0) as u8,
554 StaticColorMixSpace::SrgbLinear => {
555 let first_linear = decode_srgb_channel(f64::from(first) / 255.0);
556 let second_linear = decode_srgb_channel(f64::from(second) / 255.0);
557 let value = (first_linear * context.first_alpha * context.first_weight
558 + second_linear * context.second_alpha * context.second_weight)
559 / context.interpolated_alpha;
560 encode_srgb_channel(value)
561 }
562 }
563}
564
565fn non_opaque_alpha(alpha: f64) -> Option<f64> {
566 ((alpha - 1.0).abs() > f64::EPSILON).then_some(alpha)
567}
568
569fn parse_static_hex_color(text: &str) -> Option<SrgbColor> {
570 let hex = text.strip_prefix('#')?;
571 match hex.len() {
572 3 => {
573 let mut chars = hex.chars();
574 Some(SrgbColor {
575 red: parse_repeated_hex_digit(chars.next()?)?,
576 green: parse_repeated_hex_digit(chars.next()?)?,
577 blue: parse_repeated_hex_digit(chars.next()?)?,
578 })
579 }
580 6 => Some(SrgbColor {
581 red: u8::from_str_radix(hex.get(0..2)?, 16).ok()?,
582 green: u8::from_str_radix(hex.get(2..4)?, 16).ok()?,
583 blue: u8::from_str_radix(hex.get(4..6)?, 16).ok()?,
584 }),
585 _ => None,
586 }
587}
588
589fn parse_static_hex_color_with_alpha(text: &str) -> Option<StaticSrgbColorWithAlpha> {
590 let hex = text.strip_prefix('#')?;
591 match hex.len() {
592 3 | 6 => Some(StaticSrgbColorWithAlpha {
593 color: parse_static_hex_color(text)?,
594 alpha: None,
595 }),
596 4 => {
597 let mut chars = hex.chars();
598 Some(StaticSrgbColorWithAlpha {
599 color: SrgbColor {
600 red: parse_repeated_hex_digit(chars.next()?)?,
601 green: parse_repeated_hex_digit(chars.next()?)?,
602 blue: parse_repeated_hex_digit(chars.next()?)?,
603 },
604 alpha: non_opaque_alpha(
605 f64::from(parse_repeated_hex_digit(chars.next()?)?) / 255.0,
606 ),
607 })
608 }
609 8 => {
610 let color = SrgbColor {
611 red: u8::from_str_radix(hex.get(0..2)?, 16).ok()?,
612 green: u8::from_str_radix(hex.get(2..4)?, 16).ok()?,
613 blue: u8::from_str_radix(hex.get(4..6)?, 16).ok()?,
614 };
615 let alpha = u8::from_str_radix(hex.get(6..8)?, 16).ok()?;
616 Some(StaticSrgbColorWithAlpha {
617 color,
618 alpha: non_opaque_alpha(f64::from(alpha) / 255.0),
619 })
620 }
621 _ => None,
622 }
623}
624
625fn parse_repeated_hex_digit(ch: char) -> Option<u8> {
626 let digit = ch.to_digit(16)? as u8;
627 Some(digit * 17)
628}
629
630fn parse_alpha_value(text: &str) -> Option<f64> {
631 parse_unit_interval_component(text)
632}
633
634fn non_opaque_alpha_value(text: &str) -> Option<Option<f64>> {
635 let alpha = parse_alpha_value(text)?;
636 Some(((alpha - 1.0).abs() > f64::EPSILON).then_some(alpha))
637}
638
639fn format_css_alpha(value: f64) -> String {
640 compress_number_prefix(&format_css_number(value))
641}
642
643fn parse_oklab_value(value: &str) -> Option<(SrgbColor, Option<f64>)> {
644 let inner = parse_whole_function_value_inner(value, "oklab")?;
645 let (parts, alpha) = split_ascii_space_separated_color_args_with_optional_alpha(inner)?;
646 let [lightness, a_axis, b_axis] = parts.as_slice() else {
647 return None;
648 };
649 let lightness = parse_ok_lightness(lightness)?;
650 let a_axis = parse_plain_f64(a_axis)?;
651 let b_axis = parse_plain_f64(b_axis)?;
652 Some((oklab_to_srgb(lightness, a_axis, b_axis)?, alpha))
653}
654
655fn parse_oklch_value(value: &str) -> Option<(SrgbColor, Option<f64>)> {
656 let inner = parse_whole_function_value_inner(value, "oklch")?;
657 let (parts, alpha) = split_ascii_space_separated_color_args_with_optional_alpha(inner)?;
658 let [lightness, chroma, hue] = parts.as_slice() else {
659 return None;
660 };
661 let lightness = parse_ok_lightness(lightness)?;
662 let chroma = parse_plain_f64(chroma)?;
663 let hue = parse_hue_degrees(hue)?.to_radians();
664 Some((
665 oklab_to_srgb(lightness, chroma * hue.cos(), chroma * hue.sin())?,
666 alpha,
667 ))
668}
669
670fn split_ascii_space_separated_color_args_with_optional_alpha(
671 inner: &str,
672) -> Option<(Vec<&str>, Option<f64>)> {
673 if inner.contains(',') {
674 return None;
675 }
676 let parts = inner.split_whitespace().collect::<Vec<_>>();
677 match parts.as_slice() {
678 [first, second, third] => Some((vec![*first, *second, *third], None)),
679 [first, second, third, "/", alpha] => Some((
680 vec![*first, *second, *third],
681 non_opaque_alpha_value(alpha)?,
682 )),
683 _ => None,
684 }
685}
686
687fn parse_ok_lightness(text: &str) -> Option<f64> {
688 let value = if let Some(percent) = text.strip_suffix('%') {
689 parse_plain_f64(percent)? / 100.0
690 } else {
691 parse_plain_f64(text)?
692 };
693 value
694 .is_finite()
695 .then_some(value)
696 .filter(|value| *value >= 0.0 && *value <= 1.0)
697}
698
699fn parse_hue_degrees(text: &str) -> Option<f64> {
700 let lower = text.to_ascii_lowercase();
701 let value = if lower.ends_with("deg") {
702 parse_plain_f64(text.get(..text.len() - 3)?)?
703 } else if lower.ends_with("turn") {
704 parse_plain_f64(text.get(..text.len() - 4)?)? * 360.0
705 } else if lower.ends_with("grad") {
706 parse_plain_f64(text.get(..text.len() - 4)?)? * 0.9
707 } else if lower.ends_with("rad") {
708 parse_plain_f64(text.get(..text.len() - 3)?)?.to_degrees()
709 } else {
710 parse_plain_f64(text)?
711 };
712 value.is_finite().then_some(value)
713}
714
715fn parse_plain_f64(text: &str) -> Option<f64> {
716 if text.contains('%') {
717 return None;
718 }
719 text.parse::<f64>().ok().filter(|value| value.is_finite())
720}
721
722fn parse_srgb_component(text: &str) -> Option<u8> {
723 Some((parse_unit_interval_component(text)? * 255.0).round() as u8)
724}
725
726fn parse_unit_interval_component(text: &str) -> Option<f64> {
727 let value = if let Some(percent) = text.strip_suffix('%') {
728 parse_plain_f64(percent)? / 100.0
729 } else {
730 parse_plain_f64(text)?
731 };
732 if !(0.0..=1.0).contains(&value) {
733 return None;
734 }
735 Some(value)
736}
737
738fn display_p3_to_srgb(red: f64, green: f64, blue: f64) -> Option<SrgbColor> {
739 let red_linear = decode_srgb_channel(red);
740 let green_linear = decode_srgb_channel(green);
741 let blue_linear = decode_srgb_channel(blue);
742
743 let x = 0.486_570_948_648_216_2 * red_linear
744 + 0.265_667_693_169_093_1 * green_linear
745 + 0.198_217_285_234_362_5 * blue_linear;
746 let y = 0.228_974_564_069_748_8 * red_linear
747 + 0.691_738_521_836_506_4 * green_linear
748 + 0.079_286_914_093_745 * blue_linear;
749 let z = 0.045_113_381_858_902_6 * green_linear + 1.043_944_368_900_976 * blue_linear;
750
751 let red_linear_srgb =
752 3.240_969_941_904_522_6 * x - 1.537_383_177_570_094 * y - 0.498_610_760_293_003_4 * z;
753 let green_linear_srgb =
754 -0.969_243_636_280_879_6 * x + 1.875_967_501_507_720_2 * y + 0.041_555_057_407_175_59 * z;
755 let blue_linear_srgb =
756 0.055_630_079_696_993_66 * x - 0.203_976_958_888_976_52 * y + 1.056_971_514_242_878_6 * z;
757
758 if !is_in_gamut_linear_srgb(red_linear_srgb)
759 || !is_in_gamut_linear_srgb(green_linear_srgb)
760 || !is_in_gamut_linear_srgb(blue_linear_srgb)
761 {
762 return None;
763 }
764
765 Some(SrgbColor {
766 red: encode_srgb_channel(red_linear_srgb),
767 green: encode_srgb_channel(green_linear_srgb),
768 blue: encode_srgb_channel(blue_linear_srgb),
769 })
770}
771
772fn decode_srgb_channel(value: f64) -> f64 {
773 if value <= 0.040_45 {
774 value / 12.92
775 } else {
776 ((value + 0.055) / 1.055).powf(2.4)
777 }
778}
779
780fn oklab_to_srgb(lightness: f64, a_axis: f64, b_axis: f64) -> Option<SrgbColor> {
781 let l_prime = lightness + 0.396_337_777_4 * a_axis + 0.215_803_757_3 * b_axis;
782 let m_prime = lightness - 0.105_561_345_8 * a_axis - 0.063_854_172_8 * b_axis;
783 let s_prime = lightness - 0.089_484_177_5 * a_axis - 1.291_485_548_0 * b_axis;
784
785 let l = l_prime.powi(3);
786 let m = m_prime.powi(3);
787 let s = s_prime.powi(3);
788
789 let red_linear = 4.076_741_662_1 * l - 3.307_711_591_3 * m + 0.230_969_929_2 * s;
790 let green_linear = -1.268_438_004_6 * l + 2.609_757_401_1 * m - 0.341_319_396_5 * s;
791 let blue_linear = -0.004_196_086_3 * l - 0.703_418_614_7 * m + 1.707_614_701_0 * s;
792
793 if !is_in_gamut_linear_srgb(red_linear)
794 || !is_in_gamut_linear_srgb(green_linear)
795 || !is_in_gamut_linear_srgb(blue_linear)
796 {
797 return None;
798 }
799
800 Some(SrgbColor {
801 red: encode_srgb_channel(red_linear),
802 green: encode_srgb_channel(green_linear),
803 blue: encode_srgb_channel(blue_linear),
804 })
805}
806
807fn is_in_gamut_linear_srgb(value: f64) -> bool {
808 (-0.000_001..=1.000_001).contains(&value)
809}
810
811fn encode_srgb_channel(value: f64) -> u8 {
812 let clamped = value.clamp(0.0, 1.0);
813 let encoded = if clamped <= 0.003_130_8 {
814 12.92 * clamped
815 } else {
816 1.055 * clamped.powf(1.0 / 2.4) - 0.055
817 };
818 (encoded * 255.0).round().clamp(0.0, 255.0) as u8
819}
820
821fn split_static_color_channels_with_optional_alpha(
822 inner: &str,
823) -> Option<(Vec<String>, Option<f64>)> {
824 if inner.contains(',') {
825 if inner.contains('/') {
826 return None;
827 }
828 let arguments = split_top_level_value_arguments_owned(inner)?;
829 return match arguments.as_slice() {
830 [first, second, third] => {
831 Some((vec![first.clone(), second.clone(), third.clone()], None))
832 }
833 [first, second, third, alpha] => Some((
834 vec![first.clone(), second.clone(), third.clone()],
835 non_opaque_alpha_value(alpha)?,
836 )),
837 _ => None,
838 };
839 }
840
841 let parts = inner.split_whitespace().collect::<Vec<_>>();
842 match parts.as_slice() {
843 [first, second, third] => Some((
844 vec![
845 (*first).to_string(),
846 (*second).to_string(),
847 (*third).to_string(),
848 ],
849 None,
850 )),
851 [first, second, third, "/", alpha] => Some((
852 vec![
853 (*first).to_string(),
854 (*second).to_string(),
855 (*third).to_string(),
856 ],
857 non_opaque_alpha_value(alpha)?,
858 )),
859 _ => None,
860 }
861}
862
863fn parse_bounded_percentage(text: &str) -> Option<f64> {
864 let value = parse_plain_f64(text.trim().strip_suffix('%')?)?;
865 if !(0.0..=100.0).contains(&value) {
866 return None;
867 }
868 Some(value / 100.0)
869}
870
871fn hwb_to_srgb(hue_degrees: f64, whiteness: f64, blackness: f64) -> Option<SrgbColor> {
872 if !hue_degrees.is_finite() || !whiteness.is_finite() || !blackness.is_finite() {
873 return None;
874 }
875
876 if whiteness + blackness >= 1.0 {
877 let gray = whiteness / (whiteness + blackness);
878 return Some(SrgbColor {
879 red: encode_css_rgb_component(gray),
880 green: encode_css_rgb_component(gray),
881 blue: encode_css_rgb_component(gray),
882 });
883 }
884
885 let pure = hsl_to_srgb(hue_degrees, 1.0, 0.5)?;
886 let scale = 1.0 - whiteness - blackness;
887 Some(SrgbColor {
888 red: mix_hwb_channel(pure.red, scale, whiteness),
889 green: mix_hwb_channel(pure.green, scale, whiteness),
890 blue: mix_hwb_channel(pure.blue, scale, whiteness),
891 })
892}
893
894fn mix_hwb_channel(channel: u8, scale: f64, whiteness: f64) -> u8 {
895 ((f64::from(channel) / 255.0) * scale + whiteness)
896 .mul_add(255.0, 0.0)
897 .round()
898 .clamp(0.0, 255.0) as u8
899}
900
901fn hsl_to_srgb(hue_degrees: f64, saturation: f64, lightness: f64) -> Option<SrgbColor> {
902 if !hue_degrees.is_finite() || !saturation.is_finite() || !lightness.is_finite() {
903 return None;
904 }
905
906 let hue = hue_degrees.rem_euclid(360.0);
907 let chroma = (1.0 - (2.0 * lightness - 1.0).abs()) * saturation;
908 let hue_sector = hue / 60.0;
909 let x = chroma * (1.0 - (hue_sector.rem_euclid(2.0) - 1.0).abs());
910 let (red1, green1, blue1) = match hue_sector.floor() as u8 {
911 0 => (chroma, x, 0.0),
912 1 => (x, chroma, 0.0),
913 2 => (0.0, chroma, x),
914 3 => (0.0, x, chroma),
915 4 => (x, 0.0, chroma),
916 _ => (chroma, 0.0, x),
917 };
918 let offset = lightness - chroma / 2.0;
919
920 Some(SrgbColor {
921 red: encode_css_rgb_component(red1 + offset),
922 green: encode_css_rgb_component(green1 + offset),
923 blue: encode_css_rgb_component(blue1 + offset),
924 })
925}
926
927fn encode_css_rgb_component(value: f64) -> u8 {
928 (value * 255.0).round().clamp(0.0, 255.0) as u8
929}
930
931fn parse_rgb_component_byte(text: &str) -> Option<u8> {
932 if let Some(percent) = text.trim().strip_suffix('%') {
933 let value = parse_plain_f64(percent)?;
934 if !(0.0..=100.0).contains(&value) {
935 return None;
936 }
937 return Some(((value / 100.0) * 255.0).round().clamp(0.0, 255.0) as u8);
938 }
939
940 let value = parse_plain_f64(text.trim())?;
941 if !(0.0..=255.0).contains(&value) {
942 return None;
943 }
944 Some(value.round().clamp(0.0, 255.0) as u8)
945}
946
947fn shortest_static_srgb_color_text(color: SrgbColor) -> String {
948 let hex = compressed_hex_color_for_srgb(color);
949 match shortest_named_srgb_color(color) {
950 Some(name) if name.len() < hex.len() => name.to_string(),
951 _ => hex,
952 }
953}
954
955fn compressed_hex_color_for_srgb(color: SrgbColor) -> String {
956 let hex = format!("{:02x}{:02x}{:02x}", color.red, color.green, color.blue);
957 let compressed = if can_shorten_hex_pairs(&hex) {
958 shorten_hex_pairs(&hex)
959 } else {
960 hex
961 };
962 format!("#{compressed}")
963}
964
965fn compressed_hex_color_for_srgb_with_alpha(color: SrgbColor, alpha: f64) -> String {
966 let alpha = encode_css_rgb_component(alpha);
967 let hex = format!(
968 "{:02x}{:02x}{:02x}{:02x}",
969 color.red, color.green, color.blue, alpha
970 );
971 let compressed = if can_shorten_hex_pairs(&hex) {
972 shorten_hex_pairs(&hex)
973 } else {
974 hex
975 };
976 format!("#{compressed}")
977}
978
979fn normalize_ascii_whitespace(value: &str) -> String {
980 value.split_whitespace().collect::<Vec<_>>().join(" ")
981}