Files
radzen-blazor/Radzen.Blazor/Documents/Spreadsheet/FormulaLocalizer.cs
Atanas Korchev 609622692e Add culture-aware editing, display, and formula entry to RadzenSpreadsheet
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.
2026-07-08 14:54:55 +03:00

216 lines
6.3 KiB
C#

using System;
using System.Globalization;
using System.Text;
namespace Radzen.Documents.Spreadsheet;
#nullable enable
/// <summary>
/// Converts formulas between the canonical invariant form (stored in <see cref="Cell.Formula"/> and XLSX)
/// and the localized form users type in comma-decimal cultures (";" separators, "," decimals) -
/// the Excel FormulaLocal semantics. A pure text transform; the engine and storage stay invariant.
/// </summary>
internal static class FormulaLocalizer
{
/// <summary>
/// Converts a canonical invariant formula to the localized form for the specified culture.
/// </summary>
internal static string ToLocalized(string formula, CultureInfo culture)
{
if (!TryGetLocalSyntax(formula, culture, out var localDecimal, out var localSeparator))
{
return formula;
}
return Transform(formula, separator: ',', toSeparator: localSeparator, decimalChar: '.', toDecimal: localDecimal, lenientComma: false);
}
/// <summary>
/// Converts a formula typed in localized form back to the canonical invariant form. Lenient:
/// "," is also accepted as an argument separator where it cannot be a decimal (=SUM(A1,A2) works in de-DE).
/// </summary>
internal static string ToInvariant(string formula, CultureInfo culture)
{
if (!TryGetLocalSyntax(formula, culture, out var localDecimal, out var localSeparator))
{
return formula;
}
return Transform(formula, separator: localSeparator, toSeparator: ',', decimalChar: localDecimal, toDecimal: '.', lenientComma: true);
}
private static bool TryGetLocalSyntax(string formula, CultureInfo culture, out char localDecimal, out char localSeparator)
{
localDecimal = '.';
localSeparator = ',';
var dec = culture.NumberFormat.NumberDecimalSeparator;
if (string.IsNullOrEmpty(formula) || dec.Length != 1 || dec[0] == '.')
{
return false;
}
localDecimal = dec[0];
if (localDecimal == ',')
{
localSeparator = ';';
}
else
{
var list = culture.TextInfo.ListSeparator;
localSeparator = list.Length == 1 && list[0] != localDecimal && list[0] != '.' ? list[0] : ';';
}
return true;
}
private static string Transform(string formula, char separator, char toSeparator, char decimalChar, char toDecimal, bool lenientComma)
{
// Allocated on the first converted character; an unchanged formula returns itself.
StringBuilder? result = null;
// The digits of A1 or my.name2 must not form a decimal context, so track how a run started.
var inRun = false;
var runStartsWithIdentifier = false;
for (var i = 0; i < formula.Length; i++)
{
var c = formula[i];
// Array constants are not supported by the engine.
if (c is '{' or '}')
{
return formula;
}
if (c is '"' or '\'')
{
var end = SkipQuoted(formula, i, c);
result?.Append(formula, i, end - i);
i = end - 1;
inRun = false;
continue;
}
if (c == '[')
{
var end = SkipBrackets(formula, i);
result?.Append(formula, i, end - i);
i = end - 1;
inRun = false;
continue;
}
char? converted = null;
if (c == decimalChar && IsDecimalContext(formula, i, inRun, runStartsWithIdentifier))
{
converted = toDecimal;
}
else if (c == separator || (lenientComma && c == ','))
{
converted = toSeparator;
}
if (converted is char replacement && replacement != c)
{
result ??= new StringBuilder(formula.Length).Append(formula, 0, i);
result.Append(replacement);
inRun = false;
continue;
}
if (char.IsAsciiLetter(c) || c is '_' or '$')
{
if (!inRun)
{
inRun = true;
runStartsWithIdentifier = true;
}
}
else if (char.IsAsciiDigit(c))
{
if (!inRun)
{
inRun = true;
runStartsWithIdentifier = false;
}
}
else
{
inRun = false;
}
result?.Append(c);
}
return result?.ToString() ?? formula;
}
// Honors doubled-quote escapes ("" and '').
private static int SkipQuoted(string formula, int start, char quote)
{
for (var i = start + 1; i < formula.Length; i++)
{
if (formula[i] == quote)
{
if (i + 1 < formula.Length && formula[i + 1] == quote)
{
i++;
continue;
}
return i + 1;
}
}
return formula.Length;
}
// Depth-counted: Table[[Col1],[Col2]] must skip to the outer close bracket.
private static int SkipBrackets(string formula, int start)
{
var depth = 0;
for (var i = start; i < formula.Length; i++)
{
if (formula[i] == '[')
{
depth++;
}
else if (formula[i] == ']' && --depth == 0)
{
return i + 1;
}
}
return formula.Length;
}
// Decimal context: digit on the right, and a pure numeric run or formula boundary on the left.
private static bool IsDecimalContext(string formula, int index, bool inRun, bool runStartsWithIdentifier)
{
if (index + 1 >= formula.Length || !char.IsAsciiDigit(formula[index + 1]))
{
return false;
}
if (index == 0)
{
return true;
}
var prev = formula[index - 1];
if (char.IsAsciiDigit(prev))
{
return inRun && !runStartsWithIdentifier;
}
return prev is '=' or '(' or ',' or ';' or '+' or '-' or '*' or '/' or '^' or '<' or '>' or '&' or '%' or ':';
}
}