diff --git a/src/ExCSS.Tests/PropertyTests/CalcPropertyTests.cs b/src/ExCSS.Tests/PropertyTests/CalcPropertyTests.cs
new file mode 100644
index 00000000..951f06b6
--- /dev/null
+++ b/src/ExCSS.Tests/PropertyTests/CalcPropertyTests.cs
@@ -0,0 +1,63 @@
+using Xunit;
+
+namespace ExCSS.Tests.PropertyTests
+{
+ public class CalcPropertyTests : CssConstructionFunctions
+ {
+ [Theory]
+ // calc()/min()/max()/clamp() (CSS Values 4 10) are accepted wherever a compatible numeric value is,
+ // and the authored text is preserved.
+ [InlineData("width: calc(100% - 20px)", "width")]
+ [InlineData("width: calc(50% + 2em)", "width")]
+ [InlineData("width: calc((100% - 20px) / 3)", "width")]
+ [InlineData("margin-top: calc(10px * 2)", "margin-top")]
+ [InlineData("width: min(100%, 500px)", "width")]
+ [InlineData("width: max(50%, 200px)", "width")]
+ [InlineData("width: clamp(200px, 50%, 500px)", "width")]
+ [InlineData("width: calc(min(10px, 5%) + 2px)", "width")]
+ public void CalcAcceptedInLengthContext(string declaration, string name)
+ {
+ var property = ParseDeclaration(declaration);
+
+ Assert.Equal(name, property.Name);
+ Assert.True(property.HasValue);
+ }
+
+ [Fact]
+ public void CalcPreservesAuthoredText()
+ {
+ var property = ParseDeclaration("width: calc(100% - 20px)");
+
+ Assert.Equal("calc(100% - 20px)", property.Value);
+ }
+
+ [Theory]
+ // A calc() also works in the angle context.
+ [InlineData("transform: rotate(calc(45deg * 2))")]
+ [InlineData("transform: rotate(calc(1turn / 4))")]
+ public void CalcAcceptedInAngleContext(string declaration)
+ {
+ var property = ParseDeclaration(declaration);
+
+ Assert.True(property.HasValue);
+ }
+
+ [Theory]
+ // Type-checking (CSS Values 4 10.10): dimensionally-inconsistent or malformed expressions are
+ // rejected.
+ [InlineData("width: calc(1px + 1s)")] // length + time
+ [InlineData("width: calc(10px * 20px)")] // length * length
+ [InlineData("width: calc(100% / 0)")] // divide by a constant zero
+ [InlineData("width: calc(1px +1px)")] // "+" needs whitespace on both sides
+ [InlineData("width: calc(5)")] // bare number is not a length
+ [InlineData("width: calc()")] // empty
+ [InlineData("width: clamp(1px, 2px)")] // clamp() takes exactly three arguments
+ [InlineData("width: calc(1px + )")] // missing operand
+ public void CalcRejectsInvalidExpressions(string declaration)
+ {
+ var property = ParseDeclaration(declaration);
+
+ Assert.False(property.HasValue);
+ }
+ }
+}
diff --git a/src/ExCSS/Calc/CalcCategory.cs b/src/ExCSS/Calc/CalcCategory.cs
new file mode 100644
index 00000000..5f5e04ce
--- /dev/null
+++ b/src/ExCSS/Calc/CalcCategory.cs
@@ -0,0 +1,21 @@
+using System;
+
+namespace ExCSS
+{
+ ///
+ /// The CSS calc() type-checking category a (sub-)expression resolves to. A flags enum so that
+ /// Length | Percentage can represent the combined <length-percentage> type once a
+ /// length and a percentage have been added or subtracted together.
+ ///
+ [Flags]
+ internal enum CalcCategory
+ {
+ Number = 1,
+ Length = 2,
+ Percentage = 4,
+ LengthPercentage = Length | Percentage,
+ Angle = 8,
+ Time = 16,
+ Resolution = 32
+ }
+}
diff --git a/src/ExCSS/Calc/CalcNode.cs b/src/ExCSS/Calc/CalcNode.cs
new file mode 100644
index 00000000..3407efc8
--- /dev/null
+++ b/src/ExCSS/Calc/CalcNode.cs
@@ -0,0 +1,120 @@
+using System.Collections.Generic;
+
+namespace ExCSS
+{
+ ///
+ /// AST node for a parsed calc()/min()/max()/clamp() expression (CSS Values 4 §10).
+ ///
+ internal abstract class CalcNode
+ {
+ }
+
+ internal sealed class NumberCalcNode : CalcNode
+ {
+ public NumberCalcNode(double value)
+ {
+ Value = value;
+ }
+
+ public double Value { get; }
+ }
+
+ internal sealed class DimensionCalcNode : CalcNode
+ {
+ public DimensionCalcNode(double value, Length.Unit unit)
+ {
+ Value = value;
+ Unit = unit;
+ }
+
+ public double Value { get; }
+ public Length.Unit Unit { get; }
+ }
+
+ internal sealed class PercentageCalcNode : CalcNode
+ {
+ public PercentageCalcNode(double value)
+ {
+ Value = value;
+ }
+
+ public double Value { get; }
+ }
+
+ internal sealed class AngleCalcNode : CalcNode
+ {
+ public AngleCalcNode(double value, Angle.Unit unit)
+ {
+ Value = value;
+ Unit = unit;
+ }
+
+ public double Value { get; }
+ public Angle.Unit Unit { get; }
+ }
+
+ internal sealed class TimeCalcNode : CalcNode
+ {
+ public TimeCalcNode(double value, Time.Unit unit)
+ {
+ Value = value;
+ Unit = unit;
+ }
+
+ public double Value { get; }
+ public Time.Unit Unit { get; }
+ }
+
+ internal sealed class ResolutionCalcNode : CalcNode
+ {
+ public ResolutionCalcNode(double value, Resolution.Unit unit)
+ {
+ Value = value;
+ Unit = unit;
+ }
+
+ public double Value { get; }
+ public Resolution.Unit Unit { get; }
+ }
+
+ /// A leading unary sign directly before a parenthesized group or a nested function call.
+ internal sealed class UnaryCalcNode : CalcNode
+ {
+ public UnaryCalcNode(bool negative, CalcNode operand)
+ {
+ Negative = negative;
+ Operand = operand;
+ }
+
+ public bool Negative { get; }
+ public CalcNode Operand { get; }
+ }
+
+ /// A binary +/-/*// operation.
+ internal sealed class BinaryCalcNode : CalcNode
+ {
+ public BinaryCalcNode(char op, CalcNode left, CalcNode right)
+ {
+ Operator = op;
+ Left = left;
+ Right = right;
+ }
+
+ public char Operator { get; }
+ public CalcNode Left { get; }
+ public CalcNode Right { get; }
+ }
+
+ /// A min(), max(), or clamp() call over its comma-separated arguments.
+ internal sealed class CallCalcNode : CalcNode
+ {
+ public CallCalcNode(string name, IReadOnlyList arguments)
+ {
+ Name = name;
+ Arguments = arguments;
+ }
+
+ public string Name { get; }
+ public IReadOnlyList Arguments { get; }
+ }
+}
diff --git a/src/ExCSS/Calc/CalcParser.cs b/src/ExCSS/Calc/CalcParser.cs
new file mode 100644
index 00000000..7814e4dc
--- /dev/null
+++ b/src/ExCSS/Calc/CalcParser.cs
@@ -0,0 +1,264 @@
+using System.Collections.Generic;
+
+namespace ExCSS
+{
+ ///
+ /// Recursive-descent parser for calc()/min()/max()/clamp() expressions (CSS Values 4 §10). Grammar:
+ /// <calc-sum> := <calc-product> (('+'|'-') <calc-product>)*,
+ /// <calc-product> := <calc-value> (('*'|'/') <calc-value>)*,
+ /// <calc-value> := <number> | <dimension> | <percentage> | '(' <calc-sum> ')' | <nested-call> | <unary-sign> <calc-value>.
+ /// Binary +/- require whitespace on both sides; *// do not. Returns null on
+ /// any grammar violation.
+ ///
+ internal static class CalcParser
+ {
+ public static CalcNode Parse(FunctionToken function)
+ {
+ var name = function.Data;
+
+ if (name.Isi(FunctionNames.Calc))
+ {
+ var tokens = new List(function.ArgumentTokens);
+ var pos = 0;
+ var node = ParseSum(tokens, ref pos);
+ if (node == null) return null;
+
+ SkipWhitespace(tokens, ref pos);
+ return pos == tokens.Count ? node : null;
+ }
+
+ if (name.Isi(FunctionNames.Min) || name.Isi(FunctionNames.Max))
+ {
+ var args = ParseCommaSeparatedSums(function, out var groupCount);
+ return groupCount >= 1 && args != null
+ ? new CallCalcNode(name.Isi(FunctionNames.Min) ? FunctionNames.Min : FunctionNames.Max, args)
+ : null;
+ }
+
+ if (name.Isi(FunctionNames.Clamp))
+ {
+ var args = ParseCommaSeparatedSums(function, out var groupCount);
+ return groupCount == 3 && args != null ? new CallCalcNode(FunctionNames.Clamp, args) : null;
+ }
+
+ return null;
+ }
+
+ public static bool IsCalcFamily(string name)
+ {
+ return name.Isi(FunctionNames.Calc) || name.Isi(FunctionNames.Min) ||
+ name.Isi(FunctionNames.Max) || name.Isi(FunctionNames.Clamp);
+ }
+
+ private static List ParseCommaSeparatedSums(FunctionToken function, out int groupCount)
+ {
+ var groups = function.ArgumentTokens.ToList();
+ groupCount = groups.Count;
+
+ var args = new List(groups.Count);
+
+ foreach (var group in groups)
+ {
+ if (group.Count == 0) return null;
+
+ var pos = 0;
+ var node = ParseSum(group, ref pos);
+ if (node == null) return null;
+
+ SkipWhitespace(group, ref pos);
+ if (pos != group.Count) return null;
+
+ args.Add(node);
+ }
+
+ return args;
+ }
+
+ private static CalcNode ParseSum(List tokens, ref int pos)
+ {
+ var left = ParseProduct(tokens, ref pos);
+ if (left == null) return null;
+
+ while (true)
+ {
+ var beforeWhitespace = pos;
+ SkipWhitespace(tokens, ref pos);
+ var sawWhitespaceBefore = pos != beforeWhitespace;
+
+ if (pos >= tokens.Count || tokens[pos].Type != TokenType.Delim ||
+ (tokens[pos].Data != "+" && tokens[pos].Data != "-"))
+ {
+ pos = beforeWhitespace;
+ break;
+ }
+
+ // Binary +/- must have whitespace on both sides; without it this can't be a valid binary
+ // operator here (an attached sign is already merged into the adjacent numeric token).
+ if (!sawWhitespaceBefore) return null;
+
+ var op = tokens[pos].Data[0];
+ pos++;
+
+ var afterOp = pos;
+ SkipWhitespace(tokens, ref pos);
+ if (pos == afterOp) return null;
+
+ var right = ParseProduct(tokens, ref pos);
+ if (right == null) return null;
+
+ left = new BinaryCalcNode(op, left, right);
+ }
+
+ return left;
+ }
+
+ private static CalcNode ParseProduct(List tokens, ref int pos)
+ {
+ var left = ParseValue(tokens, ref pos);
+ if (left == null) return null;
+
+ while (true)
+ {
+ var save = pos;
+ SkipWhitespace(tokens, ref pos);
+
+ if (pos >= tokens.Count || tokens[pos].Type != TokenType.Delim ||
+ (tokens[pos].Data != "*" && tokens[pos].Data != "/"))
+ {
+ pos = save;
+ break;
+ }
+
+ var op = tokens[pos].Data[0];
+ pos++;
+ SkipWhitespace(tokens, ref pos);
+
+ var right = ParseValue(tokens, ref pos);
+ if (right == null) return null;
+
+ left = new BinaryCalcNode(op, left, right);
+ }
+
+ return left;
+ }
+
+ private static CalcNode ParseValue(List tokens, ref int pos)
+ {
+ SkipWhitespace(tokens, ref pos);
+ if (pos >= tokens.Count) return null;
+
+ var token = tokens[pos];
+
+ switch (token.Type)
+ {
+ case TokenType.Number:
+ pos++;
+ return new NumberCalcNode(((NumberToken) token).Value);
+
+ case TokenType.Percentage:
+ pos++;
+ return new PercentageCalcNode(((UnitToken) token).Value);
+
+ case TokenType.Dimension:
+ return ParseDimension((UnitToken) token, tokens, ref pos);
+
+ case TokenType.RoundBracketOpen:
+ {
+ pos++;
+ var inner = ParseSum(tokens, ref pos);
+ if (inner == null) return null;
+
+ SkipWhitespace(tokens, ref pos);
+ if (pos >= tokens.Count || tokens[pos].Type != TokenType.RoundBracketClose) return null;
+ pos++;
+ return inner;
+ }
+
+ case TokenType.Delim when token.Data == "+" || token.Data == "-":
+ {
+ var negative = token.Data == "-";
+ pos++;
+
+ // A unary sign must be immediately followed by a parenthesized group or a nested
+ // function call (a signed number/dimension is already a single token from the lexer).
+ if (pos >= tokens.Count ||
+ (tokens[pos].Type != TokenType.RoundBracketOpen && tokens[pos].Type != TokenType.Function))
+ {
+ return null;
+ }
+
+ var operand = ParseValue(tokens, ref pos);
+ return operand == null ? null : new UnaryCalcNode(negative, operand);
+ }
+
+ case TokenType.Function:
+ {
+ var nested = (FunctionToken) token;
+ if (!IsCalcFamily(nested.Data)) return null;
+ pos++;
+ return Parse(nested);
+ }
+
+ default:
+ return null;
+ }
+ }
+
+ private static CalcNode ParseDimension(UnitToken token, List tokens, ref int pos)
+ {
+ var lengthUnit = Length.GetUnit(token.Unit);
+ if (IsSupportedLengthUnit(lengthUnit))
+ {
+ pos++;
+ return new DimensionCalcNode(token.Value, lengthUnit);
+ }
+
+ var angleUnit = Angle.GetUnit(token.Unit);
+ if (angleUnit != Angle.Unit.None)
+ {
+ pos++;
+ return new AngleCalcNode(token.Value, angleUnit);
+ }
+
+ var timeUnit = Time.GetUnit(token.Unit);
+ if (timeUnit != Time.Unit.None)
+ {
+ pos++;
+ return new TimeCalcNode(token.Value, timeUnit);
+ }
+
+ var resolutionUnit = Resolution.GetUnit(token.Unit);
+ if (resolutionUnit != Resolution.Unit.None)
+ {
+ pos++;
+ return new ResolutionCalcNode(token.Value, resolutionUnit);
+ }
+
+ return null;
+ }
+
+ private static bool IsSupportedLengthUnit(Length.Unit unit)
+ {
+ switch (unit)
+ {
+ case Length.Unit.Em:
+ case Length.Unit.Rem:
+ case Length.Unit.Ex:
+ case Length.Unit.Px:
+ case Length.Unit.Mm:
+ case Length.Unit.Cm:
+ case Length.Unit.In:
+ case Length.Unit.Pt:
+ case Length.Unit.Pc:
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ private static void SkipWhitespace(List tokens, ref int pos)
+ {
+ while (pos < tokens.Count && tokens[pos].Type == TokenType.Whitespace) pos++;
+ }
+ }
+}
diff --git a/src/ExCSS/Calc/CalcTypeChecker.cs b/src/ExCSS/Calc/CalcTypeChecker.cs
new file mode 100644
index 00000000..84a8320b
--- /dev/null
+++ b/src/ExCSS/Calc/CalcTypeChecker.cs
@@ -0,0 +1,153 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace ExCSS
+{
+ ///
+ /// Implements CSS calc()'s type-checking rules (CSS Values 4 §10.10) over a parsed
+ /// tree, returning the resolved category or null when the expression is
+ /// dimensionally inconsistent (e.g. adding a length to a time, or dividing by something that isn't a
+ /// number). Also folds pure-number subtrees, which is what makes a constant divide-by-zero divisor
+ /// detectable here.
+ ///
+ internal static class CalcTypeChecker
+ {
+ public static CalcCategory? Check(CalcNode node)
+ {
+ switch (node)
+ {
+ case NumberCalcNode _:
+ return CalcCategory.Number;
+
+ case DimensionCalcNode _:
+ return CalcCategory.Length;
+
+ case PercentageCalcNode _:
+ return CalcCategory.Percentage;
+
+ case AngleCalcNode _:
+ return CalcCategory.Angle;
+
+ case TimeCalcNode _:
+ return CalcCategory.Time;
+
+ case ResolutionCalcNode _:
+ return CalcCategory.Resolution;
+
+ case UnaryCalcNode unary:
+ return Check(unary.Operand);
+
+ case BinaryCalcNode binary when binary.Operator == '+' || binary.Operator == '-':
+ return Combine(Check(binary.Left), Check(binary.Right));
+
+ case BinaryCalcNode binary when binary.Operator == '*':
+ {
+ var left = Check(binary.Left);
+ var right = Check(binary.Right);
+ if (left == null || right == null) return null;
+ if (left == CalcCategory.Number) return right;
+ if (right == CalcCategory.Number) return left;
+ return null;
+ }
+
+ case BinaryCalcNode binary when binary.Operator == '/':
+ {
+ var left = Check(binary.Left);
+ var right = Check(binary.Right);
+ if (left == null || right != CalcCategory.Number) return null;
+
+ // A Number-category subtree is built entirely from number leaves, so it always folds -
+ // this is what lets a constant divide-by-zero be rejected here rather than deferred.
+ var divisor = FoldNumber(binary.Right);
+ return divisor == null || divisor.Value == 0d ? (CalcCategory?) null : left;
+ }
+
+ case CallCalcNode call:
+ {
+ CalcCategory? combined = null;
+ foreach (var argument in call.Arguments)
+ {
+ var category = Check(argument);
+ if (category == null) return null;
+ combined = combined == null ? category : Combine(combined, category);
+ if (combined == null) return null;
+ }
+
+ return combined;
+ }
+
+ default:
+ return null;
+ }
+ }
+
+ // Folds a pure-number subtree to a concrete value; null if it isn't purely numeric.
+ public static double? FoldNumber(CalcNode node)
+ {
+ switch (node)
+ {
+ case NumberCalcNode number:
+ return number.Value;
+
+ case UnaryCalcNode unary:
+ {
+ var value = FoldNumber(unary.Operand);
+ return value == null ? (double?) null : unary.Negative ? -value.Value : value.Value;
+ }
+
+ case BinaryCalcNode binary:
+ {
+ var left = FoldNumber(binary.Left);
+ var right = FoldNumber(binary.Right);
+ if (left == null || right == null) return null;
+
+ switch (binary.Operator)
+ {
+ case '+': return left.Value + right.Value;
+ case '-': return left.Value - right.Value;
+ case '*': return left.Value * right.Value;
+ case '/': return right.Value != 0d ? left.Value / right.Value : (double?) null;
+ default: return null;
+ }
+ }
+
+ case CallCalcNode call:
+ {
+ var values = new List(call.Arguments.Count);
+ foreach (var argument in call.Arguments)
+ {
+ var value = FoldNumber(argument);
+ if (value == null) return null;
+ values.Add(value.Value);
+ }
+
+ if (call.Name.Isi(FunctionNames.Min)) return values.Min();
+ if (call.Name.Isi(FunctionNames.Max)) return values.Max();
+ if (call.Name.Isi(FunctionNames.Clamp) && values.Count == 3)
+ {
+ var min = values[0];
+ var val = values[1];
+ var max = values[2];
+ // clamp(min, val, max) == max(min, min(val, max)); if min > max, min wins.
+ var upper = val < max ? val : max;
+ return upper < min ? min : upper;
+ }
+
+ return null;
+ }
+
+ default:
+ // Dimension/percentage leaves are never purely numeric.
+ return null;
+ }
+ }
+
+ private static CalcCategory? Combine(CalcCategory? left, CalcCategory? right)
+ {
+ if (left == null || right == null) return null;
+ if (left == CalcCategory.Number && right == CalcCategory.Number) return CalcCategory.Number;
+ if (left.Value.HasFlag(CalcCategory.Number) || right.Value.HasFlag(CalcCategory.Number)) return null;
+ return left.Value | right.Value;
+ }
+ }
+}
diff --git a/src/ExCSS/Enumerations/FunctionNames.cs b/src/ExCSS/Enumerations/FunctionNames.cs
index 36a5272e..c73bf06f 100644
--- a/src/ExCSS/Enumerations/FunctionNames.cs
+++ b/src/ExCSS/Enumerations/FunctionNames.cs
@@ -20,6 +20,9 @@ public static class FunctionNames
public static readonly string Counter = "counter";
public static readonly string Counters = "counters";
public static readonly string Calc = "calc";
+ public static readonly string Min = "min";
+ public static readonly string Max = "max";
+ public static readonly string Clamp = "clamp";
public static readonly string Toggle = "toggle";
public static readonly string Translate = "translate";
public static readonly string TranslateX = "translateX";
diff --git a/src/ExCSS/Model/Converters.cs b/src/ExCSS/Model/Converters.cs
index 1efadd3d..f97ae5dc 100644
--- a/src/ExCSS/Model/Converters.cs
+++ b/src/ExCSS/Model/Converters.cs
@@ -6,10 +6,12 @@ namespace ExCSS
internal static class Converters
{
public static readonly IValueConverter LineWidthConverter =
- new StructValueConverter(ValueExtensions.ToBorderWidth);
+ new StructValueConverter(ValueExtensions.ToBorderWidth)
+ .Or(new CalcValueConverter(CalcCategory.Length));
public static readonly IValueConverter LengthConverter =
- new StructValueConverter(ValueExtensions.ToLength);
+ new StructValueConverter(ValueExtensions.ToLength)
+ .Or(new CalcValueConverter(CalcCategory.Length));
public static readonly IValueConverter ResolutionConverter =
new StructValueConverter(ValueExtensions.ToResolution);
@@ -44,10 +46,12 @@ public static readonly IValueConverter
BinaryConverter = new StructValueConverter(ValueExtensions.ToBinary);
public static readonly IValueConverter
- AngleConverter = new StructValueConverter(ValueExtensions.ToAngle);
+ AngleConverter = new StructValueConverter(ValueExtensions.ToAngle)
+ .Or(new CalcValueConverter(CalcCategory.Angle));
public static readonly IValueConverter NumberConverter =
- new StructValueConverter(ValueExtensions.ToSingle);
+ new StructValueConverter(ValueExtensions.ToSingle)
+ .Or(new CalcValueConverter(CalcCategory.Number));
public static readonly IValueConverter NaturalNumberConverter =
new StructValueConverter(ValueExtensions.ToNaturalSingle);
@@ -65,7 +69,8 @@ public static readonly IValueConverter
new StructValueConverter(ValueExtensions.ToColor);
public static readonly IValueConverter LengthOrPercentConverter =
- new StructValueConverter(ValueExtensions.ToDistance);
+ new StructValueConverter(ValueExtensions.ToDistance)
+ .Or(new CalcValueConverter(CalcCategory.LengthPercentage));
public static readonly IValueConverter PercentOrFractionConverter =
new StructValueConverter(ValueExtensions.ToPercentOrFraction);
@@ -74,7 +79,8 @@ public static readonly IValueConverter
new StructValueConverter(ValueExtensions.ToPercentOrNumber);
public static readonly IValueConverter AngleNumberConverter =
- new StructValueConverter(ValueExtensions.ToAngleNumber);
+ new StructValueConverter(ValueExtensions.ToAngleNumber)
+ .Or(new CalcValueConverter(CalcCategory.Angle | CalcCategory.Number));
public static readonly IValueConverter SideOrCornerConverter = WithAny(
Assign(Keywords.Left, -1.0).Or(Keywords.Right, 1.0).Option(0.0),
diff --git a/src/ExCSS/Parser/Lexer.cs b/src/ExCSS/Parser/Lexer.cs
index 177fc564..0809c9eb 100644
--- a/src/ExCSS/Parser/Lexer.cs
+++ b/src/ExCSS/Parser/Lexer.cs
@@ -1038,11 +1038,27 @@ private Token NewIdent(string value)
private Token NewFunction(string value)
{
var function = new FunctionToken(value, _position);
+
+ // Track paren depth so a bare parenthesized group nested in the arguments - e.g.
+ // calc((100% - 20px) / 3) - isn't mistaken for the end of the function; only the ')' matching
+ // this function's own '(' terminates it. (Also submitted standalone as the nested-parentheses
+ // lexer fix; if that merges first, this rebases cleanly.)
+ var depth = 1;
var token = Get();
while (token.Type != TokenType.EndOfFile)
{
function.AddArgumentToken(token);
- if (token.Type == TokenType.RoundBracketClose) break;
+
+ if (token.Type == TokenType.RoundBracketOpen)
+ {
+ depth++;
+ }
+ else if (token.Type == TokenType.RoundBracketClose)
+ {
+ depth--;
+ if (depth == 0) break;
+ }
+
token = Get();
}
diff --git a/src/ExCSS/ValueConverters/CalcValueConverter.cs b/src/ExCSS/ValueConverters/CalcValueConverter.cs
new file mode 100644
index 00000000..6362f1b1
--- /dev/null
+++ b/src/ExCSS/ValueConverters/CalcValueConverter.cs
@@ -0,0 +1,52 @@
+using System.Collections.Generic;
+
+namespace ExCSS
+{
+ ///
+ /// Accepts a calc()/min()/max()/clamp() expression whose type-checked category is within
+ /// allowed, and preserves the authored text. Composed onto the existing length/percent/number/
+ /// angle converters via .Or(...) so support cascades to every property built on them.
+ ///
+ internal sealed class CalcValueConverter : IValueConverter
+ {
+ private readonly CalcCategory _allowed;
+
+ public CalcValueConverter(CalcCategory allowed)
+ {
+ _allowed = allowed;
+ }
+
+ public IPropertyValue Convert(IEnumerable value)
+ {
+ if (value.OnlyOrDefault() is not FunctionToken function) return null;
+ if (!CalcParser.IsCalcFamily(function.Data)) return null;
+
+ var node = CalcParser.Parse(function);
+ if (node == null) return null;
+
+ var category = CalcTypeChecker.Check(node);
+ if (category == null || (category.Value & ~_allowed) != 0) return null;
+
+ return new CalcValue(value);
+ }
+
+ public IPropertyValue Construct(Property[] properties)
+ {
+ return properties.Guard();
+ }
+
+ private sealed class CalcValue : IPropertyValue
+ {
+ public CalcValue(IEnumerable tokens)
+ {
+ Original = new TokenValue(tokens);
+ }
+
+ public string CssText => Original.Text;
+
+ public TokenValue Original { get; }
+
+ public TokenValue ExtractFor(string name) => Original;
+ }
+ }
+}