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