Skip to main content

omena_value_lattice/
color.rs

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
61/// Relative-color channel names for a color function family, per CSS Color 5
62/// `<colorfn>(from <color> <ch>… [/ alpha])`. Returns the ordered channel
63/// identifiers, or `None` if the function defines no fixed relative form
64/// (`color(from …)` channels are colorspace-dependent and excluded here).
65///
66/// Recognition vocabulary only: this crate does not fold `rgb(from …)` — such a
67/// value round-trips as `Raw`/identity until a dedicated lowering consumes it.
68pub 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
80/// Whether `value` is a relative-color form `<colorfn>(from …)` (CSS Color 5),
81/// including colorspace-keyed `color(from …)`. Recognition only — the form is
82/// never folded; it round-trips byte-faithfully.
83pub 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
192/// Lowers a static relative color `rgb(from <static-color> <r> <g> <b> [/ <a>])`
193/// (CSS Color 5) to its absolute sRGB form. Each channel resolves either to the
194/// origin color's matching channel (`r`/`g`/`b`) or to a literal sRGB component;
195/// alpha resolves to the origin alpha (`alpha`) or a literal. Returns `None` for
196/// any non-static origin, non-`rgb`/`rgba` family, or unresolved channel so the
197/// value is preserved verbatim.
198pub fn parse_relative_color_value(value: &str) -> Option<String> {
199    // Gate on the Wave-1 recognition vocabulary: only fold a recognized
200    // relative-color form, and only the rgb/rgba channel family ([r, g, b, alpha])
201    // this layer resolves on the string domain. Other families/non-relative forms
202    // stay unfolded (preserved verbatim).
203    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
240// NOTE: a channel reference resolves to the origin's matching channel BY NAME
241// (`r`->red, `g`->green, `b`->blue), so a swap like `from c b g r` is honoured;
242// only a literal sRGB component is otherwise decidable. Anything else stays unfolded.
243fn 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}