using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
namespace Radzen.Documents.Spreadsheet;
#nullable enable
///
/// Provides Excel-compatible number format code rendering.
///
public static class NumberFormat
{
///
/// 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).
///
public static string? Apply(string? formatCode, object? value, CellDataType type)
=> ApplyWithColor(formatCode, value, type, CultureInfo.InvariantCulture).Text;
///
/// 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.
///
public static string? Apply(string? formatCode, object? value, CellDataType type, CultureInfo culture)
=> ApplyWithColor(formatCode, value, type, culture).Text;
///
/// 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]).
///
public static (string? Text, string? Color) ApplyWithColor(string? formatCode, object? value, CellDataType type)
=> ApplyWithColor(formatCode, value, type, CultureInfo.InvariantCulture);
///
/// 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.
///
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);
}
///
/// Adjusts the number of decimal places in a format code.
///
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);
}
}