mirror of
https://github.com/radzenhq/radzen-blazor.git
synced 2026-07-08 18:16:08 +00:00
1121 lines
35 KiB
C#
1121 lines
35 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, Dictionary<string, object>? cache = null)
|
|
=> ApplyWithColor(formatCode, value, type, cache).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]).
|
|
/// When <paramref name="cache"/> is provided, parsed format codes are cached for reuse across multiple calls.
|
|
/// </summary>
|
|
public static (string? Text, string? Color) ApplyWithColor(string? formatCode, object? value, CellDataType type, Dictionary<string, object>? cache = null)
|
|
{
|
|
if (value == 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 = GetOrParseFormatCode(formatCode, cache);
|
|
|
|
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";
|
|
}
|
|
|
|
// Find the decimal point and percent/suffix
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Determines whether a format code represents a date/time format.
|
|
/// </summary>
|
|
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)
|
|
{
|
|
number = 0;
|
|
if (value is double d) { number = d; return true; }
|
|
if (value is int i) { number = i; return true; }
|
|
if (value is float f) { number = f; return true; }
|
|
if (value is decimal dec) { number = (double)dec; return true; }
|
|
if (value is long l) { number = l; return true; }
|
|
if (value is DateTime dt) { number = dt.ToNumber(); return true; }
|
|
if (type == CellDataType.Number || type == CellDataType.Date)
|
|
{
|
|
return double.TryParse(value?.ToString(), NumberStyles.Any, CultureInfo.InvariantCulture, out number);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
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 = new StringBuilder();
|
|
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 sb.ToString();
|
|
}
|
|
|
|
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;
|
|
|
|
// Percentage: multiply by 100
|
|
if (section.IsPercentage)
|
|
{
|
|
absValue *= 100;
|
|
}
|
|
|
|
// Thousands scaling
|
|
for (var i = 0; i < section.ThousandsScale; i++)
|
|
{
|
|
absValue /= 1000;
|
|
}
|
|
|
|
// Count integer and decimal placeholders
|
|
var intZeros = section.IntegerZeros;
|
|
var intHashes = section.IntegerHashes;
|
|
var decimalPlaces = section.DecimalPlaces;
|
|
var hasThousands = section.HasThousandsSeparator;
|
|
|
|
// Round to decimal places
|
|
absValue = Math.Round(absValue, decimalPlaces, MidpointRounding.AwayFromZero);
|
|
|
|
var sb = new StringBuilder();
|
|
|
|
// Prefix literals
|
|
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('-');
|
|
}
|
|
|
|
// Opening paren for negative section with parens
|
|
if (section.HasParens)
|
|
{
|
|
// parens are part of the format literals
|
|
}
|
|
|
|
// Split into integer and decimal parts
|
|
var intPart = (long)Math.Truncate(absValue);
|
|
var fracPart = absValue - Math.Truncate(absValue);
|
|
|
|
// Format integer part
|
|
var intStr = intPart.ToString(CultureInfo.InvariantCulture);
|
|
|
|
// Pad with zeros
|
|
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');
|
|
}
|
|
|
|
// Add thousands separators
|
|
if (hasThousands && intStr.Length > 0)
|
|
{
|
|
var formatted = new StringBuilder();
|
|
var count = 0;
|
|
for (var i = intStr.Length - 1; i >= 0; i--)
|
|
{
|
|
if (count > 0 && count % 3 == 0)
|
|
{
|
|
formatted.Insert(0, ',');
|
|
}
|
|
formatted.Insert(0, intStr[i]);
|
|
count++;
|
|
}
|
|
intStr = formatted.ToString();
|
|
}
|
|
|
|
sb.Append(intStr);
|
|
|
|
// Format decimal part
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Suffix (%, etc.)
|
|
sb.Append(section.Suffix);
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static string FormatScientific(FormatSection section, double value)
|
|
{
|
|
var sb = new StringBuilder();
|
|
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);
|
|
}
|
|
|
|
// Round mantissa to DecimalPlaces
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Format mantissa
|
|
if (section.DecimalPlaces > 0)
|
|
{
|
|
sb.Append(mantissa.ToString("F" + section.DecimalPlaces, CultureInfo.InvariantCulture));
|
|
}
|
|
else
|
|
{
|
|
sb.Append(((long)Math.Round(mantissa)).ToString(CultureInfo.InvariantCulture));
|
|
}
|
|
|
|
// Format exponent part (e.g. "E+00")
|
|
var expFormat = section.ExponentFormat;
|
|
var expChar = expFormat[0]; // E or e
|
|
var expSign = expFormat[1]; // + or -
|
|
var expDigits = expFormat.Length - 2; // number of 0/# after sign
|
|
|
|
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 sb.ToString();
|
|
}
|
|
|
|
#region Parsing
|
|
|
|
private static ParsedFormat GetOrParseFormatCode(string formatCode, Dictionary<string, object>? cache)
|
|
{
|
|
if (cache != null)
|
|
{
|
|
if (cache.TryGetValue(formatCode, out var cached))
|
|
{
|
|
return (ParsedFormat)cached;
|
|
}
|
|
|
|
var result = ParseFormatCode(formatCode);
|
|
cache[formatCode] = result;
|
|
return result;
|
|
}
|
|
|
|
return ParseFormatCode(formatCode);
|
|
}
|
|
|
|
private static ParsedFormat ParseFormatCode(string formatCode)
|
|
{
|
|
var parts = SplitSections(formatCode);
|
|
var sections = new List<FormatSection>();
|
|
|
|
for (var i = 0; i < parts.Count; i++)
|
|
{
|
|
sections.Add(ParseSection(parts[i], i));
|
|
}
|
|
|
|
return new ParsedFormat(sections);
|
|
}
|
|
|
|
private static List<string> SplitSections(string formatCode)
|
|
{
|
|
var sections = new List<string>();
|
|
var current = new StringBuilder();
|
|
var inLiteral = false;
|
|
var escaped = false;
|
|
|
|
foreach (var c in formatCode)
|
|
{
|
|
if (escaped)
|
|
{
|
|
current.Append(c);
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
|
|
if (c == '\\')
|
|
{
|
|
current.Append(c);
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
|
|
if (c == '"')
|
|
{
|
|
current.Append(c);
|
|
inLiteral = !inLiteral;
|
|
continue;
|
|
}
|
|
|
|
if (c == ';' && !inLiteral)
|
|
{
|
|
sections.Add(current.ToString());
|
|
current.Clear();
|
|
continue;
|
|
}
|
|
|
|
current.Append(c);
|
|
}
|
|
|
|
if (current.Length > 0)
|
|
{
|
|
sections.Add(current.ToString());
|
|
}
|
|
|
|
return sections;
|
|
}
|
|
|
|
private static FormatSection ParseSection(string section, int index)
|
|
{
|
|
var tokens = Tokenize(section);
|
|
var isDate = false;
|
|
var isPercentage = false;
|
|
var isScientific = false;
|
|
var exponentFormat = "";
|
|
var hasThousands = false;
|
|
var thousandsScale = 0;
|
|
var integerZeros = 0;
|
|
var integerHashes = 0;
|
|
var decimalPlaces = 0;
|
|
var decimalZeros = 0;
|
|
var decimalSpacePlaceholders = 0;
|
|
var inDecimal = false;
|
|
var is12Hour = false;
|
|
var hasParens = false;
|
|
var hasDigitPlaceholders = false;
|
|
var prefix = new StringBuilder();
|
|
var suffix = new StringBuilder();
|
|
string? sectionColor = null;
|
|
|
|
// Extract color code from tokens
|
|
foreach (var token in tokens)
|
|
{
|
|
if (token.Type == TokenType.ColorCode)
|
|
{
|
|
sectionColor = token.Text;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Check for AM/PM and date tokens
|
|
foreach (var token in tokens)
|
|
{
|
|
if (token.Type == TokenType.AmPm) is12Hour = true;
|
|
if (token.Type is TokenType.Year or TokenType.Month or TokenType.Day
|
|
or TokenType.Hour or TokenType.Minute or TokenType.Second or TokenType.AmPm)
|
|
{
|
|
isDate = true;
|
|
}
|
|
}
|
|
|
|
if (!isDate)
|
|
{
|
|
// Parse number format tokens
|
|
var seenDigit = false;
|
|
var afterDigits = false;
|
|
|
|
foreach (var token in tokens)
|
|
{
|
|
switch (token.Type)
|
|
{
|
|
case TokenType.Zero:
|
|
seenDigit = true;
|
|
hasDigitPlaceholders = true;
|
|
if (inDecimal) { decimalPlaces += token.Text.Length; decimalZeros += token.Text.Length; }
|
|
else integerZeros += token.Text.Length;
|
|
break;
|
|
case TokenType.Hash:
|
|
seenDigit = true;
|
|
hasDigitPlaceholders = true;
|
|
if (inDecimal) decimalPlaces += token.Text.Length;
|
|
else integerHashes += token.Text.Length;
|
|
break;
|
|
case TokenType.QuestionMark:
|
|
seenDigit = true;
|
|
hasDigitPlaceholders = true;
|
|
if (inDecimal) { decimalPlaces += token.Text.Length; decimalSpacePlaceholders += token.Text.Length; }
|
|
else integerHashes += token.Text.Length;
|
|
break;
|
|
case TokenType.Decimal:
|
|
inDecimal = true;
|
|
break;
|
|
case TokenType.Comma:
|
|
if (seenDigit && !inDecimal)
|
|
{
|
|
hasThousands = true;
|
|
}
|
|
break;
|
|
case TokenType.Percent:
|
|
isPercentage = true;
|
|
afterDigits = seenDigit;
|
|
break;
|
|
case TokenType.Exponent:
|
|
isScientific = true;
|
|
exponentFormat = token.Text;
|
|
break;
|
|
case TokenType.Underscore:
|
|
if (!seenDigit) prefix.Append(' ');
|
|
else suffix.Append(' ');
|
|
break;
|
|
case TokenType.Asterisk:
|
|
// Fill-repeat not applicable in HTML — skip
|
|
break;
|
|
case TokenType.Literal:
|
|
if (!seenDigit) prefix.Append(token.Text);
|
|
else suffix.Append(token.Text);
|
|
break;
|
|
case TokenType.OpenParen:
|
|
hasParens = true;
|
|
if (!seenDigit) prefix.Append('(');
|
|
break;
|
|
case TokenType.CloseParen:
|
|
suffix.Append(')');
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Check for trailing commas (scaling)
|
|
var trimmed = section.TrimEnd();
|
|
var trailingCommas = 0;
|
|
for (var i = trimmed.Length - 1; i >= 0; i--)
|
|
{
|
|
if (trimmed[i] == ',') trailingCommas++;
|
|
else break;
|
|
}
|
|
if (trailingCommas > 0 && !hasThousands)
|
|
{
|
|
thousandsScale = trailingCommas;
|
|
}
|
|
}
|
|
|
|
return new FormatSection(
|
|
tokens, index, isDate, isPercentage, hasThousands, thousandsScale,
|
|
integerZeros, integerHashes, decimalPlaces, decimalZeros, is12Hour, hasParens,
|
|
hasDigitPlaceholders, prefix.ToString(), isPercentage ? "%" : suffix.ToString(),
|
|
isScientific, exponentFormat, decimalSpacePlaceholders, sectionColor);
|
|
}
|
|
|
|
private static List<FormatToken> Tokenize(string section)
|
|
{
|
|
var tokens = new List<FormatToken>();
|
|
var i = 0;
|
|
var hasH = false;
|
|
|
|
while (i < section.Length)
|
|
{
|
|
var c = section[i];
|
|
|
|
// Escaped character
|
|
if (c == '\\' && i + 1 < section.Length)
|
|
{
|
|
tokens.Add(new FormatToken(TokenType.Literal, section[i + 1].ToString()));
|
|
i += 2;
|
|
continue;
|
|
}
|
|
|
|
// Quoted literal
|
|
if (c == '"')
|
|
{
|
|
var end = section.IndexOf('"', i + 1);
|
|
if (end > i)
|
|
{
|
|
tokens.Add(new FormatToken(TokenType.Literal, section[(i + 1)..end]));
|
|
i = end + 1;
|
|
}
|
|
else
|
|
{
|
|
i++;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Bracket (color codes, conditions)
|
|
if (c == '[')
|
|
{
|
|
var end = section.IndexOf(']', i + 1);
|
|
if (end > i)
|
|
{
|
|
var bracketContent = section[(i + 1)..end];
|
|
var colorCss = MapColorCode(bracketContent);
|
|
if (colorCss != null)
|
|
{
|
|
tokens.Add(new FormatToken(TokenType.ColorCode, colorCss));
|
|
}
|
|
i = end + 1;
|
|
}
|
|
else
|
|
{
|
|
i++;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// AM/PM
|
|
if (i + 4 < section.Length &&
|
|
section.Substring(i, 5).Equals("AM/PM", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
tokens.Add(new FormatToken(TokenType.AmPm, "AM/PM"));
|
|
i += 5;
|
|
continue;
|
|
}
|
|
|
|
// Date/time tokens
|
|
if (c is 'y' or 'Y')
|
|
{
|
|
var len = ConsumeRun(section, i, c);
|
|
tokens.Add(new FormatToken(TokenType.Year, section.Substring(i, len)));
|
|
i += len;
|
|
continue;
|
|
}
|
|
|
|
if (c is 'd' or 'D')
|
|
{
|
|
var len = ConsumeRun(section, i, c);
|
|
tokens.Add(new FormatToken(TokenType.Day, section.Substring(i, len)));
|
|
i += len;
|
|
continue;
|
|
}
|
|
|
|
if (c is 'h' or 'H')
|
|
{
|
|
hasH = true;
|
|
var len = ConsumeRun(section, i, c);
|
|
tokens.Add(new FormatToken(TokenType.Hour, section.Substring(i, len)));
|
|
i += len;
|
|
continue;
|
|
}
|
|
|
|
if (c is 's' or 'S')
|
|
{
|
|
var len = ConsumeRun(section, i, c);
|
|
tokens.Add(new FormatToken(TokenType.Second, section.Substring(i, len)));
|
|
i += len;
|
|
continue;
|
|
}
|
|
|
|
if (c is 'm' or 'M')
|
|
{
|
|
var len = ConsumeRun(section, i, c);
|
|
var text = section.Substring(i, len);
|
|
|
|
// Determine if this is month or minute
|
|
// m is minute if preceded by h or followed by s
|
|
var isMinute = false;
|
|
if (hasH)
|
|
{
|
|
isMinute = true;
|
|
}
|
|
else
|
|
{
|
|
// Look ahead for s
|
|
for (var j = i + len; j < section.Length; j++)
|
|
{
|
|
var nc = section[j];
|
|
if (nc is ':' or ' ') continue;
|
|
if (nc is 's' or 'S') { isMinute = true; break; }
|
|
break;
|
|
}
|
|
}
|
|
|
|
tokens.Add(new FormatToken(isMinute ? TokenType.Minute : TokenType.Month, text));
|
|
i += len;
|
|
continue;
|
|
}
|
|
|
|
// Number format tokens
|
|
if (c == '0')
|
|
{
|
|
var len = ConsumeRun(section, i, '0');
|
|
tokens.Add(new FormatToken(TokenType.Zero, section.Substring(i, len)));
|
|
i += len;
|
|
continue;
|
|
}
|
|
|
|
if (c == '#')
|
|
{
|
|
var len = ConsumeRun(section, i, '#');
|
|
tokens.Add(new FormatToken(TokenType.Hash, section.Substring(i, len)));
|
|
i += len;
|
|
continue;
|
|
}
|
|
|
|
if (c == '.')
|
|
{
|
|
tokens.Add(new FormatToken(TokenType.Decimal, "."));
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (c == ',')
|
|
{
|
|
tokens.Add(new FormatToken(TokenType.Comma, ","));
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (c == '%')
|
|
{
|
|
tokens.Add(new FormatToken(TokenType.Percent, "%"));
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (c == '(')
|
|
{
|
|
tokens.Add(new FormatToken(TokenType.OpenParen, "("));
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
if (c == ')')
|
|
{
|
|
tokens.Add(new FormatToken(TokenType.CloseParen, ")"));
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
// Separators (: / - space) as literals
|
|
if (c is ':' or '/' or '-' or ' ')
|
|
{
|
|
tokens.Add(new FormatToken(TokenType.Literal, c.ToString()));
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
// Currency and other literal characters
|
|
if (c is '$' or '€' or '£' or '¥')
|
|
{
|
|
tokens.Add(new FormatToken(TokenType.Literal, c.ToString()));
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
// Scientific notation: E+00, E-00, e+00, e-00
|
|
if (c is 'E' or 'e' && i + 1 < section.Length && section[i + 1] is '+' or '-')
|
|
{
|
|
var start = i;
|
|
i += 2; // skip E and sign
|
|
while (i < section.Length && section[i] is '0' or '#')
|
|
{
|
|
i++;
|
|
}
|
|
tokens.Add(new FormatToken(TokenType.Exponent, section[start..i]));
|
|
continue;
|
|
}
|
|
|
|
// Underscore: _X produces a space with the width of X
|
|
if (c == '_' && i + 1 < section.Length)
|
|
{
|
|
tokens.Add(new FormatToken(TokenType.Underscore, section[i + 1].ToString()));
|
|
i += 2;
|
|
continue;
|
|
}
|
|
|
|
// Asterisk: *X repeats character X to fill (ignored in HTML)
|
|
if (c == '*' && i + 1 < section.Length)
|
|
{
|
|
tokens.Add(new FormatToken(TokenType.Asterisk, section[i + 1].ToString()));
|
|
i += 2;
|
|
continue;
|
|
}
|
|
|
|
// Question mark: digit placeholder that pads with spaces
|
|
if (c == '?')
|
|
{
|
|
var len = ConsumeRun(section, i, '?');
|
|
tokens.Add(new FormatToken(TokenType.QuestionMark, section.Substring(i, len)));
|
|
i += len;
|
|
continue;
|
|
}
|
|
|
|
// Skip unknown
|
|
i++;
|
|
}
|
|
|
|
return tokens;
|
|
}
|
|
|
|
private static int ConsumeRun(string s, int start, char c)
|
|
{
|
|
var i = start;
|
|
var lower = char.ToLowerInvariant(c);
|
|
while (i < s.Length && char.ToLowerInvariant(s[i]) == lower) i++;
|
|
return i - start;
|
|
}
|
|
|
|
#endregion
|
|
|
|
#region Types
|
|
|
|
private sealed class ParsedFormat
|
|
{
|
|
public List<FormatSection> Sections { get; }
|
|
public ParsedFormat(List<FormatSection> sections) => Sections = sections;
|
|
}
|
|
|
|
private enum TokenType
|
|
{
|
|
Literal, Zero, Hash, Decimal, Comma, Percent,
|
|
Year, Month, Day, Hour, Minute, Second, AmPm,
|
|
OpenParen, CloseParen,
|
|
Exponent, Underscore, Asterisk, QuestionMark,
|
|
ColorCode
|
|
}
|
|
|
|
private sealed class FormatToken
|
|
{
|
|
public TokenType Type { get; }
|
|
public string Text { get; }
|
|
public FormatToken(TokenType type, string text) { Type = type; Text = text; }
|
|
}
|
|
|
|
private sealed class FormatSection
|
|
{
|
|
public List<FormatToken> Tokens { get; }
|
|
public int Index { get; }
|
|
public bool IsDate { get; }
|
|
public bool IsPercentage { get; }
|
|
public bool HasThousandsSeparator { get; }
|
|
public int ThousandsScale { get; }
|
|
public int IntegerZeros { get; }
|
|
public int IntegerHashes { get; }
|
|
public int DecimalPlaces { get; }
|
|
public int DecimalZeros { get; }
|
|
public bool Is12Hour { get; }
|
|
public bool HasParens { get; }
|
|
public bool HasDigitPlaceholders { get; }
|
|
public string Prefix { get; }
|
|
public string Suffix { get; }
|
|
public bool IsScientific { get; }
|
|
public string ExponentFormat { get; }
|
|
public int DecimalSpacePlaceholders { get; }
|
|
public string? Color { get; }
|
|
|
|
public FormatSection(List<FormatToken> tokens, int index, bool isDate, bool isPercentage,
|
|
bool hasThousandsSeparator, int thousandsScale, int integerZeros, int integerHashes,
|
|
int decimalPlaces, int decimalZeros, bool is12Hour, bool hasParens,
|
|
bool hasDigitPlaceholders, string prefix, string suffix,
|
|
bool isScientific, string exponentFormat, int decimalSpacePlaceholders,
|
|
string? color)
|
|
{
|
|
Tokens = tokens;
|
|
Index = index;
|
|
IsDate = isDate;
|
|
IsPercentage = isPercentage;
|
|
HasThousandsSeparator = hasThousandsSeparator;
|
|
ThousandsScale = thousandsScale;
|
|
IntegerZeros = integerZeros;
|
|
IntegerHashes = integerHashes;
|
|
DecimalPlaces = decimalPlaces;
|
|
DecimalZeros = decimalZeros;
|
|
Is12Hour = is12Hour;
|
|
HasParens = hasParens;
|
|
HasDigitPlaceholders = hasDigitPlaceholders;
|
|
Prefix = prefix;
|
|
Suffix = suffix;
|
|
IsScientific = isScientific;
|
|
ExponentFormat = exponentFormat;
|
|
DecimalSpacePlaceholders = decimalSpacePlaceholders;
|
|
Color = color;
|
|
}
|
|
}
|
|
|
|
private static string? MapColorCode(string code)
|
|
{
|
|
return code.ToUpperInvariant() switch
|
|
{
|
|
"RED" => "red",
|
|
"GREEN" => "green",
|
|
"BLUE" => "blue",
|
|
"YELLOW" => "#CCCC00",
|
|
"CYAN" => "cyan",
|
|
"MAGENTA" => "magenta",
|
|
"WHITE" => "white",
|
|
"BLACK" => "black",
|
|
_ => null
|
|
};
|
|
}
|
|
|
|
#endregion
|
|
}
|