diff --git a/Cargo.toml b/Cargo.toml index dd55452a..67b27efd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,6 +40,7 @@ bundler = ["dashmap", "sourcemap", "rayon"] cli = ["atty", "clap", "serde_json", "browserslist", "jemallocator"] grid = [] jsonschema = ["schemars", "serde", "parcel_selectors/jsonschema"] +level6 = [] nodejs = ["dep:serde"] serde = ["dep:serde", "smallvec/serde", "cssparser/serde", "parcel_selectors/serde", "into_owned"] sourcemap = ["parcel_sourcemap"] diff --git a/README.md b/README.md index f44d7b3c..484616c2 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,11 @@ An extremely fast CSS parser, transformer, and minifier written in Rust. Use it - CSS Nesting - Custom media queries (draft spec) - Logical properties + * [Color Level 6](https://drafts.csswg.org/css-color-6/) (in draft, behind `level6` feature flag) + - [`contrast-color()`](https://drafts.csswg.org/css-color-6/#colorcontrast) function * [Color Level 5](https://drafts.csswg.org/css-color-5/) - - `color-mix()` function + - [`color-mix()`](https://drafts.csswg.org/css-color-5/#color-mix) function + - [`contrast-color()`](https://drafts.csswg.org/css-color-5/#contrast-color) function - Relative color syntax, e.g. `lab(from purple calc(l * .8) a b)` - [Color Level 4](https://drafts.csswg.org/css-color-4/) - `lab()`, `lch()`, `oklab()`, and `oklch()` colors diff --git a/src/lib.rs b/src/lib.rs index 77d904d2..297d3e8c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -17409,6 +17409,126 @@ mod tests { ); } + #[test] + fn contrast_color_level5() { + fn test(input: &str, output: &str) { + let output = CssColor::parse_string(output) + .unwrap() + .to_css_string(PrinterOptions { + minify: true, + ..PrinterOptions::default() + }) + .unwrap(); + minify_test( + &format!(".foo {{ color: {} }}", input), + &format!(".foo{{color:{}}}", output), + ); + } + + test("contrast-color(#000)", "#fff"); + test("contrast-color(#333)", "#fff"); + test("contrast-color(#ccc)", "#000"); + test("contrast-color(#fff)", "#000"); + + test("contrast-color(#000 max)", "#fff"); + test("contrast-color(#333 max)", "#fff"); + test("contrast-color(#ccc max)", "#000"); + test("contrast-color(#fff max)", "#000"); + + test("contrast-color(#00364a)", "#fff"); + test("contrast-color(#00c7fc)", "#000"); + test("contrast-color(#263e0f)", "#fff"); + test("contrast-color(#371a94)", "#fff"); + test("contrast-color(#9aa60e)", "#000"); + test("contrast-color(#c3d117)", "#000"); + test("contrast-color(#ffb43f)", "#000"); + test("contrast-color(#ffe4a8)", "#000"); + + test("contrast-color(#00364a max)", "#fff"); + test("contrast-color(#00c7fc max)", "#000"); + test("contrast-color(#263e0f max)", "#fff"); + test("contrast-color(#371a94 max)", "#fff"); + test("contrast-color(#9aa60e max)", "#000"); + test("contrast-color(#c3d117 max)", "#000"); + test("contrast-color(#ffb43f max)", "#000"); + test("contrast-color(#ffe4a8 max)", "#000"); + } + + #[test] + #[cfg(feature = "level6")] + fn contrast_color_level6() { + fn test(input: &str, output: &str) { + let output = CssColor::parse_string(output) + .unwrap() + .to_css_string(PrinterOptions { + minify: true, + ..PrinterOptions::default() + }) + .unwrap(); + minify_test( + &format!(".foo {{ color: {} }}", input), + &format!(".foo{{color:{}}}", output), + ); + } + + test("contrast-color(#00364a tbd-bg wcag2, #b10, #7b4, #05d)", "#7b4"); + test("contrast-color(#00c7fc tbd-bg wcag2, #b10, #7b4, #05d)", "#b10"); + test("contrast-color(#263e0f tbd-bg wcag2, #b10, #7b4, #05d)", "#7b4"); + test("contrast-color(#371a94 tbd-bg wcag2, #b10, #7b4, #05d)", "#7b4"); + test("contrast-color(#9aa60e tbd-bg wcag2, #b10, #7b4, #05d)", "#b10"); + test("contrast-color(#c3d117 tbd-bg wcag2, #b10, #7b4, #05d)", "#b10"); + test("contrast-color(#ffb43f tbd-bg wcag2, #b10, #7b4, #05d)", "#b10"); + test("contrast-color(#ffe4a8 tbd-bg wcag2, #b10, #7b4, #05d)", "#b10"); + + test("contrast-color(#000 tbd-bg wcag2, #111, #eee)", "#eee"); + test("contrast-color(#666 tbd-bg wcag2, #111, #eee)", "#eee"); + test("contrast-color(#ccc tbd-bg wcag2, #111, #eee)", "#111"); + test("contrast-color(#fff tbd-bg wcag2, #111, #eee)", "#111"); + + test("contrast-color(#000 tbd-fg wcag2, #111, #eee)", "#eee"); + test("contrast-color(#666 tbd-fg wcag2, #111, #eee)", "#eee"); + test("contrast-color(#ccc tbd-fg wcag2, #111, #eee)", "#111"); + test("contrast-color(#fff tbd-fg wcag2, #111, #eee)", "#111"); + + test("contrast-color(#000 tbd-bg wcag2, #111, #eee, #ddd)", "#eee"); + test("contrast-color(#666 tbd-bg wcag2, #111, #eee, #ddd)", "#eee"); + test("contrast-color(#ccc tbd-bg wcag2, #111, #eee, #ddd)", "#111"); + test("contrast-color(#fff tbd-bg wcag2, #111, #eee, #ddd)", "#111"); + + test("contrast-color(#000 tbd-bg wcag2, #111, #eee, #ddd, #ccc)", "#eee"); + test("contrast-color(#666 tbd-bg wcag2, #111, #eee, #ddd, #ccc)", "#eee"); + test("contrast-color(#ccc tbd-bg wcag2, #111, #eee, #ddd, #ccc)", "#111"); + test("contrast-color(#fff tbd-bg wcag2, #111, #eee, #ddd, #ccc)", "#111"); + + test("contrast-color(lab(from green l a b))", "#fff"); + test("contrast-color(lab(from green l a b) tbd-bg wcag2, #111, #eee)", "#eee"); + + // https://drafts.csswg.org/css-color-6/#example-62c2d8fa + test( + "contrast-color(wheat tbd-bg wcag2, tan, sienna, #b22222, #d2691e)", + "#b22222", + ); + + // https://drafts.csswg.org/css-color-6/#example-3a7863eb + test( + "contrast-color(hsl(200 50% 80%) tbd-fg wcag2, hsl(200 83% 23%), purple, hsl(300 100% 25%))", + "purple", + ); + + // Leaves variables as-is + fn test_leaves_as_is(input: &str) { + minify_test( + &format!(".foo {{ color: {} }}", input), + &format!(".foo{{color:{}}}", input), + ); + } + + test_leaves_as_is("contrast-color(var(--test))"); + test_leaves_as_is("contrast-color(currentColor)"); + test_leaves_as_is("contrast-color(#000 tbd-bg wcag2,var(--test))"); + test_leaves_as_is("contrast-color(#000 tbd-bg wcag2,currentColor)"); + } + #[test] fn test_relative_color() { fn test(input: &str, output: &str) { diff --git a/src/values/color.rs b/src/values/color.rs index 274c3f43..b96d3a6f 100644 --- a/src/values/color.rs +++ b/src/values/color.rs @@ -1038,6 +1038,9 @@ fn parse_color_function<'i, 't>( "rgb" | "rgba" => { parse_rgb(input, &mut parser) }, + "contrast-color" => { + input.parse_nested_block(parse_contrast_color) + }, "color-mix" => { input.parse_nested_block(parse_color_mix) }, @@ -1630,6 +1633,21 @@ impl RGBA { pub fn alpha_f32(&self) -> f32 { self.alpha as f32 / 255.0 } + + /// Returns the [relative luminance](https://www.w3.org/TR/WCAG21/#dfn-relative-luminance) of the color. + fn relative_luminance(&self) -> f32 { + fn channel_luminance(channel: f32) -> f32 { + if channel <= 0.04045 { + channel / 12.92 + } else { + ((channel + 0.055) / 1.055).powf(2.4) + } + } + + 0.2126 * channel_luminance(self.red_f32()) + + 0.7152 * channel_luminance(self.green_f32()) + + 0.0722 * channel_luminance(self.blue_f32()) + } } fn clamp_unit_f32(val: f32) -> u8 { @@ -3134,6 +3152,105 @@ where current.into() } +fn calculate_contrast_color(base_luminance: f32, mut candidates: Vec) -> CssColor { + if candidates.is_empty() { + candidates.push(CssColor::RGBA(RGBA::new(0, 0, 0, 1.0))); + candidates.push(CssColor::RGBA(RGBA::new(255, 255, 255, 1.0))); + } + + fn wcag2_contrast_ratio(l1: f32, l2: f32) -> f32 { + // https://www.w3.org/TR/WCAG21/#dfn-contrast-ratio + let l1 = l1 + 0.05; + let l2 = l2 + 0.05; + + if l1 > l2 { + l1 / l2 + } else { + l2 / l1 + } + } + + let mut best_contrast = 0.0; + let mut best_contrast_index = 0; + + for (i, candidate) in candidates.iter().enumerate() { + let candidate_luminance = candidate.relative_luminance().unwrap(); + let contrast = wcag2_contrast_ratio(base_luminance, candidate_luminance); + + if contrast > best_contrast { + best_contrast = contrast; + best_contrast_index = i; + } + } + + candidates[best_contrast_index].clone() +} + +fn parse_contrast_color<'i, 't>(input: &mut Parser<'i, 't>) -> Result>> { + let base_color = CssColor::parse(input)?; + + let base_luminance = match base_color.relative_luminance() { + Some(value) => value, + None => { + return Err(input.new_custom_error(ParserError::InvalidValue)); + } + }; + + let location = input.current_source_location(); + + match input.expect_ident() { + Ok(value) => { + match_ignore_ascii_case! { value, + // https://drafts.csswg.org/css-color-5/#contrast-color + "max" => { + return Ok(calculate_contrast_color(base_luminance, Vec::new())); + }, + + // https://github.com/w3c/csswg-drafts/issues/7937 + #[cfg(feature = "level6")] + "tbd-bg" | "tbd-fg" => { + input.expect_ident_matching("wcag2")?; + input.expect_comma()?; + + let mut candidates = Vec::new(); + + loop { + let color = CssColor::parse(input)?; + + if color.relative_luminance().is_none() { + break Err(input.new_custom_error(ParserError::InvalidValue)); + } + + candidates.push(color); + + match input.expect_comma() { + Ok(()) => {} + + Err(BasicParseError { + kind: BasicParseErrorKind::EndOfInput, + .. + }) => break Ok(calculate_contrast_color(base_luminance, candidates)), + + Err(e) => break Err(e.into()), + } + } + }, + + _ => { + Err(location.new_unexpected_token_error(Token::Ident(value.to_owned()))) + } + } + } + + Err(BasicParseError { + kind: BasicParseErrorKind::EndOfInput, + .. + }) => Ok(calculate_contrast_color(base_luminance, Vec::new())), + + Err(e) => Err(e.into()), + } +} + fn parse_color_mix<'i, 't>(input: &mut Parser<'i, 't>) -> Result>> { input.expect_ident_matching("in")?; let method = ColorSpaceName::parse(input)?; @@ -3233,6 +3350,19 @@ impl CssColor { } } + /// Returns the [relative luminance](https://www.w3.org/TR/WCAG21/#dfn-relative-luminance) of the color, if it can be calculated. + fn relative_luminance(&self) -> Option { + match self { + CssColor::CurrentColor => None, + CssColor::RGBA(rgba) => Some(rgba.relative_luminance()), + CssColor::LAB(lab) => Some(RGBA::from(**lab).relative_luminance()), + CssColor::Predefined(pre) => Some(RGBA::from(**pre).relative_luminance()), + CssColor::Float(float) => Some(RGBA::from(**float).relative_luminance()), + CssColor::LightDark(..) => None, + CssColor::System(_) => None, + } + } + /// Mixes this color with another color, including the specified amount of each. /// Implemented according to the [`color-mix()`](https://www.w3.org/TR/css-color-5/#color-mix) function. pub fn interpolate(