diff --git a/src/css/values/calc.rs b/src/css/values/calc.rs index a429d0d7381e..d5f63594d753 100644 --- a/src/css/values/calc.rs +++ b/src/css/values/calc.rs @@ -510,8 +510,8 @@ impl Calc { 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); } @@ -918,10 +918,10 @@ impl Calc { Ok(val) } - pub(crate) fn parse_hypot(args: &mut [Self]) -> CssResult> { + pub(crate) fn parse_hypot(args: &[Self]) -> CssResult> { 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 { @@ -1094,20 +1094,19 @@ impl Calc { for arg in args.iter_mut() { let mut found: Option> = 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; } } @@ -1771,10 +1770,10 @@ impl CalcValue for Percentage { fn into_calc(self) -> Calc { Calc::Value(Box::new(self)) } - fn from_calc(c: Calc, _input: &mut css::Parser) -> CssResult { + fn from_calc(c: Calc, input: &mut css::Parser) -> CssResult { match c { Calc::Value(v) => Ok(*v), - _ => Ok(Percentage { v: f32::NAN }), + _ => Err(input.new_custom_error(css::ParserError::invalid_value)), } } #[inline] diff --git a/src/css/values/color.rs b/src/css/values/color.rs index b48d87439bc6..ee8f6e62d60d 100644 --- a/src/css/values/color.rs +++ b/src/css/values/color.rs @@ -2127,13 +2127,11 @@ impl RelativeComponentParser { return Ok(NumberOrPercentage::Percentage { unit_value: value }); } + // The keyword is a : `r * 50%` is a number times a percentage. if let Ok(value) = input.try_parse(|i| { match Calc::::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)), diff --git a/test/js/bun/css/color.test.ts b/test/js/bun/css/color.test.ts index 092843e693e5..2c387935d3b1 100644 --- a/test/js/bun/css/color.test.ts +++ b/test/js/bun/css/color.test.ts @@ -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 , so multiplying it by +// a gives a , 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( * ))", (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 , and a only combines with a + // 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"], diff --git a/test/js/bun/css/css.test.ts b/test/js/bun/css/css.test.ts index 7f866ce8df78..b4fd7924ff06 100644 --- a/test/js/bun/css/css.test.ts +++ b/test/js/bun/css/css.test.ts @@ -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 and a 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 , so `calc(r * 50%)` is a 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 , which only combines with a + // 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 @@ -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 (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( `