From 0988087f3121ee64b038c3079fb40bc43af9dfc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Barr=C3=A9?= Date: Fri, 4 Sep 2026 20:58:12 -0400 Subject: [PATCH 1/2] Fixed associativity of repeated calc() operators The calc() operand parser recursed into itself for the right operand of every operator, which made the resulting expression tree right associative. Chains of two or more identical operators were therefore evaluated in the wrong order: calc(10px - 2px - 3px) built Sub(10px, Sub(2px, 3px)) and computed to 11px instead of 5px, and calc(100px / 2 / 5) built 100 / (2 / 5) instead of (100 / 2) / 5. Mixed precedence expressions happened to come out right, so only chains of same precedence operators were affected. No parse error was raised; the wrong number was simply handed to the computed style. Replace the four right recursive levels with two iterative loops that fold the operands to the left, matching the grammar expression := term (('+' | '-') term)* term := factor (('*' | '/') factor)* This also collapses the artificial split between the Add/Sub and the Mul/Div levels, which is what introduced the asymmetry. Serialization is unaffected, as CssText concatenates the operands in order without adding parentheses. Expected values are taken from Chrome via getComputedStyle. --- src/AngleSharp.Css.Tests/Values/Calc.cs | 32 ++++++++++ src/AngleSharp.Css/Parser/Micro/CalcParser.cs | 58 ++++--------------- 2 files changed, 44 insertions(+), 46 deletions(-) diff --git a/src/AngleSharp.Css.Tests/Values/Calc.cs b/src/AngleSharp.Css.Tests/Values/Calc.cs index 85f01b14..8bc56e17 100644 --- a/src/AngleSharp.Css.Tests/Values/Calc.cs +++ b/src/AngleSharp.Css.Tests/Values/Calc.cs @@ -140,6 +140,38 @@ public void CalcLengthWithUnitlessOperandIsComputed(String expression, String ex Assert.AreEqual(expected, style.GetWidth()); } + [TestCase("calc(10px - 2px - 3px)", "5px")] + [TestCase("calc(100px - 10px - 20px - 30px)", "40px")] + [TestCase("calc(30px - 10px + 5px)", "25px")] + [TestCase("calc(10px + 20px - 5px)", "25px")] + [TestCase("calc(50px - (10px - 5px))", "45px")] + public void CalcSameOperatorChainIsLeftAssociative(String expression, String expected) + { + var document = ParseDocument($"

"); + var style = document.QuerySelector("p").ComputeCurrentStyle(); + Assert.AreEqual(expected, style.GetWidth()); + } + + [TestCase("calc(100px / 2 / 5)", "10px")] + [TestCase("calc(100px / 2 * 5)", "250px")] + [TestCase("calc(100px * 2 / 5)", "40px")] + [TestCase("calc(1px * 2 * 3)", "6px")] + public void CalcMultiplicativeChainIsLeftAssociative(String expression, String expected) + { + var document = ParseDocument($"

"); + var style = document.QuerySelector("p").ComputeCurrentStyle(); + Assert.AreEqual(expected, style.GetWidth()); + } + + [TestCase("calc(2 * 3px + 1px)", "7px")] + [TestCase("calc(21px + 5px - 4px * 2)", "18px")] + public void CalcMixedPrecedenceIsComputed(String expression, String expected) + { + var document = ParseDocument($"

"); + var style = document.QuerySelector("p").ComputeCurrentStyle(); + Assert.AreEqual(expected, style.GetWidth()); + } + [TestCase(typeof(CssAngleValue))] [TestCase(typeof(CssFrequencyValue))] [TestCase(typeof(CssIntegerValue))] diff --git a/src/AngleSharp.Css/Parser/Micro/CalcParser.cs b/src/AngleSharp.Css/Parser/Micro/CalcParser.cs index 49bbd36d..7a72f4bf 100644 --- a/src/AngleSharp.Css/Parser/Micro/CalcParser.cs +++ b/src/AngleSharp.Css/Parser/Micro/CalcParser.cs @@ -47,51 +47,12 @@ private static ICssValue ParseExpression(this StringSource source) } private static ICssValue ParseAddExpression(this StringSource source) - { - var left = ParseSubExpression(source); - - if (source.Current == Symbols.Plus) - { - source.SkipCurrentAndSpaces(); - var right = ParseAddExpression(source); - - if (right == null) - { - return null; - } - - return new CssCalcAddExpression(left, right); - } - - return left; - } - - private static ICssValue ParseSubExpression(this StringSource source) { var left = ParseMulExpression(source); - if (source.Current == Symbols.Minus) - { - source.SkipCurrentAndSpaces(); - var right = ParseSubExpression(source); - - if (right == null) - { - return null; - } - - return new CssCalcSubExpression(left, right); - } - - return left; - } - - private static ICssValue ParseMulExpression(this StringSource source) - { - var left = ParseDivExpression(source); - - if (source.Current == Symbols.Asterisk) + while (left != null && (source.Current == Symbols.Plus || source.Current == Symbols.Minus)) { + var add = source.Current == Symbols.Plus; source.SkipCurrentAndSpaces(); var right = ParseMulExpression(source); @@ -100,27 +61,32 @@ private static ICssValue ParseMulExpression(this StringSource source) return null; } - return new CssCalcMulExpression(left, right); + left = add ? + new CssCalcAddExpression(left, right) : + (ICssValue)new CssCalcSubExpression(left, right); } return left; } - private static ICssValue ParseDivExpression(this StringSource source) + private static ICssValue ParseMulExpression(this StringSource source) { var left = ParseBracketExpression(source); - if (source.Current == Symbols.Solidus) + while (left != null && (source.Current == Symbols.Asterisk || source.Current == Symbols.Solidus)) { + var mul = source.Current == Symbols.Asterisk; source.SkipCurrentAndSpaces(); - var right = ParseDivExpression(source); + var right = ParseBracketExpression(source); if (right == null) { return null; } - return new CssCalcDivExpression(left, right); + left = mul ? + new CssCalcMulExpression(left, right) : + (ICssValue)new CssCalcDivExpression(left, right); } return left; From 8449e1b82f3f017d9b1bf0024bfc107845cffd26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?G=C3=A9rald=20Barr=C3=A9?= Date: Fri, 4 Sep 2026 20:58:31 -0400 Subject: [PATCH 2/2] Fixed the unit of calc() division results Two independent defects made a division in calc() report a value with the wrong unit. Dividing two values that share a unit cancels the unit out and yields a plain number, but CssCalcDivExpression kept the unit of the left operand. calc(10px / 20px) computed to 0.5px instead of 0.5, so declarations such as opacity, flex-grow, z-index or line-height ended up with a length where a number was expected. CssMetricValueExtensions.WithValue creates the result through Activator.CreateInstance(type, value), and the single argument constructor of CssLengthValue defaults to pixels. Any unitless length was therefore turned into a length in pixels: calc(1 / 4) computed to 0.25px rather than 0.25. Preserve the unit of the template instead; this covers multiplication too, where calc(2 * 3) computed to 6px. Expected values are taken from Chrome via getComputedStyle, which reports 0.5 for opacity: calc(10px / 20px), 2 for flex-grow: calc(100px / 50px) and 150px for width: calc(100px / 2px * 3px). --- src/AngleSharp.Css.Tests/Values/Calc.cs | 33 +++++++++++++++++++ .../Extensions/CssMetricValueExtensions.cs | 6 +++- .../Expressions/CssCalcDivExpression.cs | 21 +++++++----- 3 files changed, 51 insertions(+), 9 deletions(-) diff --git a/src/AngleSharp.Css.Tests/Values/Calc.cs b/src/AngleSharp.Css.Tests/Values/Calc.cs index 8bc56e17..1b3cae70 100644 --- a/src/AngleSharp.Css.Tests/Values/Calc.cs +++ b/src/AngleSharp.Css.Tests/Values/Calc.cs @@ -172,6 +172,39 @@ public void CalcMixedPrecedenceIsComputed(String expression, String expected) Assert.AreEqual(expected, style.GetWidth()); } + [TestCase("opacity", "calc(10px / 20px)", "0.5")] + [TestCase("opacity", "calc(2s / 8s)", "0.25")] + [TestCase("flex-grow", "calc(100px / 50px)", "2")] + [TestCase("z-index", "calc(100px / 25px)", "4")] + [TestCase("line-height", "calc(40px / 20px)", "2")] + public void CalcDivisionOfEqualUnitsYieldsNumber(String property, String expression, String expected) + { + var document = ParseDocument($"

"); + var style = document.QuerySelector("p").ComputeCurrentStyle(); + Assert.AreEqual(expected, style.GetPropertyValue(property)); + } + + [TestCase("width", "calc(100px / 2)", "50px")] + [TestCase("width", "calc(100px * 3 / 2)", "150px")] + [TestCase("width", "calc(100px / 2px * 3px)", "150px")] + [TestCase("transition-duration", "calc(2s / 4)", "500ms")] + public void CalcDivisionByNumberKeepsUnitOfLeftOperand(String property, String expression, String expected) + { + var document = ParseDocument($"

"); + var style = document.QuerySelector("p").ComputeCurrentStyle(); + Assert.AreEqual(expected, style.GetPropertyValue(property)); + } + + [TestCase("opacity", "calc(1 / 4)", "0.25")] + [TestCase("opacity", "calc(2 * 3)", "6")] + [TestCase("flex-shrink", "calc(20 / 8)", "2.5")] + public void CalcOfUnitlessOperandsStaysUnitless(String property, String expression, String expected) + { + var document = ParseDocument($"

"); + var style = document.QuerySelector("p").ComputeCurrentStyle(); + Assert.AreEqual(expected, style.GetPropertyValue(property)); + } + [TestCase(typeof(CssAngleValue))] [TestCase(typeof(CssFrequencyValue))] [TestCase(typeof(CssIntegerValue))] diff --git a/src/AngleSharp.Css/Extensions/CssMetricValueExtensions.cs b/src/AngleSharp.Css/Extensions/CssMetricValueExtensions.cs index caf9d9ff..828229f2 100644 --- a/src/AngleSharp.Css/Extensions/CssMetricValueExtensions.cs +++ b/src/AngleSharp.Css/Extensions/CssMetricValueExtensions.cs @@ -34,6 +34,10 @@ static class CssMetricValueExtensions "public constructor taking a single Double themselves, e.g., via DynamicDependency.")] #endif public static ICssValue WithValue(this ICssMetricValue template, Double value) => - (ICssValue)Activator.CreateInstance(template.GetType(), value); + // The single argument constructor of CssLengthValue defaults to pixels, which would + // turn a unitless length (e.g., the result of calc(1 / 4)) into a length in pixels. + template is CssLengthValue length ? + new CssLengthValue(value, length.Type) : + (ICssValue)Activator.CreateInstance(template.GetType(), value); } } diff --git a/src/AngleSharp.Css/Values/Expressions/CssCalcDivExpression.cs b/src/AngleSharp.Css/Values/Expressions/CssCalcDivExpression.cs index 01ef8508..68245cc6 100644 --- a/src/AngleSharp.Css/Values/Expressions/CssCalcDivExpression.cs +++ b/src/AngleSharp.Css/Values/Expressions/CssCalcDivExpression.cs @@ -57,15 +57,20 @@ ICssValue ICssValue.Compute(ICssComputeContext context) var left = ComputeValue(_left, context); var right = ComputeValue(_right, context); - if (left is ICssMetricValue x && right is ICssMetricValue y && x.UnitString == y.UnitString) + if (left is ICssMetricValue x && right is ICssMetricValue y) { - var result = x.Value / y.Value; - return x.WithValue(result); - } - - if (left is ICssMetricValue unitLeft && right is ICssMetricValue unitlessRight && unitlessRight.UnitString.Length == 0) - { - return unitLeft.WithValue(unitLeft.Value / unitlessRight.Value); + // Dividing by a plain number scales the left operand, keeping its unit. + if (y.UnitString.Length == 0) + { + return x.WithValue(x.Value / y.Value); + } + + // Dividing two values sharing a unit cancels the unit out, i.e. the + // result is a plain number (calc(40px / 20px) is 2, not 2px). + if (x.UnitString == y.UnitString) + { + return new CssLengthValue(x.Value / y.Value, CssLengthValue.Unit.None); + } } return null;