Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Implement contrast-color function #808

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
120 changes: 120 additions & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
130 changes: 130 additions & 0 deletions src/values/color.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
},
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -3134,6 +3152,105 @@ where
current.into()
}

fn calculate_contrast_color(base_luminance: f32, mut candidates: Vec<CssColor>) -> 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<CssColor, ParseError<'i, ParserError<'i>>> {
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")]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than a cargo flag, we should use the drafts option for this. Then at runtime you could decide, and we can expose this to the JS API. We could accept the spec-level as the value. For example:

{
  drafts: {
    contrastColor: 6
  }
}

"tbd-bg" | "tbd-fg" => {
input.expect_ident_matching("wcag2")?;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like this is optional in the spec.

If no color candidates have been provided, may be omitted, in which case a UA-chosen algorithm is used.

If the target contrast level is omitted, the color candidate with the greatest contrast is returned. Otherwise, the returned color is the first color candidate that meets or exceeds that level, defaulting to white or black if none qualify.

Also looks like it can be a function:

Arguments to a functional notation indicate the target contrast level.

= wcag2 | wcag2([ | [ aa | aaa ] && large? ])

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<CssColor, ParseError<'i, ParserError<'i>>> {
input.expect_ident_matching("in")?;
let method = ColorSpaceName::parse(input)?;
Expand Down Expand Up @@ -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<f32> {
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<T>(
Expand Down