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
39 changes: 19 additions & 20 deletions src/css/values/calc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -510,8 +510,8 @@ impl<V: CalcValue> Calc<V> {
CalcUnit::Sqrt => Self::parse_numeric_fn(input, NumericFnOp::Sqrt, ctx, parse_ident),
CalcUnit::Exp => Self::parse_numeric_fn(input, NumericFnOp::Exp, ctx, parse_ident),
CalcUnit::Hypot => input.parse_nested_block(|i| {
let mut args = i.parse_comma_separated(|i| Self::parse_sum(i, ctx, parse_ident))?;
let val = Self::parse_hypot(&mut args)?;
let args = i.parse_comma_separated(|i| Self::parse_sum(i, ctx, parse_ident))?;
let val = Self::parse_hypot(&args)?;
if let Some(v) = val {
return Ok(v);
}
Expand Down Expand Up @@ -918,10 +918,10 @@ impl<V: CalcValue> Calc<V> {
Ok(val)
}

pub(crate) fn parse_hypot(args: &mut [Self]) -> CssResult<Option<Self>> {
pub(crate) fn parse_hypot(args: &[Self]) -> CssResult<Option<Self>> {
if args.len() == 1 {
let v = core::mem::replace(&mut args[0], Calc::Number(0.0));
return Ok(Some(v));
// hypot(A) is sqrt(A * A), i.e. abs(A): it folds exactly when abs() would.
return Ok(Self::apply_map(&args[0], absf));
}

if args.len() == 2 {
Expand Down Expand Up @@ -1094,20 +1094,19 @@ impl<V: CalcValue> Calc<V> {

for arg in args.iter_mut() {
let mut found: Option<Option<usize>> = None;
if let Calc::Value(val) = &*arg {
for (idx, b) in reduced.iter().enumerate() {
if let Calc::Value(v) = b {
let result = protocol::PartialCmp::partial_cmp(&**val, &**v);
if result.is_some() {
if result == Some(order) {
found = Some(Some(idx));
break;
} else {
found = Some(None);
break;
}
}
for (idx, b) in reduced.iter().enumerate() {
let result = match (&*arg, b) {
(Calc::Value(val), Calc::Value(v)) => {
protocol::PartialCmp::partial_cmp(&**val, &**v)
}
(Calc::Number(val), Calc::Number(v)) => {
protocol::PartialCmp::partial_cmp(val, v)
}
_ => None,
};
if let Some(result) = result {
found = Some((result == order).then_some(idx));
break;
}
}

Expand Down Expand Up @@ -1771,10 +1770,10 @@ impl CalcValue for Percentage {
fn into_calc(self) -> Calc<Self> {
Calc::Value(Box::new(self))
}
fn from_calc(c: Calc<Self>, _input: &mut css::Parser) -> CssResult<Self> {
fn from_calc(c: Calc<Self>, input: &mut css::Parser) -> CssResult<Self> {
match c {
Calc::Value(v) => Ok(*v),
_ => Ok(Percentage { v: f32::NAN }),
_ => Err(input.new_custom_error(css::ParserError::invalid_value)),
}
}
#[inline]
Expand Down
6 changes: 2 additions & 4 deletions src/css/values/color.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2127,13 +2127,11 @@ impl RelativeComponentParser {
return Ok(NumberOrPercentage::Percentage { unit_value: value });
}

// The keyword is a <number>: `r * 50%` is a number times a percentage.
if let Ok(value) = input.try_parse(|i| {
match Calc::<Percentage>::parse_with(i, this, |ctx, ident| {
let v = ctx.get_ident(ident, allowed)?;
// value variant is a *Percentage
// but we immediately dereference it and discard the pointer
// so using a field on this closure struct instead of making a gratuitous allocation
Some(Calc::Value(Box::new(Percentage { v })))
Some(Calc::Number(v))
}) {
Ok(Calc::Value(v)) => Ok(*v),
_ => Err(i.new_custom_error(css::ParserError::invalid_value)),
Expand Down
102 changes: 102 additions & 0 deletions test/js/bun/css/color.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,6 +550,108 @@ describe("css string output parses back to the same color", () => {
});
});

// https://drafts.csswg.org/css-color-5/#relative-colors
// Inside a math function a channel keyword is a <number>, so multiplying it by
// a <percentage> gives a <percentage>, while adding or comparing it to one is a
// type error.
describe("relative color calc() with percentages", () => {
const origin = "color(srgb .8 .4 .2)";

test.each([
["calc(r * 50%)", ".4"],
["calc(50% * r)", ".4"],
["calc(r * -50%)", "-.4"],
["calc(r * 50% + 10%)", ".5"],
["calc(50% - r * 25%)", ".3"],
["calc(r / 2 * 100%)", ".4"],
["calc(r * g * 50%)", ".16"],
["calc((r + g) * 50%)", ".6"],
["min(r * 50%, 30%)", ".3"],
["max(r * 50%, 30%)", ".4"],
["round(r * 100%, 30%)", ".9"],
["mod(r * 100%, 30%)", ".2"],
["hypot(r * 100%, 30%)", ".8544"],
])("%s is a percentage of the channel", (expr, expected) => {
expect(color(`color(from ${origin} srgb ${expr} g b)`, "css")).toBe(`color(srgb ${expected} .4 .2)`);
});

const spaces: [space: string, channels: string, printed?: string][] = [
["srgb", "g b"],
["srgb-linear", "g b"],
["display-p3", "g b"],
["prophoto-rgb", "g b"],
["rec2020", "g b"],
["xyz-d50", "y z"],
["xyz-d65", "y z", "xyz"],
["xyz", "y z"],
];
test.each(spaces)("color(from ... %s calc(<channel> * <percentage>))", (space, channels, printed = space) => {
const [b, c] = channels.split(" ");
expect(color(`color(from color(${space} .8 .4 .2) ${space} ${b} calc(${b} * 50%) calc(50% * ${c}))`, "css")).toBe(
`color(${printed} .4 .2 .1)`,
);
});

test.each([
["color(from color(srgb .8 .4 .2 / .5) srgb r g b / calc(alpha * 50%))", "color(srgb .8 .4 .2 / .25)"],
["rgb(from rgb(200 100 50 / .5) r g b / calc(alpha * 50%))", "#c8643240"],
["hsl(from hsl(30 50% 50% / .5) h s l / calc(50% * alpha))", "#bf804040"],
["hwb(from hwb(30 20% 40% / .5) h w b / calc(alpha * 50%))", "#99663340"],
["lab(from lab(50% 20 30 / .5) l a b / calc(alpha * 50%))", "lab(50% 20 30 / .25)"],
["lch(from lch(50% 20 30 / .5) l c h / calc(alpha * 50%))", "lch(50% 20 30 / .25)"],
["oklab(from oklab(50% .1 .1 / .5) l a b / calc(alpha * 50%))", "oklab(50% .1 .1 / .25)"],
["oklch(from oklch(50% .1 30 / .5) l c h / calc(alpha * 50%))", "oklch(50% .1 30 / .25)"],
])("%s", (input, expected) => {
expect(color(input, "css")).toBe(expected);
});

// min()/max() fold channel keywords whether or not a literal number is mixed in.
test.each([
["min(r, g)", ".4"],
["max(r, g)", ".8"],
["min(r, g, b)", ".2"],
["min(r, .5)", ".5"],
["min(.5, r)", ".5"],
["max(r, .5)", ".8"],
["min(r, g, .3)", ".3"],
["max(g, .3, b)", ".4"],
["calc(r * .5)", ".4"],
["round(r, .5)", "1"],
])("%s folds to a number", (expr, expected) => {
expect(color(`color(from ${origin} srgb ${expr} g b)`, "css")).toBe(`color(srgb ${expected} .4 .2)`);
});

test("min()/max() fold in the number and hue channels too", () => {
expect(color("lab(from lab(50% 20 30) l max(a, b) b)", "css")).toBe("lab(50% 30 30)");
expect(color("lab(from lab(50% 20 30) l min(a, 10) b)", "css")).toBe("lab(50% 10 30)");
expect(color("lch(from lch(50% 20 30) l c min(h, 10))", "css")).toBe("lch(50% 20 10)");
});

// A bare keyword is a <number>, and a <number> only combines with a <percentage>
// through * and /. lightningcss leaves every one of these unparsed as well.
test.each([
"color(from color(srgb .8 .4 .2) srgb calc(r + 50%) g b)",
"color(from color(srgb .8 .4 .2) srgb calc(50% + r) g b)",
"color(from color(srgb .8 .4 .2) srgb calc(r * 50% + .1) g b)",
"color(from color(srgb .8 .4 .2) srgb calc(r * 50% - g) g b)",
"color(from color(srgb .8 .4 .2) srgb min(r, 50%) g b)",
"color(from color(srgb .8 .4 .2) srgb max(50%, r) g b)",
"color(from color(srgb .8 .4 .2) srgb round(r, 30%) g b)",
"color(from color(srgb .8 .4 .2) srgb hypot(r, 30%) g b)",
"color(from color(srgb .8 .4 .2 / .5) srgb r g b / calc(alpha + 10%))",
"color(from color(srgb .8 .4 .2 / .5) srgb r g b / max(alpha, 30%))",
"rgb(from rgb(200 100 50 / .5) r g b / calc(alpha + 10%))",
"rgb(from rgb(200 100 50 / .5) r g b / calc(alpha - 20%))",
"rgb(from rgb(200 100 50 / .5) r g b / min(alpha, 30%))",
// The same type error outside relative color syntax (not a `none` channel).
"color(srgb calc(1 + 50%) 0 0)",
"color(srgb calc(50% + 1) 0 0)",
"hsl(120 calc(50% + 1) 40%)",
])("%s mixes a number and a percentage and does not parse", input => {
expect(color(input, "css")).toBeNull();
});
});

describe("input forms", () => {
test.each([
["a named color", "red"],
Expand Down
138 changes: 138 additions & 0 deletions test/js/bun/css/css.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,92 @@ describe("css tests", () => {
minify_test(`a { opacity: calc(NaN) }`, `a{opacity:0}`);
minify_test(`a { rotate: calc(NaN * 1deg) }`, `a{rotate:0deg}`);
minify_test(`a { transition-duration: calc(NaN * 1s) }`, `a{transition-duration:0s}`);

// min()/max() of plain numbers fold like min()/max() of dimensions do.
minify_test(`a { line-height: min(1.5, 1.2) }`, `a{line-height:1.2}`);
minify_test(`a { line-height: max(1.2, 2, 1.5) }`, `a{line-height:2}`);
minify_test(`a { line-height: calc(min(1.5, 1.2) * 2) }`, `a{line-height:2.4}`);
minify_test(`a { width: calc(min(1.5, 1.2) * 10px) }`, `a{width:12px}`);
minify_test(`a { opacity: min(.5, .7) }`, `a{opacity:.5}`);
minify_test(`a { opacity: max(50%, 70%) }`, `a{opacity:.7}`);
minify_test(`a { opacity: min(NaN, .5) }`, `a{opacity:min(NaN,.5)}`);
minify_test(`a { width: min(10px, 1.5) }`, `a{width:min(10px,1.5)}`);

// A <number> and a <percentage> cannot be added: the declaration is invalid and
// is left as written, not evaluated to NaN and serialized as 0.
minify_test(`a { opacity: calc(50% + 1) }`, `a{opacity:calc(50% + 1)}`);
minify_test(`a { opacity: calc(1 - 50%) }`, `a{opacity:calc(1 - 50%)}`);
minify_test(`a { opacity: calc(50% + 25%) }`, `a{opacity:.75}`);
minify_test(`a { opacity: calc(50% * 1.5) }`, `a{opacity:.75}`);
});

describe("relative color calc() with percentages", () => {
// https://drafts.csswg.org/css-color-5/#relative-colors: a channel keyword is
// a <number>, so `calc(r * 50%)` is a <percentage> and `calc(r + 50%)` is invalid.
minify_test(
".foo { color: color(from color(srgb .8 .4 .2) srgb calc(r * 50%) calc(50% * g) b) }",
".foo{color:color(srgb .4 .2 .2)}",
);
minify_test(
".foo { color: color(from color(display-p3 .8 .4 .2) display-p3 r calc(g * 50% + 10%) calc(50% - b * 50%)) }",
".foo{color:color(display-p3 .8 .3 .4)}",
);
minify_test(
".foo { color: color(from color(srgb .8 .4 .2) srgb min(r * 50%, 30%) max(g * 50%, 30%) b) }",
".foo{color:color(srgb .3 .3 .2)}",
);
minify_test(
".foo { color: color(from color(srgb .8 .4 .2) srgb round(r * 100%, 30%) g b) }",
".foo{color:color(srgb .9 .4 .2)}",
);
minify_test(
".foo { color: color(from color(srgb .8 .4 .2 / .5) srgb r g b / calc(alpha * 50%)) }",
".foo{color:color(srgb .8 .4 .2/.25)}",
);
minify_test(".foo { color: rgb(from rgb(200 100 50 / .5) r g b / calc(alpha * 50%)) }", ".foo{color:#c8643240}");
// Not `rgb(255 0 0/calc(alpha*50%))`, which no browser accepts.
minify_test(".foo { color: rgb(from red r g b / calc(alpha * 50%)) }", ".foo{color:#ff000080}");
minify_test(".foo { color: hsl(from hsl(30 50% 50% / .5) h s l / calc(50% * alpha)) }", ".foo{color:#bf804040}");
minify_test(
".foo { color: oklch(from oklch(50% .1 30 / .5) l c h / calc(alpha * 50%)) }",
".foo{color:oklch(50% .1 30/.25)}",
);

// min()/max() fold over channel keywords, with or without a literal number
// among the arguments.
minify_test(
".foo { color: color(from color(srgb .8 .4 .2) srgb min(r, g) max(g, b) b) }",
".foo{color:color(srgb .4 .4 .2)}",
);
minify_test(
".foo { color: color(from color(srgb .8 .4 .2) srgb min(r, .5) max(g, .5) b) }",
".foo{color:color(srgb .5 .5 .2)}",
);
minify_test(".foo { color: lab(from lab(50% 20 30) l max(a, b) min(b, 10)) }", ".foo{color:lab(50% 30 10)}");

// A bare keyword is a <number>, which only combines with a <percentage>
// through * and /; these are invalid and stay as written, as in lightningcss.
minify_test(
".foo { color: color(from red srgb calc(r + 50%) g b) }",
".foo{color:color(from red srgb calc(r + 50%)g b)}",
);
minify_test(
".foo { color: color(from red srgb min(r, 50%) g b) }",
".foo{color:color(from red srgb min(r,50%)g b)}",
);
minify_test(
".foo { color: color(from red srgb r g b / calc(alpha + 10%)) }",
".foo{color:color(from red srgb r g b/calc(alpha + 10%))}",
);
// rgb()/hsl() with an alpha that does not parse resolve the channels and keep
// the alpha as written (the `/ var(--alpha)` path); same output as lightningcss.
// The point is that the invalid alpha is not folded into `red` / `#ff00004d`.
minify_test(
".foo { color: rgb(from red r g b / calc(alpha + 10%)) }",
".foo{color:rgb(255 0 0/calc(alpha + 10%))}",
);
minify_test(".foo { color: rgb(from red r g b / min(alpha, 30%)) }", ".foo{color:rgb(255 0 0/min(alpha,30%))}");
minify_test(".foo { color: color(srgb calc(1 + 50%) 0 0) }", ".foo{color:color(srgb calc(1 + 50%)0 0)}");
});
describe("calc stack overflow", () => {
// https://github.com/oven-sh/bun/issues/20128
Expand All @@ -191,6 +277,58 @@ describe("css tests", () => {
); // ideally -400% - 8vh + 3ic
minify_test(`a { top: calc(100% - 1 * 2 - 8 * 2); }`, `a{top:calc(100% - 2 - 16)}`); // ideally 100% - 18
});
describe("hypot() with one argument", () => {
// hypot(A) is sqrt(A * A), i.e. abs(A) (CSS Values 4), so it folds exactly like
// abs(A) does. It used to be returned as its argument, unchanged.
minify_test(`a { margin-left: hypot(-3px) }`, `a{margin-left:3px}`);
minify_test(`a { margin-left: hypot(3px) }`, `a{margin-left:3px}`);
minify_test(`a { margin-left: hypot(calc(-3px)) }`, `a{margin-left:3px}`);
minify_test(`a { margin-left: hypot(-1in) }`, `a{margin-left:1in}`);
minify_test(`a { border-spacing: hypot(-3px) }`, `a{border-spacing:3px}`);
minify_test(`a { rotate: hypot(-90deg) }`, `a{rotate:90deg}`);
minify_test(
`a { background: conic-gradient(red hypot(-0.25turn), blue) }`,
`a{background:conic-gradient(red .25turn,#00f)}`,
);
minify_test(`a { transition-delay: hypot(-2s) }`, `a{transition-delay:2s}`);
minify_test(`a { line-height: hypot(-3) }`, `a{line-height:3}`);
minify_test(`a { opacity: hypot(-0.5) }`, `a{opacity:.5}`);
minify_test(`a { color: rgb(255 0 0 / hypot(-0.5)) }`, `a{color:#ff000080}`);
// The folded value takes part in the calculation around it.
minify_test(`a { margin-left: calc(hypot(-3px) + 1px) }`, `a{margin-left:4px}`);
minify_test(`a { width: calc(100% - hypot(-3px)) }`, `a{width:calc(100% - 3px)}`);
minify_test(`a { opacity: calc(1 - hypot(-0.25)) }`, `a{opacity:.75}`);
// Two arguments still fold the way they did before.
minify_test(`a { margin-left: hypot(-3px, 4px) }`, `a{margin-left:5px}`);

// Whatever abs() leaves unevaluated (a percentage, a sum, a function that did not
// fold), hypot() leaves unevaluated too, and it stays wrapped in hypot(): the
// argument on its own would have the wrong sign, and a bare sum or product
// outside of a math function is not valid CSS.
minify_test(`a { margin-left: hypot(-30%) }`, `a{margin-left:hypot(-30%)}`);
minify_test(`a { margin-left: abs(-30%) }`, `a{margin-left:abs(-30%)}`);
minify_test(`a { margin-left: hypot(1px + 10%) }`, `a{margin-left:hypot(1px + 10%)}`);
minify_test(`a { margin-left: abs(1px + 10%) }`, `a{margin-left:abs(1px + 10%)}`);
minify_test(`a { margin-left: hypot(2 * min(1px, 1em)) }`, `a{margin-left:hypot(2*min(1px,1em))}`);
minify_test(`a { margin-left: hypot(1px + min(1px, 1em)) }`, `a{margin-left:hypot(1px + min(1px,1em))}`);
minify_test(`a { margin-left: hypot(min(-1px, -1em)) }`, `a{margin-left:hypot(min(-1px,-1em))}`);
minify_test(`a { border-spacing: hypot(min(-1px, -1em)) }`, `a{border-spacing:hypot(min(-1px,-1em))}`);
minify_test(
`a { background: conic-gradient(red hypot(-30%), blue) }`,
`a{background:conic-gradient(red hypot(-30%),#00f)}`,
);
minify_test(`a { margin-left: calc(1px + hypot(1px + 10%)) }`, `a{margin-left:calc(1px + hypot(1px + 10%))}`);
minify_test(`a { width: calc(100% - hypot(1px + 10%)) }`, `a{width:calc(100% + -1*hypot(1px + 10%))}`);
// A bare <percentage> (opacity, an alpha channel) is not folded by abs() either,
// so hypot() of one is kept as written, whether on its own or inside a sum. The
// sums used to be evaluated as if hypot() were not there (.6 and #f009).
minify_test(`a { opacity: hypot(-50%) }`, `a{opacity:hypot(-50%)}`);
minify_test(`a { opacity: hypot(50%) }`, `a{opacity:hypot(50%)}`);
minify_test(`a { opacity: abs(50%) }`, `a{opacity:abs(50%)}`);
minify_test(`a { opacity: calc(10% + hypot(50%)) }`, `a{opacity:calc(10% + hypot(50%))}`);
minify_test(`a { color: rgb(255 0 0 / hypot(50%)) }`, `a{color:rgb(255 0 0/hypot(50%))}`);
minify_test(`a { color: rgb(255 0 0 / calc(hypot(50%) + 10%)) }`, `a{color:rgb(255 0 0/calc(hypot(50%) + 10%))}`);
});
describe("border_spacing", () => {
minify_test(
`
Expand Down