Skip to content
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
29 changes: 27 additions & 2 deletions src/css/values/color.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1396,6 +1396,12 @@ pub(crate) fn parse_hsl_hwb<
})
}

/// The `<number>` that stands for `100%` in the saturation/lightness of hsl() and the
/// whiteness/blackness of hwb(): `hsl(120 50 40)` is `hsl(120 50% 40%)`.
/// https://www.w3.org/TR/css-color-4/#the-hsl-notation
/// https://www.w3.org/TR/css-color-4/#the-hwb-notation
const HSL_PERCENT_BASIS: f32 = 100.0;

pub(crate) fn parse_hsl_hwb_components<T>(
input: &mut css::Parser,
parser: &mut ComponentParser,
Expand All @@ -1408,13 +1414,22 @@ pub(crate) fn parse_hsl_hwb_components<T>(
&& !h.is_nan()
&& input.try_parse(|i| i.expect_comma()).is_ok();

let a = parser.parse_percentage(input)?.clamp(0.0, 1.0);
// Only the legacy comma syntax is limited to `<percentage>`.
let parse_channel = |input: &mut css::Parser, parser: &ComponentParser| {
if is_legacy_syntax {
parser.parse_percentage(input)
} else {
parser.parse_unit_channel(input, HSL_PERCENT_BASIS)
}
};

let a = parse_channel(input, parser)?.clamp(0.0, 1.0);

if is_legacy_syntax {
input.expect_comma()?;
}

let b = parser.parse_percentage(input)?.clamp(0.0, 1.0);
let b = parse_channel(input, parser)?.clamp(0.0, 1.0);

if is_legacy_syntax && (a.is_nan() || b.is_nan()) {
return Err(input.new_custom_error(css::ParserError::invalid_value));
Expand Down Expand Up @@ -1994,6 +2009,16 @@ impl ComponentParser {
}
}

/// A channel written as `<percentage> | <number> | none` and stored as a unit value,
/// `percent_basis` being the `<number>` that stands for `100%`.
fn parse_unit_channel(&self, input: &mut css::Parser, percent_basis: f32) -> CssResult<f32> {
if let Ok(number) = input.try_parse(CSSNumberFns::parse) {
return Ok(number / percent_basis);
}

self.parse_percentage(input)
}

fn parse_percentage(&self, input: &mut css::Parser) -> CssResult<f32> {
if let Some(from) = &self.from {
if let Ok(res) = input.try_parse(|i| RelativeComponentParser::parse_percentage(i, from))
Expand Down
91 changes: 91 additions & 0 deletions test/js/bun/css/color.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -764,3 +764,94 @@ describe("conversions between color spaces", () => {
);
});
});

// In the modern (space separated) syntax, the saturation and lightness of hsl()
// and the whiteness and blackness of hwb() are <percentage> | <number> | none,
// and the number is the percentage without its sign: hsl(120 50 40) is
// hsl(120 50% 40%). The legacy comma syntax takes only percentages.
// https://www.w3.org/TR/css-color-4/#the-hsl-notation
// https://www.w3.org/TR/css-color-4/#the-hwb-notation
describe("hsl() and hwb() number channels", () => {
// Every expected value is what the same color spelled with percentages gives,
// and what lightningcss 1.30 emits for the number spelling.
test.each([
["hsl(120 50 40)", "#393"],
["hsl(120 50 40%)", "#393"],
["hsl(120 50% 40)", "#393"],
["hsl(120deg 50 40)", "#393"],
["hsla(120 50 40)", "#393"],
["hsl(120 50 40 / 0.5)", "#33993380"],
["hsl(120 50 40 / 50%)", "#33993380"],
["hsla(120 50 40 / 0.5)", "#33993380"],
["hsl(120 none 40)", "#666"],
["hsl(120 50 none)", "#000"],
["hsl(none 50 40)", "#933"],
["hsl(120.5 50.5 40.5)", "#339b34"],
["hsl(120 .5 40)", "#656765"],
["hsl(120 1e1 40)", "#5c705c"],
["hsl(120 calc(25 * 2) 40)", "#393"],
["hsl(120 calc(25% * 2) 40)", "#393"],
["hsl(120 50 calc(20 * 2))", "#393"],
["hwb(120 20 30)", "#33b333"],
["hwb(120 20 30%)", "#33b333"],
["hwb(120 20% 30)", "#33b333"],
["hwb(120deg 20 30)", "#33b333"],
["hwb(120 20 30 / 0.5)", "#33b33380"],
["hwb(120 none 30)", "#00b300"],
["hwb(120 20 none)", "#3f3"],
["hwb(120 0 0)", "#0f0"],
["hwb(120 100 0)", "#fff"],
["hwb(120 0 100)", "#000"],
["hwb(120 calc(10 * 2) 30)", "#33b333"],
// Relative color syntax is the modern syntax, so it takes numbers too, as
// do the origin colors and the operands of color-mix().
["hsl(from red h 50 l)", "#bf4040"],
["hwb(from red h 20 b)", "#f33"],
["hsl(from hsl(120 50 40) h s l)", "#393"],
["hwb(from hwb(120 20 30) h w b)", "#33b333"],
["rgb(from hsl(120 50 40) r g b)", "#393"],
["color-mix(in hsl, hsl(120 50 40), hwb(120 20 30))", "#33a633"],
])("%s", (input, expected) => {
expect(color(input, "css")).toBe(expected);
});

// Out-of-range numbers clamp exactly like the corresponding percentages:
// hsl() to 0..100, hwb() to 0..100 and then w + b is normalized to 100.
test.each([
["hsl(120 150 40)", "hsl(120 150% 40%)", "#00cc00"],
["hsl(120 -50 40)", "hsl(120 -50% 40%)", "#666666"],
["hsl(120 50 140)", "hsl(120 50% 140%)", "#ffffff"],
["hsl(120 50 -40)", "hsl(120 50% -40%)", "#000000"],
["hwb(120 60 60)", "hwb(120 60% 60%)", "#808080"],
["hwb(120 -20 30)", "hwb(120 -20% 30%)", "#00b300"],
["hwb(120 20 130)", "hwb(120 20% 130%)", "#2a2a2a"],
])("%s is %s", (numbers, percentages, expected) => {
expect(color(numbers, "hex")).toBe(expected);
expect(color(percentages, "hex")).toBe(expected);
});

test.each([
"hsl(120, 50, 40)",
"hsl(120, 50%, 40)",
"hsl(120, 50, 40%)",
"hsla(120, 50, 40, 0.5)",
// hwb() has no legacy syntax at all.
"hwb(120, 20, 30)",
"hwb(120, 20%, 30%)",
"hsl(120 50, 40)",
"hsl(120 50 40 60)",
// The legacy rgb() percentage form shares the percentage parser and still
// does not take numbers.
"rgb(10%, 20, 30)",
])("%s is invalid", input => {
expect(color(input, "css")).toBeNull();
});

test.each([
["hsl(120, 50%, 40%)", "#393"],
["hsla(120, 50%, 40%, 0.5)", "#33993380"],
["rgb(10%, 20%, 30%)", "#1a334d"],
])("%s still parses", (input, expected) => {
expect(color(input, "css")).toBe(expected);
});
});
90 changes: 90 additions & 0 deletions test/js/bun/css/css.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7666,6 +7666,96 @@ describe("css tests", () => {
minify_test(".foo{grid-template-areas:none}", ".foo{grid-template-areas:none}");
});

describe("hsl() and hwb() number channels", () => {
// In the modern syntax the saturation/lightness and whiteness/blackness are
// <percentage> | <number> | none, and the number is the percentage without
// its sign; only the legacy comma syntax is limited to percentages.
// https://www.w3.org/TR/css-color-4/#the-hsl-notation
// https://www.w3.org/TR/css-color-4/#the-hwb-notation
// Every output below is what the percentage spelling already produced and
// what lightningcss 1.30 emits for the number spelling.
minify_test(".foo { color: hsl(120 50 40) }", ".foo{color:#393}");
minify_test(".foo { color: hsl(120 50 40%) }", ".foo{color:#393}");
minify_test(".foo { color: hsl(120 50% 40) }", ".foo{color:#393}");
minify_test(".foo { color: hsl(120deg 50 40) }", ".foo{color:#393}");
minify_test(".foo { color: hsla(120 50 40) }", ".foo{color:#393}");
minify_test(".foo { color: hsl(120 50 40 / 0.5) }", ".foo{color:#33993380}");
minify_test(".foo { color: hsla(120 50 40 / 50%) }", ".foo{color:#33993380}");
minify_test(".foo { color: hsl(120 none 40) }", ".foo{color:#666}");
minify_test(".foo { color: hsl(120 50 none) }", ".foo{color:#000}");
minify_test(".foo { color: hsl(120 150 40) }", ".foo{color:#0c0}");
minify_test(".foo { color: hsl(120 -50 40) }", ".foo{color:#666}");
minify_test(".foo { color: hsl(120 50 140) }", ".foo{color:#fff}");
minify_test(".foo { color: hsl(120.5 50.5 40.5) }", ".foo{color:#339b34}");
minify_test(".foo { color: hsl(120 .5 40) }", ".foo{color:#656765}");
minify_test(".foo { color: hsl(120 calc(25 * 2) 40) }", ".foo{color:#393}");
minify_test(".foo { color: hsl(120 calc(25% * 2) 40) }", ".foo{color:#393}");
minify_test(".foo { color: hwb(120 20 30) }", ".foo{color:#33b333}");
minify_test(".foo { color: hwb(120 20 30%) }", ".foo{color:#33b333}");
minify_test(".foo { color: hwb(120 20% 30) }", ".foo{color:#33b333}");
minify_test(".foo { color: hwb(120deg 20 30) }", ".foo{color:#33b333}");
minify_test(".foo { color: hwb(120 20 30 / 0.5) }", ".foo{color:#33b33380}");
minify_test(".foo { color: hwb(120 none 30) }", ".foo{color:#00b300}");
minify_test(".foo { color: hwb(120 20 none) }", ".foo{color:#3f3}");
minify_test(".foo { color: hwb(120 60 60) }", ".foo{color:gray}");
minify_test(".foo { color: hwb(120 -20 30) }", ".foo{color:#00b300}");
minify_test(".foo { color: hwb(120 20 130) }", ".foo{color:#2a2a2a}");
minify_test(".foo { color: hwb(120 calc(10 * 2) 30) }", ".foo{color:#33b333}");

// Relative color syntax is the modern syntax; the origin of a relative
// color, the operands of color-mix() and colors nested in other values go
// through the same parser.
minify_test(".foo { color: hsl(from red h 50 l) }", ".foo{color:#bf4040}");
minify_test(".foo { color: hwb(from red h 20 b) }", ".foo{color:#f33}");
minify_test(".foo { color: rgb(from hsl(120 50 40) r g b) }", ".foo{color:#393}");
minify_test(".foo { color: color-mix(in hsl, hsl(120 50 40), hwb(120 20 30)) }", ".foo{color:#33a633}");
minify_test(
".foo { background: linear-gradient(hsl(120 50 40), hwb(120 20 30)) }",
".foo{background:linear-gradient(#393,#33b333)}",
);
// Colors inside token lists (custom properties, var() fallbacks).
minify_test(".foo { --c: hsl(120 50 40) }", ".foo{--c:#393}");
minify_test(".foo { --c: hwb(120 20 30) }", ".foo{--c:#33b333}");
minify_test(".foo { color: var(--c, hsl(120 50 40)) }", ".foo{color:var(--c,#393)}");
// An hsl() whose alpha cannot be resolved keeps its channels, which are
// always written as percentages.
minify_test(".foo { color: hsl(120 50 40 / var(--a)) }", ".foo{color:hsl(120 50% 40%/var(--a))}");
minify_test(".foo { color: hsl(120 50 40% / var(--a)) }", ".foo{color:hsl(120 50% 40%/var(--a))}");

// Downleveling for targets without the space separated syntax works from
// the parsed color, so it is the same as for the percentage spelling.
prefix_test(
`.foo {
color: hsl(120 50 40 / 0.5);
border-color: hsl(120 50 40 / var(--a));
background: hwb(120 20 30 / 0.5);
}`,
indoc`
.foo {
color: rgba(51, 153, 51, .5);
border-color: hsla(120, 50%, 40%, var(--a));
background: rgba(51, 179, 51, .5);
}
`,
{ chrome: Some(60 << 16) },
);

// The legacy comma syntax takes only percentages, and hwb() has no legacy
// syntax, so these are left for the browser to reject, as before.
minify_test(".foo { color: hsl(120, 50, 40) }", ".foo{color:hsl(120,50,40)}");
minify_test(".foo { color: hsl(120, 50%, 40) }", ".foo{color:hsl(120,50%,40)}");
minify_test(".foo { color: hsl(120, 50, 40%) }", ".foo{color:hsl(120,50,40%)}");
minify_test(".foo { color: hsla(120, 50, 40, 0.5) }", ".foo{color:hsla(120,50,40,.5)}");
minify_test(".foo { color: hwb(120, 20, 30) }", ".foo{color:hwb(120,20,30)}");
minify_test(".foo { color: hsl(120 50, 40) }", ".foo{color:hsl(120 50,40)}");
minify_test(".foo { color: hsl(120 50 40 60) }", ".foo{color:hsl(120 50 40 60)}");
minify_test(".foo { --c: hsl(120, 50, 40) }", ".foo{--c:hsl(120,50,40)}");
// The legacy rgb() percentage form shares the percentage parser.
minify_test(".foo { color: rgb(10%, 20, 30) }", ".foo{color:rgb(10%,20,30)}");
minify_test(".foo { color: hsl(120, 50%, 40%) }", ".foo{color:#393}");
minify_test(".foo { color: rgb(10%, 20%, 30%) }", ".foo{color:#1a334d}");
});

describe("edge cases", () => {
describe("invalid gradient", () => {
cssTest(
Expand Down
Loading