Skip to content

Fix operator associativity and result units in calc() - #239

Draft
meziantou wants to merge 2 commits into
AngleSharp:develfrom
meziantou:feature/calcparser-operator-associativity-96200d
Draft

Fix operator associativity and result units in calc()#239
meziantou wants to merge 2 commits into
AngleSharp:develfrom
meziantou:feature/calcparser-operator-associativity-96200d

Conversation

@meziantou

@meziantou meziantou commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Types of Changes

Prerequisites

Please make sure you can check the following two boxes:

  • I have read the CONTRIBUTING document
  • My code follows the code style of this project

Contribution Type

What types of changes does your code introduce? Put an x in all the boxes that apply:

  • Bug fix (non-breaking change which fixes an issue, please reference the issue id)
  • New feature (non-breaking change which adds functionality, make sure to open an associated issue first)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • My change requires a change to the documentation
  • I have updated the documentation accordingly
  • I have added tests to cover my changes
  • All new and existing tests passed

Description

Fixes three related defects that made calc() report silently wrong numbers. No parse error was raised in any of these cases; the wrong value was simply handed to the computed style. Every expected value below was taken from Chrome via getComputedStyle before writing the fix.

1. Repeated operators were right associative

CalcParser recursed into itself for the right operand of every operator, so the expression tree came out right associative. Chains of two or more identical operators were evaluated in the wrong order:

Expression Chrome Before After
calc(10px - 2px - 3px) 5px 11px 5px
calc(100px - 10px - 20px - 30px) 40px 100px 40px
calc(100px / 2 / 5) 10px 250px 10px

Mixed precedence expressions such as calc(2 * 3px + 1px) happened to come out right, which is why this survived so long.

The four right recursive levels are replaced by two iterative loops that fold operands to the left:

expression := term (('+' | '-') term)*
term       := factor (('*' | '/') factor)*

That also collapses the artificial split between the Add/Sub and Mul/Div levels, which is what introduced the asymmetry. Serialization is unaffected, since CssText concatenates operands in order without adding parentheses, so the existing round trip tests still hold.

The loop guard additionally stops a failed left operand from being wrapped in an expression node with a null child. Previously only right was null checked.

2. Dividing equal units kept the 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, so a length landed where a number was expected:

Declaration Chrome Before After
opacity: calc(10px / 20px) 0.5 0.5px 0.5
flex-grow: calc(100px / 50px) 2 2px 2
z-index: calc(100px / 25px) 4 4px 4
opacity: calc(2s / 8s) 0.25 0.25s 0.25

line-height: calc(40px / 20px) in Chrome computes to 32px, that is the number 2 times the 16px font size, which confirms the result is a number rather than 2px.

3. WithValue turned unitless lengths into pixels

CssMetricValueExtensions.WithValue builds the result via Activator.CreateInstance(type, value), and the single argument constructor of CssLengthValue defaults to Unit.Px. Any unitless length therefore came back as a length in pixels:

Declaration Before After
opacity: calc(1 / 4) 0.25px 0.25
flex-shrink: calc(20 / 6) 3.33333333333333px 3.33333333333333
opacity: calc(2 * 3) 6px 6

It now preserves the template's unit for lengths and falls back to the activator for the other metric types. This one also covers multiplication, not just division.

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.
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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant