mirror of
https://github.com/radzenhq/radzen-blazor.git
synced 2026-07-08 18:16:08 +00:00
The spreadsheet engine parsed typed input with the invariant culture while displaying values with the current culture, breaking the edit round-trip on non-en-US hosts and making comma-decimal entry (10,50) impossible. Workbook gains a Culture property (defaults to CurrentCulture) which the component stamps from its inherited Culture parameter. It drives: - Cell input parsing (CellData type inference), including day-month date handling for cultures where the group and date separators collide (de-DE) - Edit text and display rendering (GetValue, GetValueAsString, GetDisplayText) - Number format rendering - format codes stay canonical invariant tokens while separators, month names, and AM/PM designators follow the culture - Formula entry and display via the new FormulaLocalizer (Excel FormulaLocal semantics: ';' argument separators and ',' decimals in comma-decimal cultures, lenient comma acceptance where unambiguous, canonical invariant storage) - Dialog input (data validation, conditional format, filter) via shared conversion helpers on SpreadsheetDialogBase XLSX and CSV files read and write canonical invariant values regardless of the workbook culture, including autofit column widths. Malformed formulas now surface as error trees instead of an unhandled lexer exception. Number format parsing is cached (hard-bounded) since CellView reparsed per render. Includes localization demos for the Spreadsheet and Document Processing sections and culture test suites. Note: headless code on non-en-US hosts now parses string values with the host culture; set Workbook.Culture explicitly (or to InvariantCulture) for host-independent processing.
562 lines
16 KiB
C#
562 lines
16 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
|
|
namespace Radzen.Documents.Spreadsheet;
|
|
#nullable enable
|
|
|
|
internal interface IFormulaSyntaxNodeVisitor
|
|
{
|
|
void VisitNumberLiteral(NumberLiteralSyntaxNode numberLiteralSyntaxNode);
|
|
void VisitStringLiteral(StringLiteralSyntaxNode stringLiteralSyntaxNode);
|
|
void VisitBooleanLiteral(BooleanLiteralSyntaxNode booleanLiteralSyntaxNode);
|
|
void VisitErrorLiteral(ErrorLiteralSyntaxNode errorLiteralSyntaxNode);
|
|
void VisitBinaryExpression(BinaryExpressionSyntaxNode binaryExpressionSyntaxNode);
|
|
void VisitUnaryExpression(UnaryExpressionSyntaxNode unaryExpressionSyntaxNode);
|
|
void VisitCell(CellSyntaxNode cellSyntaxNode);
|
|
void VisitFunction(FunctionSyntaxNode functionSyntaxNode);
|
|
void VisitRange(RangeSyntaxNode rangeSyntaxNode);
|
|
}
|
|
|
|
internal class FormulaSyntaxTree(FormulaSyntaxNode root, List<string> errors)
|
|
{
|
|
public FormulaSyntaxNode Root { get; } = root;
|
|
|
|
public List<FormulaSyntaxNode> Find(Func<FormulaSyntaxNode, bool> predicate)
|
|
{
|
|
return Root.Find(predicate);
|
|
}
|
|
|
|
public IReadOnlyList<string> Errors => errors;
|
|
}
|
|
|
|
internal abstract class FormulaSyntaxNode(FormulaToken token)
|
|
{
|
|
public FormulaToken Token { get; } = token;
|
|
|
|
public abstract void Accept(IFormulaSyntaxNodeVisitor visitor);
|
|
|
|
public List<FormulaSyntaxNode> Find(Func<FormulaSyntaxNode, bool> predicate)
|
|
{
|
|
var results = new List<FormulaSyntaxNode>();
|
|
|
|
if (predicate(this))
|
|
{
|
|
results.Add(this);
|
|
}
|
|
|
|
var visitor = new FindVisitor(predicate, results);
|
|
|
|
Accept(visitor);
|
|
|
|
return results;
|
|
}
|
|
}
|
|
|
|
abstract class FormulaSyntaxNodeVisitorBase : IFormulaSyntaxNodeVisitor
|
|
{
|
|
public virtual void VisitNumberLiteral(NumberLiteralSyntaxNode numberLiteralSyntaxNode)
|
|
{
|
|
Visit(numberLiteralSyntaxNode);
|
|
}
|
|
|
|
public virtual void VisitStringLiteral(StringLiteralSyntaxNode stringLiteralSyntaxNode)
|
|
{
|
|
Visit(stringLiteralSyntaxNode);
|
|
}
|
|
|
|
public virtual void VisitBooleanLiteral(BooleanLiteralSyntaxNode booleanLiteralSyntaxNode)
|
|
{
|
|
Visit(booleanLiteralSyntaxNode);
|
|
}
|
|
|
|
public virtual void VisitErrorLiteral(ErrorLiteralSyntaxNode errorLiteralSyntaxNode)
|
|
{
|
|
Visit(errorLiteralSyntaxNode);
|
|
}
|
|
|
|
public virtual void VisitBinaryExpression(BinaryExpressionSyntaxNode binaryExpressionSyntaxNode)
|
|
{
|
|
binaryExpressionSyntaxNode.Left.Accept(this);
|
|
binaryExpressionSyntaxNode.Right.Accept(this);
|
|
|
|
Visit(binaryExpressionSyntaxNode);
|
|
}
|
|
|
|
public virtual void VisitUnaryExpression(UnaryExpressionSyntaxNode unaryExpressionSyntaxNode)
|
|
{
|
|
unaryExpressionSyntaxNode.Operand.Accept(this);
|
|
Visit(unaryExpressionSyntaxNode);
|
|
}
|
|
|
|
public virtual void VisitCell(CellSyntaxNode cellSyntaxNode)
|
|
{
|
|
Visit(cellSyntaxNode);
|
|
}
|
|
|
|
public virtual void VisitFunction(FunctionSyntaxNode functionSyntaxNode)
|
|
{
|
|
foreach (var argument in functionSyntaxNode.Arguments)
|
|
{
|
|
argument.Accept(this);
|
|
}
|
|
|
|
Visit(functionSyntaxNode);
|
|
}
|
|
|
|
public virtual void VisitRange(RangeSyntaxNode rangeSyntaxNode)
|
|
{
|
|
rangeSyntaxNode.Start.Accept(this);
|
|
rangeSyntaxNode.End.Accept(this);
|
|
|
|
Visit(rangeSyntaxNode);
|
|
}
|
|
|
|
protected virtual void Visit(FormulaSyntaxNode node)
|
|
{
|
|
}
|
|
}
|
|
|
|
internal class FindVisitor(Func<FormulaSyntaxNode, bool> predicate, List<FormulaSyntaxNode> results) : FormulaSyntaxNodeVisitorBase
|
|
{
|
|
protected override void Visit(FormulaSyntaxNode node)
|
|
{
|
|
if (predicate(node))
|
|
{
|
|
results.Add(node);
|
|
}
|
|
}
|
|
}
|
|
|
|
internal enum BinaryOperator
|
|
{
|
|
Plus,
|
|
Minus,
|
|
Multiply,
|
|
Divide,
|
|
Equals,
|
|
NotEquals,
|
|
LessThan,
|
|
LessThanOrEqual,
|
|
GreaterThan,
|
|
GreaterThanOrEqual,
|
|
}
|
|
|
|
internal enum UnaryOperator
|
|
{
|
|
Negate,
|
|
Plus,
|
|
}
|
|
|
|
static class FormulaTokenTypeExtensions
|
|
{
|
|
public static BinaryOperator ToBinaryOperator(this FormulaTokenType tokenType)
|
|
{
|
|
return tokenType switch
|
|
{
|
|
FormulaTokenType.Plus => BinaryOperator.Plus,
|
|
FormulaTokenType.Minus => BinaryOperator.Minus,
|
|
FormulaTokenType.Star => BinaryOperator.Multiply,
|
|
FormulaTokenType.Slash => BinaryOperator.Divide,
|
|
FormulaTokenType.Equals => BinaryOperator.Equals,
|
|
FormulaTokenType.EqualsGreaterThan => BinaryOperator.GreaterThanOrEqual,
|
|
FormulaTokenType.LessThanGreaterThan => BinaryOperator.NotEquals,
|
|
FormulaTokenType.LessThan => BinaryOperator.LessThan,
|
|
FormulaTokenType.LessThanOrEqual => BinaryOperator.LessThanOrEqual,
|
|
FormulaTokenType.GreaterThan => BinaryOperator.GreaterThan,
|
|
FormulaTokenType.GreaterThanOrEqual => BinaryOperator.GreaterThanOrEqual,
|
|
_ => throw new ArgumentOutOfRangeException(nameof(tokenType), tokenType, null)
|
|
};
|
|
}
|
|
}
|
|
|
|
internal class BinaryExpressionSyntaxNode(FormulaToken token, FormulaSyntaxNode left, FormulaSyntaxNode right, BinaryOperator @operator) : FormulaSyntaxNode(token)
|
|
{
|
|
public FormulaSyntaxNode Left { get; } = left;
|
|
public BinaryOperator Operator { get; } = @operator;
|
|
public FormulaSyntaxNode Right { get; } = right;
|
|
|
|
public override void Accept(IFormulaSyntaxNodeVisitor visitor)
|
|
{
|
|
visitor.VisitBinaryExpression(this);
|
|
}
|
|
}
|
|
|
|
internal class NumberLiteralSyntaxNode(FormulaToken token) : FormulaSyntaxNode(token)
|
|
{
|
|
public override void Accept(IFormulaSyntaxNodeVisitor visitor)
|
|
{
|
|
visitor.VisitNumberLiteral(this);
|
|
}
|
|
}
|
|
|
|
internal class StringLiteralSyntaxNode(FormulaToken token) : FormulaSyntaxNode(token)
|
|
{
|
|
public override void Accept(IFormulaSyntaxNodeVisitor visitor)
|
|
{
|
|
visitor.VisitStringLiteral(this);
|
|
}
|
|
}
|
|
|
|
internal class BooleanLiteralSyntaxNode(FormulaToken token) : FormulaSyntaxNode(token)
|
|
{
|
|
public override void Accept(IFormulaSyntaxNodeVisitor visitor)
|
|
{
|
|
visitor.VisitBooleanLiteral(this);
|
|
}
|
|
}
|
|
|
|
internal class UnaryExpressionSyntaxNode(FormulaToken token, FormulaSyntaxNode operand, UnaryOperator @operator) : FormulaSyntaxNode(token)
|
|
{
|
|
public FormulaSyntaxNode Operand { get; } = operand;
|
|
public UnaryOperator Operator { get; } = @operator;
|
|
|
|
public override void Accept(IFormulaSyntaxNodeVisitor visitor)
|
|
{
|
|
visitor.VisitUnaryExpression(this);
|
|
}
|
|
}
|
|
|
|
internal class ErrorLiteralSyntaxNode(FormulaToken token) : FormulaSyntaxNode(token)
|
|
{
|
|
public override void Accept(IFormulaSyntaxNodeVisitor visitor)
|
|
{
|
|
visitor.VisitErrorLiteral(this);
|
|
}
|
|
}
|
|
|
|
internal class FunctionSyntaxNode(FormulaToken token, FormulaToken openParenToken, FormulaToken closeParenToken, List<FormulaSyntaxNode> arguments) : FormulaSyntaxNode(token)
|
|
{
|
|
public string Name { get; } = token.Value;
|
|
|
|
public FormulaToken OpenParenToken => openParenToken;
|
|
|
|
public FormulaToken CloseParenToken => closeParenToken;
|
|
|
|
public List<FormulaSyntaxNode> Arguments { get; } = arguments;
|
|
|
|
public override void Accept(IFormulaSyntaxNodeVisitor visitor)
|
|
{
|
|
visitor.VisitFunction(this);
|
|
}
|
|
|
|
public bool IsInside(int position)
|
|
{
|
|
return position > Token.Start && position <= CloseParenToken.Start;
|
|
}
|
|
|
|
public int GetArgumentIndexAtPosition(int position)
|
|
{
|
|
if (position > CloseParenToken.Start)
|
|
{
|
|
return -1;
|
|
}
|
|
|
|
if (position < OpenParenToken.Start)
|
|
{
|
|
return -1;
|
|
}
|
|
|
|
if (Arguments.Count == 0)
|
|
{
|
|
return -1;
|
|
}
|
|
|
|
if (position < Arguments[0].Token.Start)
|
|
{
|
|
return -1;
|
|
}
|
|
|
|
for (int i = Arguments.Count - 1; i >= 0; i--)
|
|
{
|
|
if (position >= Arguments[i].Token.Start)
|
|
{
|
|
return i;
|
|
}
|
|
}
|
|
|
|
return -1;
|
|
}
|
|
}
|
|
|
|
internal class CellSyntaxNode(FormulaToken token) : FormulaSyntaxNode(token)
|
|
{
|
|
public override void Accept(IFormulaSyntaxNodeVisitor visitor)
|
|
{
|
|
visitor.VisitCell(this);
|
|
}
|
|
}
|
|
|
|
internal class RangeSyntaxNode(FormulaToken token, CellSyntaxNode start, CellSyntaxNode end) : FormulaSyntaxNode(token)
|
|
{
|
|
public CellSyntaxNode Start { get; } = start;
|
|
public CellSyntaxNode End { get; } = end;
|
|
|
|
public override void Accept(IFormulaSyntaxNodeVisitor visitor)
|
|
{
|
|
visitor.VisitRange(this);
|
|
}
|
|
}
|
|
|
|
internal class FormulaParser
|
|
{
|
|
private int position;
|
|
private readonly List<FormulaToken> tokens;
|
|
private readonly List<string> errors = [];
|
|
|
|
private FormulaParser(string expression) : this(FormulaLexer.Scan(expression, false))
|
|
{
|
|
}
|
|
|
|
private FormulaParser(List<FormulaToken> tokens)
|
|
{
|
|
this.tokens = tokens;
|
|
}
|
|
|
|
public static FormulaSyntaxTree Parse(List<FormulaToken> tokens)
|
|
{
|
|
var parser = new FormulaParser(tokens);
|
|
return parser.Parse();
|
|
}
|
|
|
|
public static FormulaSyntaxTree Parse(string expression)
|
|
{
|
|
try
|
|
{
|
|
var parser = new FormulaParser(expression);
|
|
return parser.Parse();
|
|
}
|
|
catch (InvalidOperationException ex)
|
|
{
|
|
// The lexer throws on malformed numbers; surface it like parser-collected errors.
|
|
var token = new FormulaToken(FormulaTokenType.ErrorLiteral, CellError.Name.ToString());
|
|
return new FormulaSyntaxTree(new ErrorLiteralSyntaxNode(token), [ex.Message]);
|
|
}
|
|
}
|
|
|
|
private FormulaSyntaxTree Parse()
|
|
{
|
|
Expect(FormulaTokenType.Equals);
|
|
|
|
var node = ParseExpression();
|
|
|
|
Expect(FormulaTokenType.None);
|
|
|
|
return new FormulaSyntaxTree(node, errors);
|
|
}
|
|
|
|
private FormulaSyntaxNode ParseExpression()
|
|
{
|
|
var left = ParseComparison();
|
|
|
|
return left;
|
|
}
|
|
|
|
private FormulaSyntaxNode ParseComparison()
|
|
{
|
|
var left = ParseArithmetic();
|
|
|
|
while (Peek().Type is FormulaTokenType.Equals or FormulaTokenType.EqualsGreaterThan or
|
|
FormulaTokenType.LessThanGreaterThan or
|
|
FormulaTokenType.LessThan or FormulaTokenType.LessThanOrEqual or
|
|
FormulaTokenType.GreaterThan or FormulaTokenType.GreaterThanOrEqual)
|
|
{
|
|
var token = tokens[position];
|
|
Advance(1);
|
|
left = new BinaryExpressionSyntaxNode(token, left, ParseArithmetic(), token.Type.ToBinaryOperator());
|
|
}
|
|
|
|
return left;
|
|
}
|
|
|
|
private FormulaSyntaxNode ParseArithmetic()
|
|
{
|
|
var left = ParseTerm();
|
|
|
|
while (Peek().Type is FormulaTokenType.Plus or FormulaTokenType.Minus)
|
|
{
|
|
var token = tokens[position];
|
|
Advance(1);
|
|
left = new BinaryExpressionSyntaxNode(token, left, ParseTerm(), token.Type.ToBinaryOperator());
|
|
}
|
|
|
|
return left;
|
|
}
|
|
|
|
private FormulaSyntaxNode ParseTerm()
|
|
{
|
|
var left = ParseFactor();
|
|
|
|
while (Peek().Type is FormulaTokenType.Star or FormulaTokenType.Slash)
|
|
{
|
|
var token = Peek();
|
|
Advance(1);
|
|
left = new BinaryExpressionSyntaxNode(token, left, ParseFactor(), token.Type.ToBinaryOperator());
|
|
}
|
|
|
|
return left;
|
|
}
|
|
|
|
private FormulaSyntaxNode ParseFactor()
|
|
{
|
|
var token = Peek();
|
|
|
|
if (token.Type == FormulaTokenType.Minus)
|
|
{
|
|
Advance(1);
|
|
var operand = ParseFactor();
|
|
return new UnaryExpressionSyntaxNode(token, operand, UnaryOperator.Negate);
|
|
}
|
|
|
|
if (token.Type == FormulaTokenType.Plus)
|
|
{
|
|
Advance(1);
|
|
var operand = ParseFactor();
|
|
return new UnaryExpressionSyntaxNode(token, operand, UnaryOperator.Plus);
|
|
}
|
|
|
|
if (token.Type == FormulaTokenType.OpenParen)
|
|
{
|
|
Advance(1);
|
|
var expr = ParseExpression();
|
|
Expect(FormulaTokenType.CloseParen);
|
|
return expr;
|
|
}
|
|
|
|
if (token.Type == FormulaTokenType.Identifier)
|
|
{
|
|
return ParseFunctionCall();
|
|
}
|
|
|
|
if (token.Type == FormulaTokenType.BooleanLiteral)
|
|
{
|
|
Advance(1);
|
|
return new BooleanLiteralSyntaxNode(token);
|
|
}
|
|
|
|
if (token.Type == FormulaTokenType.CellIdentifier)
|
|
{
|
|
Advance(1);
|
|
var start = new CellSyntaxNode(token);
|
|
|
|
if (Peek().Type == FormulaTokenType.Colon)
|
|
{
|
|
Advance(1);
|
|
var endToken = Expect(FormulaTokenType.CellIdentifier);
|
|
// If either side has a sheet, ensure both sides have the same sheet; if only one has sheet, propagate to the other
|
|
var startAddr = start.Token.Address;
|
|
var endAddr = endToken.Address;
|
|
if (startAddr.Worksheet is not null && endAddr.Worksheet is null)
|
|
{
|
|
endToken.Address = new CellRef(endAddr.Row, endAddr.Column)
|
|
{
|
|
IsColumnAbsolute = endAddr.IsColumnAbsolute,
|
|
IsRowAbsolute = endAddr.IsRowAbsolute,
|
|
Worksheet = startAddr.Worksheet
|
|
};
|
|
}
|
|
else if (endAddr.Worksheet is not null && startAddr.Worksheet is null)
|
|
{
|
|
start = new CellSyntaxNode(new FormulaToken(start.Token.Type, start.Token.Value)
|
|
{
|
|
Address = new CellRef(startAddr.Row, startAddr.Column)
|
|
{
|
|
IsColumnAbsolute = startAddr.IsColumnAbsolute,
|
|
IsRowAbsolute = startAddr.IsRowAbsolute,
|
|
Worksheet = endAddr.Worksheet
|
|
}
|
|
});
|
|
}
|
|
return new RangeSyntaxNode(token, start, new CellSyntaxNode(endToken));
|
|
}
|
|
|
|
return start;
|
|
}
|
|
|
|
if (token.Type == FormulaTokenType.StringLiteral)
|
|
{
|
|
Advance(1);
|
|
return new StringLiteralSyntaxNode(token);
|
|
}
|
|
|
|
if (token.Type == FormulaTokenType.ErrorLiteral)
|
|
{
|
|
Advance(1);
|
|
return new ErrorLiteralSyntaxNode(token);
|
|
}
|
|
|
|
return ParseNumberLiteral();
|
|
}
|
|
|
|
private FormulaSyntaxNode ParseFunctionCall()
|
|
{
|
|
var token = Expect(FormulaTokenType.Identifier);
|
|
|
|
// Support dotted function names (STDEV.S, MODE.SNGL, RANK.EQ): the lexer splits them into
|
|
// Identifier '.' Identifier, so re-join into one name token spanning both so the registry resolves them.
|
|
if (Peek().Type == FormulaTokenType.Dot)
|
|
{
|
|
Advance(1);
|
|
var suffix = Expect(FormulaTokenType.Identifier);
|
|
token = new FormulaToken(FormulaTokenType.Identifier, $"{token.Value}.{suffix.Value}")
|
|
{
|
|
Start = token.Start,
|
|
End = suffix.End
|
|
};
|
|
}
|
|
|
|
var openParenToken = Expect(FormulaTokenType.OpenParen);
|
|
|
|
var arguments = new List<FormulaSyntaxNode>();
|
|
|
|
if (Peek().Type != FormulaTokenType.CloseParen)
|
|
{
|
|
arguments.Add(ParseExpression());
|
|
|
|
while (Peek().Type == FormulaTokenType.Comma)
|
|
{
|
|
Advance(1);
|
|
arguments.Add(ParseExpression());
|
|
}
|
|
}
|
|
|
|
var closeParenToken = Expect(FormulaTokenType.CloseParen);
|
|
|
|
return new FunctionSyntaxNode(token, openParenToken, closeParenToken, arguments);
|
|
}
|
|
|
|
private FormulaSyntaxNode ParseNumberLiteral()
|
|
{
|
|
var token = Expect(FormulaTokenType.NumericLiteral);
|
|
|
|
return new NumberLiteralSyntaxNode(token);
|
|
}
|
|
|
|
FormulaToken Expect(FormulaTokenType type)
|
|
{
|
|
var token = Peek();
|
|
|
|
if (token.Type == type)
|
|
{
|
|
Advance(1);
|
|
return token;
|
|
}
|
|
|
|
errors.Add($"Unexpected token: {token.Type}. Expected: {type}");
|
|
return new FormulaToken(type, string.Empty) { Start = token.Start, End = token.End };
|
|
}
|
|
|
|
private void Advance(int count)
|
|
{
|
|
position += count;
|
|
}
|
|
|
|
private FormulaToken Peek(int offset = 0)
|
|
{
|
|
if (position + offset >= tokens.Count)
|
|
{
|
|
return tokens[^1];
|
|
}
|
|
|
|
return tokens[position + offset];
|
|
}
|
|
} |