mirror of
https://github.com/radzenhq/radzen-blazor.git
synced 2026-07-08 18:16:08 +00:00
Remove narration comments, stale/incorrect comments, and XML doc comments on private and internal API across the Spreadsheet model, formula functions, editor, and tools. Keep comments documenting non-obvious rationale and XML docs on public API.
563 lines
17 KiB
C#
563 lines
17 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).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)
|
|
{
|
|
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);
|
|
}
|
|
else
|
|
{
|
|
text = FormatNumber(section, number);
|
|
}
|
|
|
|
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)
|
|
{
|
|
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", CultureInfo.InvariantCulture),
|
|
4 => dt.ToString("MMMM", CultureInfo.InvariantCulture),
|
|
_ => dt.ToString("MMMM", CultureInfo.InvariantCulture)
|
|
});
|
|
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", CultureInfo.InvariantCulture),
|
|
_ => dt.ToString("dddd", CultureInfo.InvariantCulture)
|
|
});
|
|
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:
|
|
sb.Append(dt.Hour >= 12 ? "PM" : "AM");
|
|
break;
|
|
}
|
|
}
|
|
|
|
return StringBuilderCache.GetStringAndRelease(sb);
|
|
}
|
|
|
|
private static string FormatNumber(FormatSection section, double value)
|
|
{
|
|
// 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);
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
if (hasThousands && intStr.Length > 0)
|
|
{
|
|
var formatted = new StringBuilder(intStr.Length + (intStr.Length - 1) / 3);
|
|
for (var i = 0; i < intStr.Length; i++)
|
|
{
|
|
var remaining = intStr.Length - i;
|
|
if (i > 0 && remaining % 3 == 0)
|
|
{
|
|
formatted.Append(',');
|
|
}
|
|
formatted.Append(intStr[i]);
|
|
}
|
|
intStr = formatted.ToString();
|
|
}
|
|
|
|
sb.Append(intStr);
|
|
|
|
if (decimalPlaces > 0)
|
|
{
|
|
sb.Append('.');
|
|
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 dot too
|
|
if (fracStr.Length == 0)
|
|
{
|
|
sb.Length--; // remove the '.'
|
|
}
|
|
else
|
|
{
|
|
sb.Append(fracStr);
|
|
}
|
|
}
|
|
|
|
sb.Append(section.Suffix);
|
|
|
|
return StringBuilderCache.GetStringAndRelease(sb);
|
|
}
|
|
|
|
private static string FormatScientific(FormatSection section, double value)
|
|
{
|
|
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, CultureInfo.InvariantCulture));
|
|
}
|
|
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);
|
|
}
|
|
}
|