using System;
using System.Globalization;
using System.Text;
namespace Radzen.Documents.Spreadsheet;
#nullable enable
///
/// Converts formulas between the canonical invariant form (stored in 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.
///
internal static class FormulaLocalizer
{
///
/// Converts a canonical invariant formula to the localized form for the specified culture.
///
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);
}
///
/// 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).
///
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 ':';
}
}