diff --git a/src/css/values/calc.rs b/src/css/values/calc.rs index 45fc6c521542..6689a85afc7d 100644 --- a/src/css/values/calc.rs +++ b/src/css/values/calc.rs @@ -377,7 +377,7 @@ impl Calc { Self::parse_math_fn( i, strategy, - |s, a, b| round((), a, b, s), + round, |s, a, b| MathFunction::Round { strategy: s, value: a, @@ -1367,14 +1367,33 @@ fn arr2(a: T, b: T) -> Vec { vec![a, b] } -fn round(_: (), value: f32, to: f32, strategy: RoundingStrategy) -> f32 { - let v = value / to; - match strategy { - RoundingStrategy::Down => v.floor() * to, - RoundingStrategy::Up => v.ceil() * to, - RoundingStrategy::Nearest => v.round() * to, - RoundingStrategy::ToZero => v.trunc() * to, +/// CSS Values 4 `#round-func` and `#round-infinities`. This differs from lightningcss on purpose. +#[inline(never)] +fn round(strategy: RoundingStrategy, value: f32, to: f32) -> f32 { + let step = to.abs(); + let quotient = value / step; + if step.is_infinite() && value.is_finite() { + let signed_zero = quotient; + return match strategy { + RoundingStrategy::Up if value > 0.0 => f32::INFINITY, + RoundingStrategy::Down if value < 0.0 => f32::NEG_INFINITY, + _ => signed_zero, + }; } + let lower = quotient.floor(); + let multiple = match strategy { + RoundingStrategy::Down => lower, + RoundingStrategy::Up => quotient.ceil(), + RoundingStrategy::ToZero => quotient.trunc(), + RoundingStrategy::Nearest => { + if quotient - lower < 0.5 { + lower + } else { + quotient.ceil() + } + } + }; + multiple * step } fn hypot(_: (), a: f32, b: f32) -> f32 { diff --git a/test/js/bun/css/color.test.ts b/test/js/bun/css/color.test.ts index 64477fbebbc0..8373f7196c3d 100644 --- a/test/js/bun/css/color.test.ts +++ b/test/js/bun/css/color.test.ts @@ -686,6 +686,20 @@ describe("rgb() channel order and legacy syntax", () => { }); }); +describe("round() in a channel", () => { + // CSS Values 4: round() takes the multiple nearer to +∞ when the value is exactly halfway, + // so a hue of -45 rounds to -30 and not to -60. + test.each([ + ["hsl(round(-45, 30) 50% 50%)", "#bf4080"], + ["hsl(round(45, 30) 50% 50%)", "#bfbf40"], + // The channel keywords of a relative color reach the same fold. + ["lab(from lab(50% -45 20) l round(a, 30) b)", "lab(50% -30 20)"], + ["lch(from lch(50% 30 120) l c round(h - 165, 30))", "lch(50% 30 -30)"], + ])("%s is %s", (input, expected) => { + expect(color(input, "css")).toBe(expected); + }); +}); + describe("conversions between color spaces", () => { // Each case converts a color whose channels all differ, so a channel landing // in another channel's place shows up in the output. Mixing a color with diff --git a/test/js/bun/css/css.test.ts b/test/js/bun/css/css.test.ts index 6e7b32d3b5e6..43a8ce15873c 100644 --- a/test/js/bun/css/css.test.ts +++ b/test/js/bun/css/css.test.ts @@ -223,6 +223,100 @@ describe("css tests", () => { minify_test(`a { transition-duration: clamp(1s, 500ms, 2s) }`, `a{transition-duration:1s}`); minify_test(`a { width: calc(clamp(1px, 2px, 3px) + 1px) }`, `a{width:3px}`); }); + describe("round() folding", () => { + // CSS Values 4: round() picks between "lower B", the multiple of B nearer to -∞, and "upper B", + // the multiple nearer to +∞. `nearest` takes upper B when the value is exactly halfway. + // The expected values are what Chrome computes. + minify_test(`a { margin-left: round(2.5px, 1px) }`, `a{margin-left:3px}`); + minify_test(`a { margin-left: round(-2.5px, 1px) }`, `a{margin-left:-2px}`); + minify_test(`a { margin-left: round(7.5px, 5px) }`, `a{margin-left:10px}`); + minify_test(`a { margin-left: round(-7.5px, 5px) }`, `a{margin-left:-5px}`); + minify_test(`a { margin-left: round(-1.5px, 1px) }`, `a{margin-left:-1px}`); + minify_test(`a { margin-left: round(-.5px, 1px) }`, `a{margin-left:0}`); + minify_test(`a { margin-left: round(nearest, -2.5px, 1px) }`, `a{margin-left:-2px}`); + // Not a tie: the nearer multiple wins on both sides of zero. + minify_test(`a { margin-left: round(13px, 10px) }`, `a{margin-left:10px}`); + minify_test(`a { margin-left: round(18px, 10px) }`, `a{margin-left:20px}`); + minify_test(`a { margin-left: round(-13px, 10px) }`, `a{margin-left:-10px}`); + minify_test(`a { margin-left: round(-18px, 10px) }`, `a{margin-left:-20px}`); + // A value that is already a multiple stays, with every strategy. + minify_test(`a { margin-left: round(-10px, 5px) }`, `a{margin-left:-10px}`); + minify_test(`a { margin-left: round(up, 10px, 5px) }`, `a{margin-left:10px}`); + minify_test(`a { margin-left: round(down, 10px, 5px) }`, `a{margin-left:10px}`); + minify_test(`a { margin-left: round(to-zero, 10px, 5px) }`, `a{margin-left:10px}`); + minify_test(`a { margin-left: round(up, -10px, 5px) }`, `a{margin-left:-10px}`); + minify_test(`a { margin-left: round(down, -10px, 5px) }`, `a{margin-left:-10px}`); + minify_test(`a { margin-left: round(to-zero, -10px, 5px) }`, `a{margin-left:-10px}`); + // The other strategies at a negative tie. + minify_test(`a { margin-left: round(up, -15px, 10px) }`, `a{margin-left:-10px}`); + minify_test(`a { margin-left: round(down, -15px, 10px) }`, `a{margin-left:-20px}`); + minify_test(`a { margin-left: round(to-zero, -15px, 10px) }`, `a{margin-left:-10px}`); + // The sign of the step does not change which multiple is the upper one. The first six rows + // are the "negative step" block of WPT css/css-values/round-function.html. + minify_test(`a { margin-left: round(23px, -10px) }`, `a{margin-left:20px}`); + minify_test(`a { margin-left: round(18px, -10px) }`, `a{margin-left:20px}`); + minify_test(`a { margin-left: round(15px, -10px) }`, `a{margin-left:20px}`); + minify_test(`a { margin-left: round(13px, -10px) }`, `a{margin-left:10px}`); + minify_test(`a { margin-left: round(-13px, -10px) }`, `a{margin-left:-10px}`); + minify_test(`a { margin-left: round(-18px, -10px) }`, `a{margin-left:-20px}`); + minify_test(`a { margin-left: round(-15px, -10px) }`, `a{margin-left:-10px}`); + minify_test(`a { margin-left: round(up, 23px, -10px) }`, `a{margin-left:30px}`); + minify_test(`a { margin-left: round(down, 23px, -10px) }`, `a{margin-left:20px}`); + minify_test(`a { margin-left: round(up, -23px, -10px) }`, `a{margin-left:-20px}`); + minify_test(`a { margin-left: round(down, -23px, -10px) }`, `a{margin-left:-30px}`); + minify_test(`a { margin-left: round(to-zero, 23px, -10px) }`, `a{margin-left:20px}`); + minify_test(`a { margin-left: round(to-zero, -23px, -10px) }`, `a{margin-left:-20px}`); + // A finite value with an infinite step is zero. The exceptions are `up` with a positive + // value (+∞) and `down` with a negative value (-∞). + minify_test(`a { margin-left: round(up, 5px, calc(infinity * 1px)) }`, `a{margin-left:3.40282e38px}`); + minify_test(`a { margin-left: round(down, -5px, calc(infinity * 1px)) }`, `a{margin-left:-3.40282e38px}`); + minify_test(`a { margin-left: calc(1px + round(5px, calc(infinity * 1px))) }`, `a{margin-left:1px}`); + minify_test(`a { margin-left: calc(1px + round(up, -5px, calc(infinity * 1px))) }`, `a{margin-left:1px}`); + minify_test(`a { margin-left: calc(1px + round(down, 5px, calc(infinity * 1px))) }`, `a{margin-left:1px}`); + minify_test(`a { margin-left: calc(1px + round(to-zero, -5px, calc(-infinity * 1px))) }`, `a{margin-left:1px}`); + // A zero value is not positive and not negative, so `up` and `down` give a zero too. + minify_test(`a { margin-left: calc(1px + round(up, 0px, calc(infinity * 1px))) }`, `a{margin-left:1px}`); + minify_test(`a { margin-left: calc(1px + round(down, 0px, calc(infinity * 1px))) }`, `a{margin-left:1px}`); + // That zero has the sign of the value: pow(+0, -1) is +∞ and pow(-0, -1) is -∞. + minify_test(`a { margin-left: calc(1px * pow(round(up, 0, infinity), -1)) }`, `a{margin-left:3.40282e38px}`); + minify_test(`a { margin-left: calc(1px * pow(round(up, -1 * 0, infinity), -1)) }`, `a{margin-left:-3.40282e38px}`); + minify_test(`a { margin-left: calc(1px * pow(round(down, 0, infinity), -1)) }`, `a{margin-left:3.40282e38px}`); + minify_test( + `a { margin-left: calc(1px * pow(round(down, -1 * 0, infinity), -1)) }`, + `a{margin-left:-3.40282e38px}`, + ); + // An infinite value keeps its infinity. A zero step is NaN, and so are two infinite + // arguments with every strategy. NaN prints as zero. + minify_test(`a { margin-left: round(calc(-infinity * 1px), -5px) }`, `a{margin-left:-3.40282e38px}`); + minify_test(`a { margin-left: calc(1px + round(5px, 0px)) }`, `a{margin-left:0px}`); + minify_test( + `a { margin-left: calc(1px + round(up, calc(infinity * 1px), calc(infinity * 1px))) }`, + `a{margin-left:0px}`, + ); + minify_test( + `a { margin-left: calc(1px + round(down, calc(-infinity * 1px), calc(infinity * 1px))) }`, + `a{margin-left:0px}`, + ); + // Other value types and contexts. + minify_test(`a { rotate: round(-45deg, 30deg) }`, `a{rotate:-30deg}`); + minify_test(`a { transition-delay: round(-2.5s, 1s) }`, `a{transition-delay:-2s}`); + minify_test(`a { transition-delay: round(-2.5s, 1000ms) }`, `a{transition-delay:-2s}`); + minify_test(`a { margin-left: round(-75%, 50%) }`, `a{margin-left:-50%}`); + minify_test(`a { margin-left: round(-1.5em, 1em) }`, `a{margin-left:-1em}`); + minify_test(`a { scale: round(-2.5, 1) }`, `a{scale:-2}`); + minify_test(`a { color: hsl(round(-45, 30) 50% 50%) }`, `a{color:#bf4080}`); + minify_test(`@media (min-width: round(-2.5px, 1px)) { a { color: red } }`, `@media (width>=-2px){a{color:red}}`); + minify_test( + `@property --x { syntax: ""; inherits: false; initial-value: round(-2.5px, 1px) }`, + `@property --x{syntax:"";inherits:false;initial-value:-2px}`, + ); + // The folded value feeds the enclosing math function. + minify_test(`a { margin-left: calc(round(-2.5px, 1px) * 2) }`, `a{margin-left:-4px}`); + minify_test(`a { margin-left: max(round(-2.5px, 1px), -2.75px) }`, `a{margin-left:-2px}`); + // A zero result has a sign: round(-.5, 1) and round(-.4, 1) are both -0. + minify_test(`a { margin-left: calc(1px * pow(round(-.5, 1), -1)) }`, `a{margin-left:-3.40282e38px}`); + minify_test(`a { margin-left: calc(1px * pow(round(-.4, 1), -1)) }`, `a{margin-left:-3.40282e38px}`); + }); describe("calc stack overflow", () => { // https://github.com/oven-sh/bun/issues/20128 minify_test(`a { width: calc(100% - 2 - 1) }`, `a{width:calc(100% - 2 - 1)}`); // ideally 100% - 3