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); } }