csskit_transform/
reduce_colors.rs

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	/// When true, the outer color-mix() is being replaced entirely, so inner
11	/// `visit_color` calls should be suppressed to avoid overlapping edits.
12	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		// color-mix() is handled by visit_color_mix_function
54		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		// Only generate sRGB-based candidates if the colour is within the sRGB gamut.
63		// Converting an out-of-gamut colour (e.g. display-p3 1 0 0) to sRGB would silently
64		// clamp the values, changing the actual colour.
65		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		// Compute effective percentages per CSS Color 5 3.2:
87		// - If only one percentage is given, the other is 100% - given
88		// - If neither is given, both default to 50%
89		// - If both are given, they're used as-is (and may not sum to 100%)
90		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		// The same color on both sides should just shrink to the one color,
101		// but only when alpha_mult is 1 (sum >= 100)
102		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		// 100%/0% elimination — only when sum == 100 (no alpha multiplier, no normalization)
114		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		// 0%/100% elimination — only when sum == 100
126		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		// Try to statically mix both colors if they're both known
138		if let (Some(first), Some(second)) = (first_chroma, second_chroma)
139			&& sum > 0.0
140		{
141			// Normalize so that p1_norm + p2_norm = 100
142			let np1 = (p1 as f64) / (sum as f64) * 100.0;
143			// The percentage for mixing is "how much of the second color"
144			let percentage = 100.0 - np1;
145
146			let mixed = mix.interpolation.color_space.mix(first, second, percentage);
147
148			// Apply alpha multiplier per CSS Color 5 3.3:
149			// alpha_mult = 1 - leftover, where leftover = max(1 - sum/100, 0)
150			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			// Only convert to sRGB hex/named if the result is in sRGB gamut.
155			// Converting out-of-gamut colors to hex silently clamps, changing the color.
156			let candidate = if mixed.in_gamut_of(ColorSpace::Srgb) {
157				mixed.shortest()
158			} else {
159				// Out of sRGB gamut — output in the native interpolation color space
160				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		// Partial optimizations (only when not replacing the entire expression)
173
174		// Remove redundant 50% percentages — only when both effective percentages are 50%
175		// (i.e. the sum is 100, so no alpha multiplier effect)
176		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		// Remove redundant "shorter hue" (shorter is the default hue interpolation direction)
190		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		// 80% + 40% = 120%, normalizes to 66.67%/33.33%, giving rgb(170, 0, 85) = #a05
415		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		// 30% + 30% = 60%, alpha_mult = 0.6, result is semi-transparent purple
427		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		// red 100% + blue 50% sums to 150%, must normalize to 67/33 — not short-circuit to red
439		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		// oklch mix of lime+blue is out of sRGB gamut — resolved to oklch(), not hex
451		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}