1use crate::prelude::*;
2use chromashift::{COLOR_EPSILON, ColorDistance, ColorSpace, Hex, Named, Srgb, ToAlpha};
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).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
45impl<'a, 'ctx, N> Visit for ReduceColors<'a, 'ctx, N>
46where
47 N: Visitable + NodeWithMetadata<CssMetadata>,
48{
49 fn visit_color(&mut self, color: &Color) {
50 if self.replacing_outer {
51 return;
52 }
53 if matches!(color, Color::Function(ColorFunction::ColorMix(_))) {
55 return;
56 }
57 let Some(chroma_color) = color.to_chromashift() else {
58 return;
59 };
60 let len = color.to_span().len() as usize;
61
62 if !chroma_color.in_gamut_of(ColorSpace::Srgb) {
66 return;
67 }
68
69 let Some(candidate) = chroma_color.shortest() else {
70 return;
71 };
72
73 if candidate.len() < len {
74 self.transformer.replace_parsed::<Color>(color.to_span(), &candidate);
75 }
76 }
77
78 fn visit_color_mix_function<'b>(&mut self, mix: &ColorMixFunction<'b>) {
79 let outer_span = mix.to_span();
80 let outer_len = outer_span.len() as usize;
81
82 let first_chroma = mix.first.color.to_chromashift();
83 let second_chroma = mix.second.color.to_chromashift();
84 let delta_e = first_chroma.and_then(|first| second_chroma.map(|second| first.delta_e(second)));
85
86 let p1_explicit = mix.first.percentage.as_ref().map(|p| p.value());
91 let p2_explicit = mix.second.percentage.as_ref().map(|p| p.value());
92 let (p1, p2) = match (p1_explicit, p2_explicit) {
93 (Some(a), Some(b)) => (a, b),
94 (Some(a), None) => (a, 100.0 - a),
95 (None, Some(b)) => (100.0 - b, b),
96 (None, None) => (50.0, 50.0),
97 };
98 let sum = p1 + p2;
99
100 if delta_e.is_some_and(|delta| delta < COLOR_EPSILON) && sum >= 100.0 {
103 let str = first_chroma.and_then(|color| color.shortest()).unwrap_or_else(|| {
104 let span = mix.first.color.to_span();
105 self.transformer.source_text[span.start().0 as usize..span.end().0 as usize].to_string()
106 });
107 self.transformer.clear_pending_edits(outer_span);
108 self.transformer.replace_parsed::<Color>(outer_span, &str);
109 self.replacing_outer = true;
110 return;
111 }
112
113 if sum == 100.0 && (p1 == 100.0 || p2 == 0.0) {
115 let str = first_chroma.and_then(|color| color.shortest()).unwrap_or_else(|| {
116 let span = mix.first.color.to_span();
117 self.transformer.source_text[span.start().0 as usize..span.end().0 as usize].to_string()
118 });
119 self.transformer.clear_pending_edits(outer_span);
120 self.transformer.replace_parsed::<Color>(outer_span, &str);
121 self.replacing_outer = true;
122 return;
123 }
124
125 if sum == 100.0 && (p2 == 100.0 || p1 == 0.0) {
127 let str = second_chroma.and_then(|color| color.shortest()).unwrap_or_else(|| {
128 let span = mix.second.color.to_span();
129 self.transformer.source_text[span.start().0 as usize..span.end().0 as usize].to_string()
130 });
131 self.transformer.clear_pending_edits(outer_span);
132 self.transformer.replace_parsed::<Color>(outer_span, &str);
133 self.replacing_outer = true;
134 return;
135 }
136
137 if let (Some(first), Some(second)) = (first_chroma, second_chroma)
139 && sum > 0.0
140 {
141 let np1 = (p1 as f64) / (sum as f64) * 100.0;
143 let percentage = 100.0 - np1;
145
146 let mixed = mix.interpolation.color_space.mix(first, second, percentage);
147
148 let alpha_mult = (sum as f64 / 100.0).min(1.0);
151 let mixed_alpha = (mixed.to_alpha() as f64 / 100.0 * alpha_mult * 100.0) as f32;
152 let mixed = mixed.with_alpha(mixed_alpha);
153
154 let candidate = if mixed.in_gamut_of(ColorSpace::Srgb) {
157 mixed.shortest()
158 } else {
159 Some(mixed.to_string())
161 };
162
163 if let Some(candidate) = candidate
164 && candidate.len() < outer_len
165 {
166 self.transformer.replace_parsed::<Color>(outer_span, &candidate);
167 self.replacing_outer = true;
168 return;
169 }
170 }
171
172 if sum == 100.0 {
177 if p1 == 50.0
178 && let Some(ref pct) = mix.first.percentage
179 {
180 self.transformer.delete(pct.to_span());
181 }
182 if p2 == 50.0
183 && let Some(ref pct) = mix.second.percentage
184 {
185 self.transformer.delete(pct.to_span());
186 }
187 }
188
189 if let InterpolationColorSpace::Polar(_, Some(ref hue_method)) = mix.interpolation.color_space
191 && matches!(hue_method.direction, HueInterpolationDirection::Shorter(_))
192 {
193 self.transformer.delete(hue_method.to_span());
194 }
195 }
196
197 fn exit_color_mix_function<'b>(&mut self, _mix: &ColorMixFunction<'b>) {
198 self.replacing_outer = false;
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use crate::test_helpers::{assert_no_transform, assert_transform};
205 use css_ast::{CssAtomSet, StyleSheet};
206
207 #[test]
208 fn reduces_full_length_hex() {
209 assert_transform!(
210 CssMinifierFeature::ReduceColors,
211 CssAtomSet,
212 StyleSheet,
213 "body { color: #ffffff; }",
214 "body { color: #fff; }"
215 );
216 }
217
218 #[test]
219 fn prefers_shorthand_hex_over_keyword() {
220 assert_transform!(
221 CssMinifierFeature::ReduceColors,
222 CssAtomSet,
223 StyleSheet,
224 "body { color: #000000; }",
225 "body { color: #000; }"
226 );
227 }
228
229 #[test]
230 fn prefers_named_over_rgb() {
231 assert_transform!(
232 CssMinifierFeature::ReduceColors,
233 CssAtomSet,
234 StyleSheet,
235 "body { color: rgb(210, 180, 140); }",
236 "body { color: tan; }"
237 );
238 }
239
240 #[test]
241 fn shortens_alpha_hex() {
242 assert_transform!(
243 CssMinifierFeature::ReduceColors,
244 CssAtomSet,
245 StyleSheet,
246 "body { color: rgba(255, 0, 0, 0.5); }",
247 "body { color: #ff000080; }"
248 );
249 }
250
251 #[test]
252 fn no_transform_when_already_short() {
253 assert_no_transform!(CssMinifierFeature::ReduceColors, CssAtomSet, StyleSheet, "body { color: red; }");
254 }
255
256 #[test]
257 fn no_transform_for_currentcolor() {
258 assert_no_transform!(CssMinifierFeature::ReduceColors, CssAtomSet, StyleSheet, "body { color: currentcolor; }");
259 }
260
261 #[test]
262 fn reduces_color_srgb_function() {
263 assert_transform!(
264 CssMinifierFeature::ReduceColors,
265 CssAtomSet,
266 StyleSheet,
267 "a { color: color(srgb 1 0 0); }",
268 "a { color: red; }"
269 );
270 }
271
272 #[test]
273 fn reduces_color_display_p3() {
274 assert_transform!(
275 CssMinifierFeature::ReduceColors,
276 CssAtomSet,
277 StyleSheet,
278 "a { color: color(display-p3 0.5 0.5 0.5); }",
279 "a { color: gray; }"
280 );
281 }
282
283 #[test]
284 fn no_transform_for_out_of_gamut_display_p3() {
285 assert_no_transform!(
286 CssMinifierFeature::ReduceColors,
287 CssAtomSet,
288 StyleSheet,
289 "a { color: color(display-p3 1 0 0); }"
290 );
291 }
292 #[test]
293
294 fn color_mix_100_percent_first() {
295 assert_transform!(
296 CssMinifierFeature::ReduceColors,
297 CssAtomSet,
298 StyleSheet,
299 "a { color: color-mix(in srgb, red 100%, blue); }",
300 "a { color: red; }"
301 );
302 }
303
304 #[test]
305 fn color_mix_0_percent_first() {
306 assert_transform!(
307 CssMinifierFeature::ReduceColors,
308 CssAtomSet,
309 StyleSheet,
310 "a { color: color-mix(in srgb, red 0%, blue); }",
311 "a { color: #00f; }"
312 );
313 }
314
315 #[test]
316 fn color_mix_same_color_both_sides() {
317 assert_transform!(
318 CssMinifierFeature::ReduceColors,
319 CssAtomSet,
320 StyleSheet,
321 "a { color: color-mix(in srgb, red, red); }",
322 "a { color: red; }"
323 );
324 }
325
326 #[test]
327 fn color_mix_removes_redundant_50_50() {
328 assert_transform!(
329 CssMinifierFeature::ReduceColors,
330 CssAtomSet,
331 StyleSheet,
332 "a { color: color-mix(in srgb, currentcolor 50%, red 50%); }",
333 "a { color: color-mix(in srgb, currentcolor, red); }"
334 );
335 }
336
337 #[test]
338 fn color_mix_removes_single_redundant_50() {
339 assert_transform!(
340 CssMinifierFeature::ReduceColors,
341 CssAtomSet,
342 StyleSheet,
343 "a { color: color-mix(in srgb, currentcolor 50%, red); }",
344 "a { color: color-mix(in srgb, currentcolor, red); }"
345 );
346 }
347
348 #[test]
349 fn color_mix_removes_shorter_hue() {
350 assert_transform!(
351 CssMinifierFeature::ReduceColors,
352 CssAtomSet,
353 StyleSheet,
354 "a { color: color-mix(in oklch shorter hue, currentcolor, red); }",
355 "a { color: color-mix(in oklch, currentcolor, red); }"
356 );
357 }
358
359 #[test]
360 fn color_mix_keeps_longer_hue() {
361 assert_no_transform!(
362 CssMinifierFeature::ReduceColors,
363 CssAtomSet,
364 StyleSheet,
365 "a { color: color-mix(in oklch longer hue,currentcolor,red); }"
366 );
367 }
368
369 #[test]
370 fn color_mix_no_transform_when_already_compact() {
371 assert_no_transform!(
372 CssMinifierFeature::ReduceColors,
373 CssAtomSet,
374 StyleSheet,
375 "a { color: color-mix(in oklch longer hue,currentcolor,red); }"
376 );
377 }
378
379 #[test]
380 fn color_mix_minifies_inner_colors() {
381 assert_transform!(
382 CssMinifierFeature::ReduceColors,
383 CssAtomSet,
384 StyleSheet,
385 "a { color: color-mix(in oklch, rgba(255, 255, 255, 1), currentcolor); }",
386 "a { color: color-mix(in oklch, #fff, currentcolor); }"
387 );
388 }
389
390 #[test]
391 fn color_mix_minifies_inner_rgb_to_named() {
392 assert_transform!(
393 CssMinifierFeature::ReduceColors,
394 CssAtomSet,
395 StyleSheet,
396 "a { color: color-mix(in srgb, hsl(0, 100%, 50%), currentcolor); }",
397 "a { color: color-mix(in srgb, red, currentcolor); }"
398 );
399 }
400
401 #[test]
402 fn color_mix_mixes_static_colors() {
403 assert_transform!(
404 CssMinifierFeature::ReduceColors,
405 CssAtomSet,
406 StyleSheet,
407 "a { color: color-mix(in srgb, red, blue); }",
408 "a { color: purple; }"
409 );
410 }
411
412 #[test]
413 fn color_mix_normalizes_percentages_over_100() {
414 assert_transform!(
416 CssMinifierFeature::ReduceColors,
417 CssAtomSet,
418 StyleSheet,
419 "a { color: color-mix(in srgb, red 80%, blue 40%); }",
420 "a { color: #a05; }"
421 );
422 }
423
424 #[test]
425 fn color_mix_alpha_multiplier_under_100() {
426 assert_transform!(
428 CssMinifierFeature::ReduceColors,
429 CssAtomSet,
430 StyleSheet,
431 "a { color: color-mix(in srgb, red 30%, blue 30%); }",
432 "a { color: #80008099; }"
433 );
434 }
435
436 #[test]
437 fn color_mix_no_100_shortcircuit_when_both_explicit() {
438 assert_transform!(
440 CssMinifierFeature::ReduceColors,
441 CssAtomSet,
442 StyleSheet,
443 "a { color: color-mix(in srgb, red 100%, blue 50%); }",
444 "a { color: #a05; }"
445 );
446 }
447
448 #[test]
449 fn color_mix_oklch_out_of_gamut_uses_native_space() {
450 assert_transform!(
452 CssMinifierFeature::ReduceColors,
453 CssAtomSet,
454 StyleSheet,
455 "a { color: color-mix(in oklch, lime, blue); }",
456 "a { color: oklch(0.66 0.304 203.27); }"
457 );
458 }
459}