Files
radzen-blazor/Radzen.Blazor/Documents/Spreadsheet/NumberFormat.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

588 lines
18 KiB
C#

using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
namespace Radzen.Documents.Spreadsheet;
#nullable enable
/// <summary>
/// Provides Excel-compatible number format code rendering.
/// </summary>
public static class NumberFormat
{
/// <summary>
/// Applies a format code to a value and returns the formatted string.
/// Returns null when the format is General/null (caller should fall back to default rendering).
/// </summary>
public static string? Apply(string? formatCode, object? value, CellDataType type)
=> ApplyWithColor(formatCode, value, type, CultureInfo.InvariantCulture).Text;
/// <summary>
/// Applies a format code to a value and returns the formatted string, rendering separators,
/// month/day names and AM/PM designators with the specified culture. Format codes themselves
/// are canonical invariant tokens.
/// </summary>
public static string? Apply(string? formatCode, object? value, CellDataType type, CultureInfo culture)
=> ApplyWithColor(formatCode, value, type, culture).Text;
/// <summary>
/// Applies a format code to a value and returns the formatted string and optional color.
/// The color is determined by color codes in the format string (e.g., [Red], [Green]).
/// </summary>
public static (string? Text, string? Color) ApplyWithColor(string? formatCode, object? value, CellDataType type)
=> ApplyWithColor(formatCode, value, type, CultureInfo.InvariantCulture);
/// <summary>
/// Applies a format code to a value and returns the formatted string and optional color,
/// rendering separators, month/day names and AM/PM designators with the specified culture.
/// </summary>
public static (string? Text, string? Color) ApplyWithColor(string? formatCode, object? value, CellDataType type, CultureInfo culture)
{
ArgumentNullException.ThrowIfNull(culture);
if (value is null || string.IsNullOrEmpty(formatCode) ||
string.Equals(formatCode, "General", StringComparison.OrdinalIgnoreCase))
{
return (null, null);
}
if (formatCode == "@")
{
return (value.ToString(), null);
}
if (type == CellDataType.String && value is string s)
{
return (s, null);
}
var parsed = NumberFormatParser.Parse(formatCode);
if (!TryGetNumber(value, type, out var number))
{
return (value.ToString(), null);
}
var section = SelectSection(parsed, number);
string? text;
if (section.IsDate)
{
text = FormatDate(section, value, type, number, culture);
}
else
{
text = FormatNumber(section, number, culture);
}
return (text, section.Color);
}
/// <summary>
/// Adjusts the number of decimal places in a format code.
/// </summary>
public static string AdjustDecimals(string? formatCode, int delta)
{
if (string.IsNullOrEmpty(formatCode) ||
string.Equals(formatCode, "General", StringComparison.OrdinalIgnoreCase))
{
formatCode = "0";
}
var hasPercent = formatCode.Contains('%', StringComparison.Ordinal);
var core = hasPercent ? formatCode.Replace("%", "", StringComparison.Ordinal) : formatCode;
// Split off scientific exponent suffix (E+00, E-00, etc.)
var exponentSuffix = "";
for (var idx = 0; idx < core.Length; idx++)
{
if ((core[idx] is 'E' or 'e') && idx + 1 < core.Length && core[idx + 1] is '+' or '-')
{
exponentSuffix = core[idx..];
core = core[..idx];
break;
}
}
var dotIndex = core.IndexOf('.', StringComparison.Ordinal);
int currentDecimals;
string integerPart;
if (dotIndex >= 0)
{
integerPart = core[..dotIndex];
var decimalPart = core[(dotIndex + 1)..];
currentDecimals = decimalPart.Length;
}
else
{
integerPart = core;
currentDecimals = 0;
}
var newDecimals = Math.Max(0, currentDecimals + delta);
string result;
if (newDecimals == 0)
{
result = integerPart;
}
else
{
result = integerPart + "." + new string('0', newDecimals);
}
result += exponentSuffix;
if (hasPercent)
{
result += "%";
}
return result;
}
internal static bool IsDateFormat(string formatCode)
{
if (string.IsNullOrEmpty(formatCode))
{
return false;
}
var inLiteral = false;
var escaped = false;
var inBracket = false;
for (var i = 0; i < formatCode.Length; i++)
{
var c = formatCode[i];
if (escaped)
{
escaped = false;
continue;
}
if (c == '\\')
{
escaped = true;
continue;
}
if (c == '"')
{
inLiteral = !inLiteral;
continue;
}
if (inLiteral)
{
continue;
}
if (c == '[')
{
inBracket = true;
continue;
}
if (c == ']')
{
inBracket = false;
continue;
}
if (inBracket)
{
continue;
}
switch (c)
{
case 'y' or 'Y':
case 'd' or 'D':
return true;
case 'h' or 'H':
return true;
case 's' or 'S':
return true;
case 'm' or 'M':
// m is date if not preceded by h (which would make it minutes)
// but even minutes make the format a "date/time" format
return true;
}
}
return false;
}
private static bool TryGetNumber(object? value, CellDataType type, out double number)
{
if (value is string)
{
number = 0;
if (type != CellDataType.Number && type != CellDataType.Date)
{
return false;
}
}
else if (NumericCoercion.TryCoerceToDouble(value, out number))
{
return true;
}
return double.TryParse(value?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out number);
}
private static FormatSection SelectSection(ParsedFormat parsed, double value)
{
if (parsed.Sections.Count == 1)
{
return parsed.Sections[0];
}
if (parsed.Sections.Count == 2)
{
return value >= 0 ? parsed.Sections[0] : parsed.Sections[1];
}
if (parsed.Sections.Count >= 3)
{
if (value > 0)
{
return parsed.Sections[0];
}
if (value < 0)
{
return parsed.Sections[1];
}
return parsed.Sections[2];
}
return parsed.Sections[0];
}
private static string FormatDate(FormatSection section, object? value, CellDataType type, double number, CultureInfo culture)
{
DateTime dt;
if (value is DateTime dateTime)
{
dt = dateTime;
}
else
{
dt = number.ToDate();
// Preserve fractional day as time
var fractional = number - Math.Truncate(number);
if (fractional > 0)
{
dt = dt.Date.AddDays(fractional);
}
}
var sb = StringBuilderCache.Acquire();
var tokens = section.Tokens;
for (var i = 0; i < tokens.Count; i++)
{
var token = tokens[i];
switch (token.Type)
{
case TokenType.Literal:
sb.Append(token.Text);
break;
case TokenType.Year:
sb.Append(token.Text.Length switch
{
1 or 2 => dt.ToString("yy", CultureInfo.InvariantCulture),
_ => dt.ToString("yyyy", CultureInfo.InvariantCulture)
});
break;
case TokenType.Month:
sb.Append(token.Text.Length switch
{
1 => dt.Month.ToString(CultureInfo.InvariantCulture),
2 => dt.ToString("MM", CultureInfo.InvariantCulture),
3 => dt.ToString("MMM", culture),
4 => dt.ToString("MMMM", culture),
_ => dt.ToString("MMMM", culture)
});
break;
case TokenType.Day:
sb.Append(token.Text.Length switch
{
1 => dt.Day.ToString(CultureInfo.InvariantCulture),
2 => dt.ToString("dd", CultureInfo.InvariantCulture),
3 => dt.ToString("ddd", culture),
_ => dt.ToString("dddd", culture)
});
break;
case TokenType.Hour:
if (section.Is12Hour)
{
var h = dt.Hour % 12;
if (h == 0)
{
h = 12;
}
sb.Append(h.ToString(CultureInfo.InvariantCulture));
}
else
{
sb.Append(token.Text.Length >= 2
? dt.ToString("HH", CultureInfo.InvariantCulture)
: dt.Hour.ToString(CultureInfo.InvariantCulture));
}
break;
case TokenType.Minute:
sb.Append(token.Text.Length >= 2
? dt.ToString("mm", CultureInfo.InvariantCulture)
: dt.Minute.ToString(CultureInfo.InvariantCulture));
break;
case TokenType.Second:
sb.Append(token.Text.Length >= 2
? dt.ToString("ss", CultureInfo.InvariantCulture)
: dt.Second.ToString(CultureInfo.InvariantCulture));
break;
case TokenType.AmPm:
var designator = dt.Hour >= 12 ? culture.DateTimeFormat.PMDesignator : culture.DateTimeFormat.AMDesignator;
if (string.IsNullOrEmpty(designator))
{
designator = dt.Hour >= 12 ? "PM" : "AM";
}
sb.Append(designator);
break;
}
}
return StringBuilderCache.GetStringAndRelease(sb);
}
private static string FormatNumber(FormatSection section, double value, CultureInfo culture)
{
// Section with no digit placeholders (e.g. literal "-") returns just prefix+suffix
if (!section.HasDigitPlaceholders)
{
return section.Prefix + section.Suffix;
}
if (section.IsScientific)
{
return FormatScientific(section, value, culture);
}
var absValue = Math.Abs(value);
var isNegative = value < 0;
if (section.IsPercentage)
{
absValue *= 100;
}
if (section.ThousandsScale > 0)
{
absValue /= Math.Pow(1000, section.ThousandsScale);
}
var intZeros = section.IntegerZeros;
var intHashes = section.IntegerHashes;
var decimalPlaces = section.DecimalPlaces;
var hasThousands = section.HasThousandsSeparator;
absValue = Math.Round(absValue, decimalPlaces, MidpointRounding.AwayFromZero);
var sb = StringBuilderCache.Acquire();
sb.Append(section.Prefix);
// Add negative sign if this is a single-section format and value is negative
if (isNegative && section.Index == 0 && !section.HasParens)
{
sb.Append('-');
}
var intPart = (long)Math.Truncate(absValue);
var fracPart = absValue - Math.Truncate(absValue);
var intStr = intPart.ToString(CultureInfo.InvariantCulture);
var minIntDigits = Math.Max(intZeros, 1);
if (intZeros == 0 && intHashes > 0 && intPart == 0)
{
// Hash hides leading zeros - don't show zero integer part
intStr = "";
minIntDigits = 0;
}
else if (intStr.Length < intZeros)
{
intStr = intStr.PadLeft(intZeros, '0');
}
var groupSeparator = culture.NumberFormat.NumberGroupSeparator;
var decimalSeparator = culture.NumberFormat.NumberDecimalSeparator;
if (hasThousands && intStr.Length > 0)
{
var formatted = new StringBuilder(intStr.Length + (intStr.Length - 1) / 3 * groupSeparator.Length);
for (var i = 0; i < intStr.Length; i++)
{
var remaining = intStr.Length - i;
if (i > 0 && remaining % 3 == 0)
{
formatted.Append(groupSeparator);
}
formatted.Append(intStr[i]);
}
intStr = formatted.ToString();
}
sb.Append(intStr);
if (decimalPlaces > 0)
{
sb.Append(decimalSeparator);
var fracStr = Math.Round(fracPart, decimalPlaces, MidpointRounding.AwayFromZero)
.ToString("F" + decimalPlaces, CultureInfo.InvariantCulture);
// Remove "0." prefix
if (fracStr.StartsWith("0.", StringComparison.Ordinal))
{
fracStr = fracStr[2..];
}
else if (fracStr.StartsWith("1.", StringComparison.Ordinal))
{
// Rounding carried over - already handled by absValue rounding
fracStr = new string('0', decimalPlaces);
}
// Strip trailing zeros for hash (#) decimal placeholders
// Keep at least DecimalZeros + DecimalSpacePlaceholders digits
var decimalZeros = section.DecimalZeros;
var spacePlaceholders = section.DecimalSpacePlaceholders;
var keepLen = decimalZeros + spacePlaceholders;
if (keepLen < decimalPlaces)
{
while (fracStr.Length > keepLen && fracStr[^1] == '0')
{
fracStr = fracStr[..^1];
}
}
// Replace trailing zeros with spaces for question mark (?) placeholders
if (spacePlaceholders > 0)
{
var chars = fracStr.ToCharArray();
for (var j = chars.Length - 1; j >= decimalZeros; j--)
{
if (chars[j] == '0')
{
chars[j] = ' ';
}
else
{
break;
}
}
fracStr = new string(chars);
}
// If all decimal digits stripped, remove the decimal separator too
if (fracStr.Length == 0)
{
sb.Length -= decimalSeparator.Length;
}
else
{
sb.Append(fracStr);
}
}
sb.Append(section.Suffix);
return StringBuilderCache.GetStringAndRelease(sb);
}
private static string FormatScientific(FormatSection section, double value, CultureInfo culture)
{
var sb = StringBuilderCache.Acquire();
sb.Append(section.Prefix);
var isNegative = value < 0;
var absValue = Math.Abs(value);
if (isNegative)
{
sb.Append('-');
}
int exponent;
double mantissa;
if (absValue == 0)
{
exponent = 0;
mantissa = 0;
}
else
{
exponent = (int)Math.Floor(Math.Log10(absValue));
var intDigits = Math.Max(section.IntegerZeros, 1);
exponent -= (intDigits - 1);
mantissa = absValue / Math.Pow(10, exponent);
}
mantissa = Math.Round(mantissa, section.DecimalPlaces, MidpointRounding.AwayFromZero);
// Check if rounding carried over (e.g., 9.995 → 10.00)
if (mantissa != 0)
{
var intDigits = Math.Max(section.IntegerZeros, 1);
var mantissaIntPart = (long)Math.Truncate(mantissa);
var mantissaIntDigits = mantissaIntPart == 0 ? 1 : (int)Math.Floor(Math.Log10(mantissaIntPart)) + 1;
if (mantissaIntDigits > intDigits)
{
exponent += (mantissaIntDigits - intDigits);
mantissa /= Math.Pow(10, mantissaIntDigits - intDigits);
mantissa = Math.Round(mantissa, section.DecimalPlaces, MidpointRounding.AwayFromZero);
}
}
if (section.DecimalPlaces > 0)
{
sb.Append(mantissa.ToString("F" + section.DecimalPlaces, culture));
}
else
{
sb.Append(((long)Math.Round(mantissa)).ToString(CultureInfo.InvariantCulture));
}
var expFormat = section.ExponentFormat;
var expChar = expFormat[0];
var expSign = expFormat[1];
var expDigits = expFormat.Length - 2;
sb.Append(expChar);
if (exponent >= 0)
{
if (expSign == '+')
{
sb.Append('+');
}
}
else
{
sb.Append('-');
}
sb.Append(Math.Abs(exponent).ToString(CultureInfo.InvariantCulture).PadLeft(expDigits, '0'));
sb.Append(section.Suffix);
return StringBuilderCache.GetStringAndRelease(sb);
}
}