1use crate::prelude::*;
2use chromashift::{COLOR_EPSILON, ColorDistance, ColorSpace, Hex, Named, PerceptualRound, Srgb, ToAlpha, round_dp};
3use css_ast::{
4 Color, ColorFunction, ColorMixFunction, HueInterpolationDirection, InterpolationColorSpace, ToChromashift,
5 Visitable,
6};
7
8pub struct ReduceColors<'a, 'ctx, N: Visitable + NodeWithMetadata<CssMetadata>> {
9 pub transformer: &'ctx Transformer<'a, CssMetadata, N, CssMinifierFeature>,
10 replacing_outer: bool,
13}
14
15impl<'a, 'ctx, N> Transform<'a, 'ctx, CssMetadata, N, CssMinifierFeature> for ReduceColors<'a, 'ctx, N>
16where
17 N: Visitable + NodeWithMetadata<CssMetadata>,
18{
19 fn may_change(features: CssMinifierFeature, _node: &N) -> bool {
20 features.contains(CssMinifierFeature::ReduceColors)
21 }
22
23 fn new(transformer: &'ctx Transformer<'a, CssMetadata, N, CssMinifierFeature>) -> Self {
24 Self { transformer, replacing_outer: false }
25 }
26}
27
28trait Shortest {
29 fn shortest(&self) -> Option<String>;
30}
31
32impl Shortest for chromashift::Color {
33 fn shortest(&self) -> Option<String> {
34 [
35 Some(Hex::from(*self).to_string()),
36 Named::try_from(*self).ok().map(|named| named.to_string()),
37 Some(Srgb::from(*self).round().to_string()),
38 ]
39 .into_iter()
40 .flatten()
41 .min_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b)))
42 }
43}
44
45fn css_alpha(alpha: f32) -> Option<String> {
48 if alpha >= 100.0 {
49 return None;
50 }
51 Some(format!("{}", round_dp(alpha as f64 / 100.0, 3)))
52}
53
54trait ToCss {
61 fn to_css(&self) -> Option<String>;
62}
63
64macro_rules! impl_to_css_3ch {
65 ($ty:ident, $name:literal, $c1:ident, $c2:ident, $c3:ident) => {
66 impl ToCss for chromashift::$ty {
67 fn to_css(&self) -> Option<String> {
68 let alpha = css_alpha(self.alpha);
69 if let Some(a) = alpha {
70 Some(format!(concat!($name, "({} {} {} / {})"), self.$c1, self.$c2, self.$c3, a))
71 } else {
72 Some(format!(concat!($name, "({} {} {})"), self.$c1, self.$c2, self.$c3))
73 }
74 }
75 }
76 };
77 ($ty:ident, $name:literal, $c1:ident, $c2:ident: $suf2:literal, $c3:ident: $suf3:literal) => {
78 impl ToCss for chromashift::$ty {
79 fn to_css(&self) -> Option<String> {
80 let alpha = css_alpha(self.alpha);
81 if let Some(a) = alpha {
82 Some(format!(
83 concat!($name, "({} {}", $suf2, " {}", $suf3, " / {})"),
84 self.$c1, self.$c2, self.$c3, a
85 ))
86 } else {
87 Some(format!(concat!($name, "({} {}", $suf2, " {}", $suf3, ")"), self.$c1, self.$c2, self.$c3))
88 }
89 }
90 }
91 };
92}
93
94macro_rules! impl_to_css_color_fn {
95 ($ty:ident, $space:literal) => {
96 impl ToCss for chromashift::$ty {
97 fn to_css(&self) -> Option<String> {
98 let alpha = css_alpha(self.alpha);
99 if let Some(a) = alpha {
100 Some(format!(concat!("color(", $space, " {} {} {} / {})"), self.red, self.green, self.blue, a))
101 } else {
102 Some(format!(concat!("color(", $space, " {} {} {})"), self.red, self.green, self.blue))
103 }
104 }
105 }
106 };
107}
108
109macro_rules! impl_to_css_xyz {
110 ($ty:ident, $space:literal) => {
111 impl ToCss for chromashift::$ty {
112 fn to_css(&self) -> Option<String> {
113 let alpha = css_alpha(self.alpha);
114 let x = round_dp(self.x / 100.0, 4);
118 let y = round_dp(self.y / 100.0, 4);
119 let z = round_dp(self.z / 100.0, 4);
120 if let Some(a) = alpha {
121 Some(format!(concat!("color(", $space, " {} {} {} / {})"), x, y, z, a))
122 } else {
123 Some(format!(concat!("color(", $space, " {} {} {})"), x, y, z))
124 }
125 }
126 }
127 };
128}
129
130impl_to_css_3ch!(Lab, "lab", lightness, a, b);
131impl_to_css_3ch!(Lch, "lch", lightness, chroma, hue);
132impl_to_css_3ch!(Oklab, "oklab", lightness, a, b);
133impl_to_css_3ch!(Oklch, "oklch", lightness, chroma, hue);
134impl_to_css_3ch!(Hsl, "hsl", hue, saturation: "%", lightness: "%");
135impl_to_css_3ch!(Hwb, "hwb", hue, whiteness: "%", blackness: "%");
136
137impl_to_css_color_fn!(DisplayP3, "display-p3");
138impl_to_css_color_fn!(LinearRgb, "srgb-linear");
139impl_to_css_color_fn!(A98Rgb, "a98-rgb");
140impl_to_css_color_fn!(ProphotoRgb, "prophoto-rgb");
141impl_to_css_color_fn!(Rec2020, "rec2020");
142
143impl_to_css_xyz!(XyzD50, "xyz-d50");
144impl_to_css_xyz!(XyzD65, "xyz-d65");
145
146impl ToCss for chromashift::Color {
147 fn to_css(&self) -> Option<String> {
148 match self {
149 chromashift::Color::Lab(c) => c.to_css(),
150 chromashift::Color::Lch(c) => c.to_css(),
151 chromashift::Color::Oklab(c) => c.to_css(),
152 chromashift::Color::Oklch(c) => c.to_css(),
153 chromashift::Color::Hsl(c) => c.to_css(),
154 chromashift::Color::Hwb(c) => c.to_css(),
155 chromashift::Color::DisplayP3(c) => c.to_css(),
156 chromashift::Color::LinearRgb(c) => c.to_css(),
157 chromashift::Color::A98Rgb(c) => c.to_css(),
158 chromashift::Color::ProphotoRgb(c) => c.to_css(),
159 chromashift::Color::Rec2020(c) => c.to_css(),
160 chromashift::Color::XyzD50(c) => c.to_css(),
161 chromashift::Color::XyzD65(c) => c.to_css(),
162 chromashift::Color::Hex(_)
164 | chromashift::Color::Named(_)
165 | chromashift::Color::Srgb(_)
166 | chromashift::Color::Hsv(_) => None,
167 }
168 }
169}
170
171impl<'a, 'ctx, N> Visit for ReduceColors<'a, 'ctx, N>
172where
173 N: Visitable + NodeWithMetadata<CssMetadata>,
174{
175 fn visit_color(&mut self, color: &Color) {
176 if self.replacing_outer {
177 return;
178 }
179 if let Color::Function(colorfn) = color
181 && matches!(**colorfn, ColorFunction::ColorMix(_))
182 {
183 return;
184 }
185 let Some(chroma_color) = color.to_chromashift() else {
186 return;
187 };
188 let len = color.to_span().len() as usize;
189
190 if chroma_color.in_gamut_of(ColorSpace::Srgb)
191 && let Some(candidate) = chroma_color.shortest()
192 && candidate.len() < len
193 {
194 self.transformer.replace_parsed::<Color>(color.to_span(), &candidate);
195 return;
196 }
197
198 let rounded = chroma_color.round();
201 if let Some(css) = rounded.to_css()
202 && css.len() < len
203 {
204 self.transformer.replace_parsed::<Color>(color.to_span(), &css);
205 }
206 }
207
208 fn visit_color_mix_function<'b>(&mut self, mix: &ColorMixFunction<'b>) {
209 let outer_span = mix.to_span();
210 let outer_len = outer_span.len() as usize;
211
212 let first_chroma = mix.first.color.to_chromashift();
213 let second_chroma = mix.second.color.to_chromashift();
214 let delta_e = first_chroma.and_then(|first| second_chroma.map(|second| first.delta_e(second)));
215
216 let p1_explicit = mix.first.percentage.as_ref().map(|p| p.value());
221 let p2_explicit = mix.second.percentage.as_ref().map(|p| p.value());
222 let (p1, p2) = match (p1_explicit, p2_explicit) {
223 (Some(a), Some(b)) => (a, b),
224 (Some(a), None) => (a, 100.0 - a),
225 (None, Some(b)) => (100.0 - b, b),
226 (None, None) => (50.0, 50.0),
227 };
228 let sum = p1 + p2;
229
230 if delta_e.is_some_and(|delta| delta < COLOR_EPSILON) && sum >= 100.0 {
233 let str = first_chroma.and_then(|color| color.shortest()).unwrap_or_else(|| {
234 let span = mix.first.color.to_span();
235 self.transformer.source_text[span.start().0 as usize..span.end().0 as usize].to_string()
236 });
237 self.transformer.clear_pending_edits(outer_span);
238 self.transformer.replace_parsed::<Color>(outer_span, &str);
239 self.replacing_outer = true;
240 return;
241 }
242
243 if sum == 100.0 && (p1 == 100.0 || p2 == 0.0) {
245 let str = first_chroma.and_then(|color| color.shortest()).unwrap_or_else(|| {
246 let span = mix.first.color.to_span();
247 self.transformer.source_text[span.start().0 as usize..span.end().0 as usize].to_string()
248 });
249 self.transformer.clear_pending_edits(outer_span);
250 self.transformer.replace_parsed::<Color>(outer_span, &str);
251 self.replacing_outer = true;
252 return;
253 }
254
255 if sum == 100.0 && (p2 == 100.0 || p1 == 0.0) {
257 let str = second_chroma.and_then(|color| color.shortest()).unwrap_or_else(|| {
258 let span = mix.second.color.to_span();
259 self.transformer.source_text[span.start().0 as usize..span.end().0 as usize].to_string()
260 });
261 self.transformer.clear_pending_edits(outer_span);
262 self.transformer.replace_parsed::<Color>(outer_span, &str);
263 self.replacing_outer = true;
264 return;
265 }
266
267 if let (Some(first), Some(second)) = (first_chroma, second_chroma)
269 && sum > 0.0
270 {
271 let np1 = (p1 as f64) / (sum as f64) * 100.0;
273 let percentage = 100.0 - np1;
275
276 let mixed = mix.interpolation.color_space.mix(first, second, percentage);
277
278 let alpha_mult = (sum as f64 / 100.0).min(1.0);
281 let mixed_alpha = (mixed.to_alpha() as f64 / 100.0 * alpha_mult * 100.0) as f32;
282 let mixed = mixed.with_alpha(mixed_alpha);
283
284 let rounded = mixed.round();
287 let native_css = rounded.to_css();
288 let srgb_css = if mixed.in_gamut_of(ColorSpace::Srgb) { mixed.shortest() } else { None };
289 let candidate =
290 native_css.into_iter().chain(srgb_css).min_by(|a, b| a.len().cmp(&b.len()).then_with(|| a.cmp(b)));
291
292 if let Some(ref candidate) = candidate
293 && candidate.len() < outer_len
294 {
295 self.transformer.replace_parsed::<Color>(outer_span, candidate);
296 self.replacing_outer = true;
297 return;
298 }
299 }
300
301 if sum == 100.0 {
306 if p1 == 50.0
307 && let Some(ref pct) = mix.first.percentage
308 {
309 self.transformer.delete(pct.to_span());
310 }
311 if p2 == 50.0
312 && let Some(ref pct) = mix.second.percentage
313 {
314 self.transformer.delete(pct.to_span());
315 }
316 }
317
318 if let InterpolationColorSpace::Polar(_, Some(ref hue_method)) = mix.interpolation.color_space
320 && matches!(hue_method.direction, HueInterpolationDirection::Shorter(_))
321 {
322 self.transformer.delete(hue_method.to_span());
323 }
324 }
325
326 fn exit_color_mix_function<'b>(&mut self, _mix: &ColorMixFunction<'b>) {
327 self.replacing_outer = false;
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use crate::test_helpers::{assert_no_transform, assert_transform};
334 use css_ast::{CssAtomSet, StyleSheet};
335
336 #[test]
337 fn reduces_full_length_hex() {
338 assert_transform!(
339 CssMinifierFeature::ReduceColors,
340 CssAtomSet,
341 StyleSheet,
342 "body { color: #ffffff; }",
343 "body { color: #fff; }"
344 );
345 }
346
347 #[test]
348 fn prefers_shorthand_hex_over_keyword() {
349 assert_transform!(
350 CssMinifierFeature::ReduceColors,
351 CssAtomSet,
352 StyleSheet,
353 "body { color: #000000; }",
354 "body { color: #000; }"
355 );
356 }
357
358 #[test]
359 fn prefers_named_over_rgb() {
360 assert_transform!(
361 CssMinifierFeature::ReduceColors,
362 CssAtomSet,
363 StyleSheet,
364 "body { color: rgb(210, 180, 140); }",
365 "body { color: tan; }"
366 );
367 }
368
369 #[test]
370 fn shortens_alpha_hex() {
371 assert_transform!(
372 CssMinifierFeature::ReduceColors,
373 CssAtomSet,
374 StyleSheet,
375 "body { color: rgba(255, 0, 0, 0.5); }",
376 "body { color: #ff000080; }"
377 );
378 }
379
380 #[test]
381 fn no_transform_when_already_short() {
382 assert_no_transform!(CssMinifierFeature::ReduceColors, CssAtomSet, StyleSheet, "body { color: red; }");
383 }
384
385 #[test]
386 fn no_transform_for_currentcolor() {
387 assert_no_transform!(CssMinifierFeature::ReduceColors, CssAtomSet, StyleSheet, "body { color: currentcolor; }");
388 }
389
390 #[test]
391 fn reduces_color_srgb_function() {
392 assert_transform!(
393 CssMinifierFeature::ReduceColors,
394 CssAtomSet,
395 StyleSheet,
396 "a { color: color(srgb 1 0 0); }",
397 "a { color: red; }"
398 );
399 }
400
401 #[test]
402 fn reduces_in_gamut_display_p3_to_shortest() {
403 assert_transform!(
404 CssMinifierFeature::ReduceColors,
405 CssAtomSet,
406 StyleSheet,
407 "a { color: color(display-p3 0.5 0.5 0.5); }",
408 "a { color: gray; }"
409 );
410 }
411
412 #[test]
413 fn no_transform_for_out_of_gamut_display_p3() {
414 assert_no_transform!(
415 CssMinifierFeature::ReduceColors,
416 CssAtomSet,
417 StyleSheet,
418 "a { color: color(display-p3 1 0 0); }"
419 );
420 }
421 #[test]
422
423 fn color_mix_100_percent_first() {
424 assert_transform!(
425 CssMinifierFeature::ReduceColors,
426 CssAtomSet,
427 StyleSheet,
428 "a { color: color-mix(in srgb, red 100%, blue); }",
429 "a { color: red; }"
430 );
431 }
432
433 #[test]
434 fn color_mix_0_percent_first() {
435 assert_transform!(
436 CssMinifierFeature::ReduceColors,
437 CssAtomSet,
438 StyleSheet,
439 "a { color: color-mix(in srgb, red 0%, blue); }",
440 "a { color: #00f; }"
441 );
442 }
443
444 #[test]
445 fn color_mix_same_color_both_sides() {
446 assert_transform!(
447 CssMinifierFeature::ReduceColors,
448 CssAtomSet,
449 StyleSheet,
450 "a { color: color-mix(in srgb, red, red); }",
451 "a { color: red; }"
452 );
453 }
454
455 #[test]
456 fn color_mix_removes_redundant_50_50() {
457 assert_transform!(
458 CssMinifierFeature::ReduceColors,
459 CssAtomSet,
460 StyleSheet,
461 "a { color: color-mix(in srgb, currentcolor 50%, red 50%); }",
462 "a { color: color-mix(in srgb, currentcolor, red); }"
463 );
464 }
465
466 #[test]
467 fn color_mix_removes_single_redundant_50() {
468 assert_transform!(
469 CssMinifierFeature::ReduceColors,
470 CssAtomSet,
471 StyleSheet,
472 "a { color: color-mix(in srgb, currentcolor 50%, red); }",
473 "a { color: color-mix(in srgb, currentcolor, red); }"
474 );
475 }
476
477 #[test]
478 fn color_mix_removes_shorter_hue() {
479 assert_transform!(
480 CssMinifierFeature::ReduceColors,
481 CssAtomSet,
482 StyleSheet,
483 "a { color: color-mix(in oklch shorter hue, currentcolor, red); }",
484 "a { color: color-mix(in oklch, currentcolor, red); }"
485 );
486 }
487
488 #[test]
489 fn color_mix_keeps_longer_hue() {
490 assert_no_transform!(
491 CssMinifierFeature::ReduceColors,
492 CssAtomSet,
493 StyleSheet,
494 "a { color: color-mix(in oklch longer hue,currentcolor,red); }"
495 );
496 }
497
498 #[test]
499 fn color_mix_no_transform_when_already_compact() {
500 assert_no_transform!(
501 CssMinifierFeature::ReduceColors,
502 CssAtomSet,
503 StyleSheet,
504 "a { color: color-mix(in oklch longer hue,currentcolor,red); }"
505 );
506 }
507
508 #[test]
509 fn color_mix_minifies_inner_colors() {
510 assert_transform!(
511 CssMinifierFeature::ReduceColors,
512 CssAtomSet,
513 StyleSheet,
514 "a { color: color-mix(in oklch, rgba(255, 255, 255, 1), currentcolor); }",
515 "a { color: color-mix(in oklch, #fff, currentcolor); }"
516 );
517 }
518
519 #[test]
520 fn color_mix_minifies_inner_rgb_to_named() {
521 assert_transform!(
522 CssMinifierFeature::ReduceColors,
523 CssAtomSet,
524 StyleSheet,
525 "a { color: color-mix(in srgb, hsl(0, 100%, 50%), currentcolor); }",
526 "a { color: color-mix(in srgb, red, currentcolor); }"
527 );
528 }
529
530 #[test]
531 fn color_mix_mixes_static_colors() {
532 assert_transform!(
533 CssMinifierFeature::ReduceColors,
534 CssAtomSet,
535 StyleSheet,
536 "a { color: color-mix(in srgb, red, blue); }",
537 "a { color: purple; }"
538 );
539 }
540
541 #[test]
542 fn color_mix_normalizes_percentages_over_100() {
543 assert_transform!(
545 CssMinifierFeature::ReduceColors,
546 CssAtomSet,
547 StyleSheet,
548 "a { color: color-mix(in srgb, red 80%, blue 40%); }",
549 "a { color: #a05; }"
550 );
551 }
552
553 #[test]
554 fn color_mix_alpha_multiplier_under_100() {
555 assert_transform!(
557 CssMinifierFeature::ReduceColors,
558 CssAtomSet,
559 StyleSheet,
560 "a { color: color-mix(in srgb, red 30%, blue 30%); }",
561 "a { color: #80008099; }"
562 );
563 }
564
565 #[test]
566 fn color_mix_no_100_shortcircuit_when_both_explicit() {
567 assert_transform!(
569 CssMinifierFeature::ReduceColors,
570 CssAtomSet,
571 StyleSheet,
572 "a { color: color-mix(in srgb, red 100%, blue 50%); }",
573 "a { color: #a05; }"
574 );
575 }
576
577 #[test]
578 fn color_mix_oklch_out_of_gamut_uses_native_space() {
579 assert_transform!(
582 CssMinifierFeature::ReduceColors,
583 CssAtomSet,
584 StyleSheet,
585 "a { color: color-mix(in oklch, lime, blue); }",
586 "a { color: oklch(0.659 0.304 203.3); }"
587 );
588 }
589
590 #[test]
591 fn rgb_none_channel_resolves_to_zero() {
592 assert_transform!(
593 CssMinifierFeature::ReduceColors,
594 CssAtomSet,
595 StyleSheet,
596 "a { color: rgb(none 128 0); }",
597 "a { color: green; }"
598 );
599 }
600
601 #[test]
602 fn relative_rgb_static_channels_minified() {
603 assert_transform!(
604 CssMinifierFeature::ReduceColors,
605 CssAtomSet,
606 StyleSheet,
607 "a { color: rgb(from red 200 g b); }",
608 "a { color: #c80000; }"
609 );
610 }
611
612 #[test]
613 fn relative_rgb_all_keywords_passthrough() {
614 assert_transform!(
615 CssMinifierFeature::ReduceColors,
616 CssAtomSet,
617 StyleSheet,
618 "a { color: rgb(from red r g b); }",
619 "a { color: red; }"
620 );
621 }
622
623 #[test]
624 fn relative_rgb_all_static_minified() {
625 assert_transform!(
626 CssMinifierFeature::ReduceColors,
627 CssAtomSet,
628 StyleSheet,
629 "a { color: rgb(from blue 255 255 0); }",
630 "a { color: #ff0; }"
631 );
632 }
633
634 #[test]
635 fn relative_hsl_keywords_passthrough() {
636 assert_transform!(
637 CssMinifierFeature::ReduceColors,
638 CssAtomSet,
639 StyleSheet,
640 "a { color: hsl(from green h s l); }",
641 "a { color: green; }"
642 );
643 }
644
645 #[test]
646 fn relative_oklch_static_produces_named() {
647 assert_transform!(
648 CssMinifierFeature::ReduceColors,
649 CssAtomSet,
650 StyleSheet,
651 "a { color: oklch(from red l c h); }",
652 "a { color: red; }"
653 );
654 }
655}