use crate::prelude::*;
use chromashift::{COLOR_EPSILON, ColorDistance, ColorSpace, Hex, Named, PerceptualRound, Srgb, ToAlpha, round_dp};
use css_ast::{
Color, ColorFunction, ColorMixFunction, HueInterpolationDirection, InterpolationColorSpace, ToChromashift,
Visitable,
};
pub struct ReduceColors<'a, 'ctx, N: Visitable + NodeWithMetadata<CssMetadata>> {
pub transformer: &'ctx Transformer<'a, CssMetadata, N, CssMinifierFeature>,
replacing_outer: bool,
}
impl<'a, 'ctx, N> Transform<'a, 'ctx, CssMetadata, N, CssMinifierFeature> for ReduceColors<'a, 'ctx, N>
where
N: Visitable + NodeWithMetadata<CssMetadata>,
{
fn may_change(features: CssMinifierFeature, _node: &N) -> bool {
features.contains(CssMinifierFeature::ReduceColors)
}
fn new(transformer: &'ctx Transformer<'a, CssMetadata, N, CssMinifierFeature>) -> Self {
Self { transformer, replacing_outer: false }
}
}
trait Shortest {
fn shortest(&self) -> Option<String>;
}
impl Shortest for chromashift::Color {
fn shortest(&self) -> Option<String> {
[
Some(Hex::from(*self).to_string()),
Named::try_from(*self).ok().map(|named| named.to_string()),
Some(Srgb::from(*self).round().to_string()),
]
.into_iter()
.flatten()
.min_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b)))
}
}
fn css_alpha(alpha: f32) -> Option<String> {
if alpha >= 100.0 {
return None;
}
Some(format!("{}", round_dp(alpha as f64 / 100.0, 3)))
}
trait ToCss {
fn to_css(&self) -> Option<String>;
}
macro_rules! impl_to_css_3ch {
($ty:ident, $name:literal, $c1:ident, $c2:ident, $c3:ident) => {
impl ToCss for chromashift::$ty {
fn to_css(&self) -> Option<String> {
let alpha = css_alpha(self.alpha);
if let Some(a) = alpha {
Some(format!(concat!($name, "({} {} {} / {})"), self.$c1, self.$c2, self.$c3, a))
} else {
Some(format!(concat!($name, "({} {} {})"), self.$c1, self.$c2, self.$c3))
}
}
}
};
($ty:ident, $name:literal, $c1:ident, $c2:ident: $suf2:literal, $c3:ident: $suf3:literal) => {
impl ToCss for chromashift::$ty {
fn to_css(&self) -> Option<String> {
let alpha = css_alpha(self.alpha);
if let Some(a) = alpha {
Some(format!(
concat!($name, "({} {}", $suf2, " {}", $suf3, " / {})"),
self.$c1, self.$c2, self.$c3, a
))
} else {
Some(format!(concat!($name, "({} {}", $suf2, " {}", $suf3, ")"), self.$c1, self.$c2, self.$c3))
}
}
}
};
}
macro_rules! impl_to_css_color_fn {
($ty:ident, $space:literal) => {
impl ToCss for chromashift::$ty {
fn to_css(&self) -> Option<String> {
let alpha = css_alpha(self.alpha);
if let Some(a) = alpha {
Some(format!(concat!("color(", $space, " {} {} {} / {})"), self.red, self.green, self.blue, a))
} else {
Some(format!(concat!("color(", $space, " {} {} {})"), self.red, self.green, self.blue))
}
}
}
};
}
macro_rules! impl_to_css_xyz {
($ty:ident, $space:literal) => {
impl ToCss for chromashift::$ty {
fn to_css(&self) -> Option<String> {
let alpha = css_alpha(self.alpha);
let x = round_dp(self.x / 100.0, 4);
let y = round_dp(self.y / 100.0, 4);
let z = round_dp(self.z / 100.0, 4);
if let Some(a) = alpha {
Some(format!(concat!("color(", $space, " {} {} {} / {})"), x, y, z, a))
} else {
Some(format!(concat!("color(", $space, " {} {} {})"), x, y, z))
}
}
}
};
}
impl_to_css_3ch!(Lab, "lab", lightness, a, b);
impl_to_css_3ch!(Lch, "lch", lightness, chroma, hue);
impl_to_css_3ch!(Oklab, "oklab", lightness, a, b);
impl_to_css_3ch!(Oklch, "oklch", lightness, chroma, hue);
impl_to_css_3ch!(Hsl, "hsl", hue, saturation: "%", lightness: "%");
impl_to_css_3ch!(Hwb, "hwb", hue, whiteness: "%", blackness: "%");
impl_to_css_color_fn!(DisplayP3, "display-p3");
impl_to_css_color_fn!(LinearRgb, "srgb-linear");
impl_to_css_color_fn!(A98Rgb, "a98-rgb");
impl_to_css_color_fn!(ProphotoRgb, "prophoto-rgb");
impl_to_css_color_fn!(Rec2020, "rec2020");
impl_to_css_xyz!(XyzD50, "xyz-d50");
impl_to_css_xyz!(XyzD65, "xyz-d65");
impl ToCss for chromashift::Color {
fn to_css(&self) -> Option<String> {
match self {
chromashift::Color::Lab(c) => c.to_css(),
chromashift::Color::Lch(c) => c.to_css(),
chromashift::Color::Oklab(c) => c.to_css(),
chromashift::Color::Oklch(c) => c.to_css(),
chromashift::Color::Hsl(c) => c.to_css(),
chromashift::Color::Hwb(c) => c.to_css(),
chromashift::Color::DisplayP3(c) => c.to_css(),
chromashift::Color::LinearRgb(c) => c.to_css(),
chromashift::Color::A98Rgb(c) => c.to_css(),
chromashift::Color::ProphotoRgb(c) => c.to_css(),
chromashift::Color::Rec2020(c) => c.to_css(),
chromashift::Color::XyzD50(c) => c.to_css(),
chromashift::Color::XyzD65(c) => c.to_css(),
chromashift::Color::Hex(_)
| chromashift::Color::Named(_)
| chromashift::Color::Srgb(_)
| chromashift::Color::Hsv(_) => None,
}
}
}
impl<'a, 'ctx, N> Visit for ReduceColors<'a, 'ctx, N>
where
N: Visitable + NodeWithMetadata<CssMetadata>,
{
fn visit_color(&mut self, color: &Color) {
if self.replacing_outer {
return;
}
if let Color::Function(colorfn) = color
&& matches!(**colorfn, ColorFunction::ColorMix(_))
{
return;
}
let Some(chroma_color) = color.to_chromashift() else {
return;
};
let len = color.to_span().len() as usize;
if chroma_color.in_gamut_of(ColorSpace::Srgb)
&& let Some(candidate) = chroma_color.shortest()
&& candidate.len() < len
{
self.transformer.replace_parsed::<Color>(color.to_span(), &candidate);
return;
}
let rounded = chroma_color.round();
if let Some(css) = rounded.to_css()
&& css.len() < len
{
self.transformer.replace_parsed::<Color>(color.to_span(), &css);
}
}
fn visit_color_mix_function<'b>(&mut self, mix: &ColorMixFunction<'b>) {
let outer_span = mix.to_span();
let outer_len = outer_span.len() as usize;
let n = mix.parts.len();
let default_pct = 100.0 / n as f32;
let explicit_sum: f32 =
(&mix.parts).into_iter().filter_map(|(p, _)| p.percentage.as_ref().map(|pct| pct.value())).sum();
let implicit_count = (&mix.parts).into_iter().filter(|(p, _)| p.percentage.is_none()).count();
let implicit_share = if implicit_count > 0 { (100.0 - explicit_sum) / implicit_count as f32 } else { 0.0 };
let pcts: Vec<f32> = (&mix.parts)
.into_iter()
.map(|(p, _)| p.percentage.as_ref().map(|pct| pct.value()).unwrap_or(implicit_share))
.collect();
let sum: f32 = pcts.iter().sum();
if sum == 100.0
&& let Some(dominant) = pcts.iter().position(|&p| p == 100.0)
{
{
let (part, _) = &mix.parts[dominant];
let chroma = part.color.to_chromashift();
let str = chroma.and_then(|c| c.shortest()).unwrap_or_else(|| {
let span = part.color.to_span();
self.transformer.source_text[span.start().0 as usize..span.end().0 as usize].to_string()
});
self.transformer.clear_pending_edits(outer_span);
self.transformer.replace_parsed::<Color>(outer_span, &str);
self.replacing_outer = true;
return;
}
}
if sum >= 100.0 {
let chromata: Vec<_> = (&mix.parts).into_iter().map(|(p, _)| p.color.to_chromashift()).collect();
if chromata.iter().all(Option::is_some) {
let first = chromata[0].unwrap();
if chromata[1..].iter().all(|c| c.unwrap().delta_e(first) < COLOR_EPSILON) {
let (part, _) = &mix.parts[0];
let str = first.shortest().unwrap_or_else(|| {
let span = part.color.to_span();
self.transformer.source_text[span.start().0 as usize..span.end().0 as usize].to_string()
});
self.transformer.clear_pending_edits(outer_span);
self.transformer.replace_parsed::<Color>(outer_span, &str);
self.replacing_outer = true;
return;
}
}
}
let all_known = (&mix.parts).into_iter().all(|(p, _)| p.color.to_chromashift().is_some());
if all_known && let Some(mixed) = mix.to_chromashift() {
let alpha_mult = (sum as f64 / 100.0).min(1.0);
let mixed_alpha = (mixed.to_alpha() as f64 / 100.0 * alpha_mult * 100.0) as f32;
let mixed = mixed.with_alpha(mixed_alpha);
let rounded = mixed.round();
let native_css = rounded.to_css();
let srgb_css = if mixed.in_gamut_of(ColorSpace::Srgb) { mixed.shortest() } else { None };
let candidate =
native_css.into_iter().chain(srgb_css).min_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b)));
if let Some(ref candidate) = candidate
&& candidate.len() < outer_len
{
self.transformer.replace_parsed::<Color>(outer_span, candidate);
self.replacing_outer = true;
return;
}
}
if (sum - 100.0).abs() < 0.01 {
for ((part, _), &effective) in (&mix.parts).into_iter().zip(&pcts) {
if let Some(ref pct) = part.percentage
&& (effective - default_pct).abs() < 0.01
{
self.transformer.delete(pct.to_span());
}
}
}
if let Some(ref interp) = mix.interpolation
&& let InterpolationColorSpace::Polar(_, Some(ref hue_method)) = interp.color_space
&& matches!(hue_method.direction, HueInterpolationDirection::Shorter(_))
{
self.transformer.delete(hue_method.to_span());
}
}
fn exit_color_mix_function<'b>(&mut self, _mix: &ColorMixFunction<'b>) {
self.replacing_outer = false;
}
}
#[cfg(test)]
mod tests {
use crate::test_helpers::{assert_no_transform, assert_transform};
use css_ast::{CssAtomSet, StyleSheet};
#[test]
fn reduces_full_length_hex() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"body { color: #ffffff; }",
"body { color: #fff; }"
);
}
#[test]
fn prefers_shorthand_hex_over_keyword() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"body { color: #000000; }",
"body { color: #000; }"
);
}
#[test]
fn prefers_named_over_rgb() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"body { color: rgb(210, 180, 140); }",
"body { color: tan; }"
);
}
#[test]
fn shortens_alpha_hex() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"body { color: rgba(255, 0, 0, 0.5); }",
"body { color: #ff000080; }"
);
}
#[test]
fn no_transform_when_already_short() {
assert_no_transform!(CssMinifierFeature::ReduceColors, CssAtomSet, StyleSheet, "body { color: red; }");
}
#[test]
fn no_transform_for_currentcolor() {
assert_no_transform!(CssMinifierFeature::ReduceColors, CssAtomSet, StyleSheet, "body { color: currentcolor; }");
}
#[test]
fn reduces_color_srgb_function() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"a { color: color(srgb 1 0 0); }",
"a { color: red; }"
);
}
#[test]
fn reduces_in_gamut_display_p3_to_shortest() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"a { color: color(display-p3 0.5 0.5 0.5); }",
"a { color: gray; }"
);
}
#[test]
fn no_transform_for_out_of_gamut_display_p3() {
assert_no_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"a { color: color(display-p3 1 0 0); }"
);
}
#[test]
fn color_mix_100_percent_first() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"a { color: color-mix(in srgb, red 100%, blue); }",
"a { color: red; }"
);
}
#[test]
fn color_mix_0_percent_first() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"a { color: color-mix(in srgb, red 0%, blue); }",
"a { color: #00f; }"
);
}
#[test]
fn color_mix_all_zero_percent() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"a { color: color-mix(in srgb, red 0%, green 0%, blue 0%); }",
"a { color: #0000; }"
);
}
#[test]
fn color_mix_same_color_both_sides() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"a { color: color-mix(in srgb, red, red); }",
"a { color: red; }"
);
}
#[test]
fn color_mix_removes_redundant_50_50() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"a { color: color-mix(in srgb, currentcolor 50%, red 50%); }",
"a { color: color-mix(in srgb, currentcolor, red); }"
);
}
#[test]
fn color_mix_removes_redundant_equal_n_way_split() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"a { color: color-mix(in srgb, currentcolor 33.33%, red 33.33%, blue 33.33%); }",
"a { color: color-mix(in srgb, currentcolor, red, blue); }"
);
}
#[test]
fn color_mix_removes_single_redundant_50() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"a { color: color-mix(in srgb, currentcolor 50%, red); }",
"a { color: color-mix(in srgb, currentcolor, red); }"
);
}
#[test]
fn color_mix_removes_shorter_hue() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"a { color: color-mix(in oklch shorter hue, currentcolor, red); }",
"a { color: color-mix(in oklch, currentcolor, red); }"
);
}
#[test]
fn color_mix_keeps_longer_hue() {
assert_no_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"a { color: color-mix(in oklch longer hue,currentcolor,red); }"
);
}
#[test]
fn color_mix_no_transform_when_already_compact() {
assert_no_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"a { color: color-mix(in oklch longer hue,currentcolor,red); }"
);
}
#[test]
fn color_mix_minifies_inner_colors() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"a { color: color-mix(in oklch, rgba(255, 255, 255, 1), currentcolor); }",
"a { color: color-mix(in oklch, #fff, currentcolor); }"
);
}
#[test]
fn color_mix_minifies_inner_rgb_to_named() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"a { color: color-mix(in srgb, hsl(0, 100%, 50%), currentcolor); }",
"a { color: color-mix(in srgb, red, currentcolor); }"
);
}
#[test]
fn color_mix_mixes_static_colors() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"a { color: color-mix(in srgb, red, blue); }",
"a { color: purple; }"
);
}
#[test]
fn color_mix_normalizes_percentages_over_100() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"a { color: color-mix(in srgb, red 80%, blue 40%); }",
"a { color: #a05; }"
);
}
#[test]
fn color_mix_alpha_multiplier_under_100() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"a { color: color-mix(in srgb, red 30%, blue 30%); }",
"a { color: #80008099; }"
);
}
#[test]
fn color_mix_no_100_shortcircuit_when_both_explicit() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"a { color: color-mix(in srgb, red 100%, blue 50%); }",
"a { color: #a05; }"
);
}
#[test]
fn color_mix_oklch_out_of_gamut_uses_native_space() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"a { color: color-mix(in oklch, lime, blue); }",
"a { color: oklch(0.659 0.304 203.3); }"
);
}
#[test]
fn color_mix_none_channel_adopts_other() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"a { color: color-mix(in srgb, rgb(none 0 0) 50%, rgb(200 0 0) 50%); }",
"a { color: #c80000; }"
);
}
#[test]
fn rgb_none_channel_resolves_to_zero() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"a { color: rgb(none 128 0); }",
"a { color: green; }"
);
}
#[test]
fn relative_rgb_static_channels_minified() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"a { color: rgb(from red 200 g b); }",
"a { color: #c80000; }"
);
}
#[test]
fn relative_rgb_all_keywords_passthrough() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"a { color: rgb(from red r g b); }",
"a { color: red; }"
);
}
#[test]
fn relative_rgb_all_static_minified() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"a { color: rgb(from blue 255 255 0); }",
"a { color: #ff0; }"
);
}
#[test]
fn relative_hsl_keywords_passthrough() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"a { color: hsl(from green h s l); }",
"a { color: green; }"
);
}
#[test]
fn relative_oklch_static_produces_named() {
assert_transform!(
CssMinifierFeature::ReduceColors,
CssAtomSet,
StyleSheet,
"a { color: oklch(from red l c h); }",
"a { color: red; }"
);
}
}