diff --git a/GFramework.Core.Abstractions/Utility/Numeric/INumericDisplayFormatter.cs b/GFramework.Core.Abstractions/Utility/Numeric/INumericDisplayFormatter.cs new file mode 100644 index 0000000..739c905 --- /dev/null +++ b/GFramework.Core.Abstractions/Utility/Numeric/INumericDisplayFormatter.cs @@ -0,0 +1,16 @@ +namespace GFramework.Core.Abstractions.Utility.Numeric; + +/// +/// 数值显示格式化器接口。 +/// +public interface INumericDisplayFormatter +{ + /// + /// 将数值格式化为展示字符串。 + /// + /// 数值类型。 + /// 待格式化的值。 + /// 格式化选项。 + /// 格式化后的字符串。 + string Format(T value, NumericFormatOptions? options = null); +} \ No newline at end of file diff --git a/GFramework.Core.Abstractions/Utility/Numeric/INumericFormatRule.cs b/GFramework.Core.Abstractions/Utility/Numeric/INumericFormatRule.cs new file mode 100644 index 0000000..71d4cb6 --- /dev/null +++ b/GFramework.Core.Abstractions/Utility/Numeric/INumericFormatRule.cs @@ -0,0 +1,22 @@ +namespace GFramework.Core.Abstractions.Utility.Numeric; + +/// +/// 数值显示规则接口。 +/// +public interface INumericFormatRule +{ + /// + /// 规则名称。 + /// + string Name { get; } + + /// + /// 尝试按当前规则格式化数值。 + /// + /// 数值类型。 + /// 待格式化的值。 + /// 格式化选项。 + /// 输出结果。 + /// 格式化是否成功。 + bool TryFormat(T value, NumericFormatOptions options, out string result); +} \ No newline at end of file diff --git a/GFramework.Core.Abstractions/Utility/Numeric/NumericDisplayStyle.cs b/GFramework.Core.Abstractions/Utility/Numeric/NumericDisplayStyle.cs new file mode 100644 index 0000000..088d305 --- /dev/null +++ b/GFramework.Core.Abstractions/Utility/Numeric/NumericDisplayStyle.cs @@ -0,0 +1,12 @@ +namespace GFramework.Core.Abstractions.Utility.Numeric; + +/// +/// 数值显示风格。 +/// +public enum NumericDisplayStyle +{ + /// + /// 紧凑缩写风格,例如 1.2K / 3.4M。 + /// + Compact = 0 +} \ No newline at end of file diff --git a/GFramework.Core.Abstractions/Utility/Numeric/NumericFormatOptions.cs b/GFramework.Core.Abstractions/Utility/Numeric/NumericFormatOptions.cs new file mode 100644 index 0000000..f27b212 --- /dev/null +++ b/GFramework.Core.Abstractions/Utility/Numeric/NumericFormatOptions.cs @@ -0,0 +1,52 @@ +namespace GFramework.Core.Abstractions.Utility.Numeric; + +/// +/// 数值格式化选项。 +/// +public sealed record NumericFormatOptions +{ + /// + /// 显示风格。 + /// + public NumericDisplayStyle Style { get; init; } = NumericDisplayStyle.Compact; + + /// + /// 最大保留小数位数。 + /// + public int MaxDecimalPlaces { get; init; } = 1; + + /// + /// 最少保留小数位数。 + /// + public int MinDecimalPlaces { get; init; } = 0; + + /// + /// 四舍五入策略。 + /// + public MidpointRounding MidpointRounding { get; init; } = MidpointRounding.AwayFromZero; + + /// + /// 是否裁剪小数末尾的 0。 + /// + public bool TrimTrailingZeros { get; init; } = true; + + /// + /// 小于缩写阈值时是否启用千分位分组。 + /// + public bool UseGroupingBelowThreshold { get; init; } + + /// + /// 进入缩写显示的阈值。 + /// + public decimal CompactThreshold { get; init; } = 1000m; + + /// + /// 格式提供者。 + /// + public IFormatProvider? FormatProvider { get; init; } + + /// + /// 自定义格式规则。 + /// + public INumericFormatRule? Rule { get; init; } +} \ No newline at end of file diff --git a/GFramework.Core.Abstractions/Utility/Numeric/NumericSuffixThreshold.cs b/GFramework.Core.Abstractions/Utility/Numeric/NumericSuffixThreshold.cs new file mode 100644 index 0000000..49132cd --- /dev/null +++ b/GFramework.Core.Abstractions/Utility/Numeric/NumericSuffixThreshold.cs @@ -0,0 +1,8 @@ +namespace GFramework.Core.Abstractions.Utility.Numeric; + +/// +/// 数值缩写阈值定义。 +/// +/// 缩写除数,例如 1000、1000000。 +/// 缩写后缀,例如 K、M。 +public readonly record struct NumericSuffixThreshold(decimal Divisor, string Suffix); \ No newline at end of file diff --git a/GFramework.Core.Tests/Localization/LocalizationIntegrationTests.cs b/GFramework.Core.Tests/Localization/LocalizationIntegrationTests.cs index bfcf15e..5086867 100644 --- a/GFramework.Core.Tests/Localization/LocalizationIntegrationTests.cs +++ b/GFramework.Core.Tests/Localization/LocalizationIntegrationTests.cs @@ -49,7 +49,10 @@ public class LocalizationIntegrationTests { "game.title": "My Game", "ui.message.welcome": "Welcome, {playerName}!", - "status.health": "Health: {current}/{max}" + "status.health": "Health: {current}/{max}", + "status.gold": "Gold: {gold:compact}", + "status.damage": "Damage: {damage:compact:maxDecimals=2}", + "status.invalidCompact": "Gold: {gold:compact:maxDecimals=abc}" } """); @@ -57,7 +60,10 @@ public class LocalizationIntegrationTests { "game.title": "我的游戏", "ui.message.welcome": "欢迎, {playerName}!", - "status.health": "生命值: {current}/{max}" + "status.health": "生命值: {current}/{max}", + "status.gold": "金币: {gold:compact}", + "status.damage": "伤害: {damage:compact:maxDecimals=2}", + "status.invalidCompact": "金币: {gold:compact:maxDecimals=abc}" } """); } @@ -108,6 +114,36 @@ public class LocalizationIntegrationTests Assert.That(health, Is.EqualTo("Health: 80/100")); } + [Test] + public void GetString_WithCompactFormatter_ShouldFormatCorrectly() + { + var gold = _manager!.GetString("common", "status.gold") + .WithVariable("gold", 1_250) + .Format(); + + Assert.That(gold, Is.EqualTo("Gold: 1.3K")); + } + + [Test] + public void GetString_WithCompactFormatterArgs_ShouldApplyOptions() + { + var damage = _manager!.GetString("common", "status.damage") + .WithVariable("damage", 1_234) + .Format(); + + Assert.That(damage, Is.EqualTo("Damage: 1.23K")); + } + + [Test] + public void GetString_WithInvalidCompactFormatterArgs_ShouldFallbackToDefaultFormatting() + { + var gold = _manager!.GetString("common", "status.invalidCompact") + .WithVariable("gold", 1_250) + .Format(); + + Assert.That(gold, Is.EqualTo("Gold: 1250")); + } + [Test] public void LanguageChange_ShouldTriggerCallback() { diff --git a/GFramework.Core.Tests/Utility/NumericDisplayFormatterTests.cs b/GFramework.Core.Tests/Utility/NumericDisplayFormatterTests.cs new file mode 100644 index 0000000..0f6c44e --- /dev/null +++ b/GFramework.Core.Tests/Utility/NumericDisplayFormatterTests.cs @@ -0,0 +1,129 @@ +using System.Globalization; +using GFramework.Core.Abstractions.Utility.Numeric; +using GFramework.Core.Extensions; +using GFramework.Core.Utility.Numeric; + +namespace GFramework.Core.Tests.Utility; + +[TestFixture] +public class NumericDisplayFormatterTests +{ + [Test] + public void FormatCompact_ShouldReturnPlainText_WhenValueIsBelowThreshold() + { + var result = NumericDisplay.FormatCompact(950); + + Assert.That(result, Is.EqualTo("950")); + } + + [Test] + public void FormatCompact_ShouldFormatInt_AsCompactText() + { + var result = NumericDisplay.FormatCompact(1_200); + + Assert.That(result, Is.EqualTo("1.2K")); + } + + [Test] + public void FormatCompact_ShouldFormatLong_AsCompactText() + { + var result = NumericDisplay.FormatCompact(1_000_000L); + + Assert.That(result, Is.EqualTo("1M")); + } + + [Test] + public void FormatCompact_ShouldFormatDecimal_AsCompactText() + { + var result = NumericDisplay.FormatCompact(1_234.56m); + + Assert.That(result, Is.EqualTo("1.2K")); + } + + [Test] + public void FormatCompact_ShouldFormatNegativeValues() + { + var result = NumericDisplay.FormatCompact(-1_250); + + Assert.That(result, Is.EqualTo("-1.3K")); + } + + [Test] + public void FormatCompact_ShouldPromoteRoundedBoundary_ToNextSuffix() + { + var result = NumericDisplay.FormatCompact(999_950); + + Assert.That(result, Is.EqualTo("1M")); + } + + [Test] + public void Format_ShouldRespectFormatProvider() + { + var result = NumericDisplay.Format(1_234.5m, new NumericFormatOptions + { + CompactThreshold = 10_000m, + FormatProvider = CultureInfo.GetCultureInfo("de-DE") + }); + + Assert.That(result, Is.EqualTo("1234,5")); + } + + [Test] + public void Format_ShouldUseGroupingBelowThreshold_WhenEnabled() + { + var result = NumericDisplay.Format(12_345, new NumericFormatOptions + { + CompactThreshold = 1_000_000m, + UseGroupingBelowThreshold = true, + FormatProvider = CultureInfo.InvariantCulture + }); + + Assert.That(result, Is.EqualTo("12,345")); + } + + [Test] + public void Format_ShouldSupportCustomSuffixRule() + { + var rule = new NumericSuffixFormatRule("custom", + [ + new NumericSuffixThreshold(10m, "X"), + new NumericSuffixThreshold(100m, "Y") + ]); + + var result = NumericDisplay.Format(123, new NumericFormatOptions + { + Rule = rule, + CompactThreshold = 10m, + FormatProvider = CultureInfo.InvariantCulture + }); + + Assert.That(result, Is.EqualTo("1.2Y")); + } + + [Test] + public void Format_ShouldHandlePositiveInfinity() + { + var result = NumericDisplay.Format(double.PositiveInfinity, new NumericFormatOptions + { + FormatProvider = CultureInfo.InvariantCulture + }); + + Assert.That(result, Is.EqualTo("Infinity")); + } + + [Test] + public void Format_ObjectOverload_ShouldDispatchToNumericFormatter() + { + var result = NumericDisplay.Format((object)1_234m); + + Assert.That(result, Is.EqualTo("1.2K")); + } + + [Test] + public void ToCompactString_ShouldUseNumericExtension() + { + var result = 15_320.ToCompactString(); + + Assert.That(result, Is.EqualTo("15.3K")); + } +} \ No newline at end of file diff --git a/GFramework.Core/Extensions/NumericDisplayExtensions.cs b/GFramework.Core/Extensions/NumericDisplayExtensions.cs new file mode 100644 index 0000000..d67c7c9 --- /dev/null +++ b/GFramework.Core/Extensions/NumericDisplayExtensions.cs @@ -0,0 +1,30 @@ +using System.Numerics; +using GFramework.Core.Abstractions.Utility.Numeric; +using GFramework.Core.Utility.Numeric; + +namespace GFramework.Core.Extensions; + +/// +/// 数值显示扩展方法。 +/// +public static class NumericDisplayExtensions +{ + /// + /// 按指定选项将数值格式化为展示字符串。 + /// + public static string ToDisplayString(this T value, NumericFormatOptions? options = null) where T : INumber + { + return NumericDisplay.Format(value, options); + } + + /// + /// 使用默认紧凑风格将数值格式化为展示字符串。 + /// + public static string ToCompactString( + this T value, + int maxDecimalPlaces = 1, + IFormatProvider? formatProvider = null) where T : INumber + { + return NumericDisplay.FormatCompact(value, maxDecimalPlaces, formatProvider); + } +} \ No newline at end of file diff --git a/GFramework.Core/Localization/Formatters/CompactNumberLocalizationFormatter.cs b/GFramework.Core/Localization/Formatters/CompactNumberLocalizationFormatter.cs new file mode 100644 index 0000000..7630842 --- /dev/null +++ b/GFramework.Core/Localization/Formatters/CompactNumberLocalizationFormatter.cs @@ -0,0 +1,167 @@ +using GFramework.Core.Abstractions.Localization; +using GFramework.Core.Abstractions.Utility.Numeric; +using GFramework.Core.Utility.Numeric; + +namespace GFramework.Core.Localization.Formatters; + +/// +/// 紧凑数值格式化器。 +/// 格式: {value:compact} 或 {value:compact:maxDecimals=2,trimZeros=false} +/// +public sealed class CompactNumberLocalizationFormatter : ILocalizationFormatter +{ + /// + /// 获取格式化器的名称 + /// + public string Name => "compact"; + + + /// + /// 尝试将指定值按照紧凑数值格式进行格式化 + /// + /// 格式字符串,可包含以下选项: + /// maxDecimals: 最大小数位数 + /// minDecimals: 最小小数位数 + /// trimZeros: 是否去除尾随零 + /// grouping: 是否在阈值以下使用分组 + /// 要格式化的数值对象 + /// 格式提供程序,用于区域性特定的格式设置 + /// 格式化后的字符串结果 + /// 如果格式化成功则返回true;如果格式字符串无效或格式化失败则返回false + public bool TryFormat(string format, object value, IFormatProvider? provider, out string result) + { + result = string.Empty; + + if (!TryParseOptions(format, provider, out var options)) + { + return false; + } + + try + { + result = NumericDisplay.Format(value, options); + return true; + } + catch (ArgumentNullException) + { + return false; + } + catch (ArgumentException) + { + return false; + } + } + + /// + /// 尝试解析格式字符串中的选项参数 + /// + /// 格式字符串,包含以逗号分隔的键值对,如"maxDecimals=2,trimZeros=false" + /// 格式提供程序 + /// 解析成功的选项输出 + /// 如果所有选项都正确解析则返回true;如果有任何语法错误或无效值则返回false + /// + /// 支持的选项包括: + /// - maxDecimals: 最大小数位数,必须是有效整数 + /// - minDecimals: 最小小数位数,必须是有效整数 + /// - trimZeros: 是否去除尾随零,必须是有效布尔值 + /// - grouping: 是否在阈值以下使用分组,必须是有效布尔值 + /// 选项之间用逗号或分号分隔,格式为key=value + /// + private static bool TryParseOptions(string format, IFormatProvider? provider, out NumericFormatOptions options) + { + options = new NumericFormatOptions + { + FormatProvider = provider + }; + + if (string.IsNullOrWhiteSpace(format)) + { + return true; + } + + var maxDecimalPlaces = options.MaxDecimalPlaces; + var minDecimalPlaces = options.MinDecimalPlaces; + var trimTrailingZeros = options.TrimTrailingZeros; + var useGroupingBelowThreshold = options.UseGroupingBelowThreshold; + + foreach (var segment in format.Split([',', ';'], + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (!TryParseSegment(segment, out var key, out var value)) + { + return false; + } + + if (!TryApplyOption( + key, + value, + ref maxDecimalPlaces, + ref minDecimalPlaces, + ref trimTrailingZeros, + ref useGroupingBelowThreshold)) + { + return false; + } + } + + options = options with + { + MaxDecimalPlaces = maxDecimalPlaces, + MinDecimalPlaces = minDecimalPlaces, + TrimTrailingZeros = trimTrailingZeros, + UseGroupingBelowThreshold = useGroupingBelowThreshold + }; + + return true; + } + + /// + /// 尝试解析格式字符串中的单个键值对片段。 + /// + /// 包含键值对的字符串片段,格式应为"key=value" + /// 解析得到的键名 + /// 解析得到的值 + /// 如果片段格式有效且成功解析则返回true;如果格式无效(如缺少分隔符、空键等)则返回false + private static bool TryParseSegment(string segment, out string key, out string value) + { + var separatorIndex = segment.IndexOf('='); + if (separatorIndex <= 0 || separatorIndex == segment.Length - 1) + { + key = string.Empty; + value = string.Empty; + return false; + } + + key = segment[..separatorIndex].Trim(); + value = segment[(separatorIndex + 1)..].Trim(); + return true; + } + + /// + /// 尝试将解析得到的键值对应用到相应的选项变量中。 + /// + /// 选项名称 + /// 选项值的字符串表示 + /// 最大小数位数的引用参数 + /// 最小小数位数的引用参数 + /// 是否去除尾随零的引用参数 + /// 是否在阈值以下使用分组的引用参数 + /// 如果键名有效且值成功解析则返回true;如果键名无效或值解析失败则返回false + private static bool TryApplyOption( + string key, + string value, + ref int maxDecimalPlaces, + ref int minDecimalPlaces, + ref bool trimTrailingZeros, + ref bool useGroupingBelowThreshold) + { + return key switch + { + "maxDecimals" => int.TryParse(value, out maxDecimalPlaces), + "minDecimals" => int.TryParse(value, out minDecimalPlaces), + "trimZeros" => bool.TryParse(value, out trimTrailingZeros), + "grouping" => bool.TryParse(value, out useGroupingBelowThreshold), + _ => false + }; + } +} \ No newline at end of file diff --git a/GFramework.Core/Localization/LocalizationManager.cs b/GFramework.Core/Localization/LocalizationManager.cs index 5ff4d88..9c38cf1 100644 --- a/GFramework.Core/Localization/LocalizationManager.cs +++ b/GFramework.Core/Localization/LocalizationManager.cs @@ -2,6 +2,7 @@ using System.Globalization; using System.IO; using System.Text.Json; using GFramework.Core.Abstractions.Localization; +using GFramework.Core.Localization.Formatters; using GFramework.Core.Systems; namespace GFramework.Core.Localization; @@ -11,11 +12,11 @@ namespace GFramework.Core.Localization; /// public class LocalizationManager : AbstractSystem, ILocalizationManager { + private readonly List _availableLanguages; private readonly LocalizationConfig _config; private readonly Dictionary _formatters; private readonly List> _languageChangeCallbacks; private readonly Dictionary> _tables; - private List _availableLanguages; private CultureInfo _currentCulture; private string _currentLanguage; @@ -32,6 +33,7 @@ public class LocalizationManager : AbstractSystem, ILocalizationManager _currentLanguage = _config.DefaultLanguage; _currentCulture = GetCultureInfo(_currentLanguage); _availableLanguages = new List(); + RegisterBuiltInFormatters(); } /// @@ -178,6 +180,13 @@ public class LocalizationManager : AbstractSystem, ILocalizationManager _languageChangeCallbacks.Clear(); } + private void RegisterBuiltInFormatters() + { + RegisterFormatter("if", new ConditionalFormatter()); + RegisterFormatter("plural", new PluralFormatter()); + RegisterFormatter("compact", new CompactNumberLocalizationFormatter()); + } + /// /// 扫描可用语言 /// diff --git a/GFramework.Core/Utility/Numeric/NumericDisplay.cs b/GFramework.Core/Utility/Numeric/NumericDisplay.cs new file mode 100644 index 0000000..eb6997e --- /dev/null +++ b/GFramework.Core/Utility/Numeric/NumericDisplay.cs @@ -0,0 +1,58 @@ +using System.Numerics; +using GFramework.Core.Abstractions.Utility.Numeric; + +namespace GFramework.Core.Utility.Numeric; + +/// +/// 数值显示静态入口。 +/// +public static class NumericDisplay +{ + private static readonly NumericDisplayFormatter DefaultFormatter = new(); + + /// + /// 将数值格式化为展示字符串。 + /// + public static string Format(T value, NumericFormatOptions? options = null) where T : INumber + { + return DefaultFormatter.Format(value, options); + } + + /// + /// 将运行时数值对象格式化为展示字符串。 + /// + public static string Format(object value, NumericFormatOptions? options = null) + { + return DefaultFormatter.Format(value, options); + } + + /// + /// 使用默认紧凑风格格式化数值。 + /// + public static string FormatCompact( + T value, + int maxDecimalPlaces = 1, + IFormatProvider? formatProvider = null) where T : INumber + { + return Format(value, new NumericFormatOptions + { + MaxDecimalPlaces = maxDecimalPlaces, + FormatProvider = formatProvider + }); + } + + /// + /// 使用默认紧凑风格格式化运行时数值对象。 + /// + public static string FormatCompact( + object value, + int maxDecimalPlaces = 1, + IFormatProvider? formatProvider = null) + { + return Format(value, new NumericFormatOptions + { + MaxDecimalPlaces = maxDecimalPlaces, + FormatProvider = formatProvider + }); + } +} \ No newline at end of file diff --git a/GFramework.Core/Utility/Numeric/NumericDisplayFormatter.cs b/GFramework.Core/Utility/Numeric/NumericDisplayFormatter.cs new file mode 100644 index 0000000..e9afc87 --- /dev/null +++ b/GFramework.Core/Utility/Numeric/NumericDisplayFormatter.cs @@ -0,0 +1,124 @@ +using System.Globalization; +using System.Numerics; +using GFramework.Core.Abstractions.Utility.Numeric; + +namespace GFramework.Core.Utility.Numeric; + +/// +/// 默认数值显示格式化器。 +/// +public sealed class NumericDisplayFormatter : INumericDisplayFormatter +{ + private readonly INumericFormatRule _defaultRule; + + /// + /// 初始化默认数值显示格式化器。 + /// + public NumericDisplayFormatter() + : this(NumericSuffixFormatRule.InternationalCompact) + { + } + + /// + /// 初始化数值显示格式化器。 + /// + /// 默认规则。 + public NumericDisplayFormatter(INumericFormatRule defaultRule) + { + _defaultRule = defaultRule ?? throw new ArgumentNullException(nameof(defaultRule)); + } + + /// + public string Format(T value, NumericFormatOptions? options = null) + { + if (value is null) + { + throw new ArgumentNullException(nameof(value)); + } + + var resolvedOptions = NormalizeOptions(options); + var rule = resolvedOptions.Rule ?? _defaultRule; + + if (rule.TryFormat(value, resolvedOptions, out var result)) + { + return result; + } + + return FormatFallback(value!, resolvedOptions.FormatProvider); + } + + /// + /// 将运行时数值对象格式化为展示字符串。 + /// + /// 待格式化的数值对象。 + /// 格式化选项。 + /// 格式化后的字符串。 + public string Format(object value, NumericFormatOptions? options = null) + { + ArgumentNullException.ThrowIfNull(value); + + return value switch + { + byte byteValue => Format(byteValue, options), + sbyte sbyteValue => Format(sbyteValue, options), + short shortValue => Format(shortValue, options), + ushort ushortValue => Format(ushortValue, options), + int intValue => Format(intValue, options), + uint uintValue => Format(uintValue, options), + long longValue => Format(longValue, options), + ulong ulongValue => Format(ulongValue, options), + nint nativeIntValue => Format(nativeIntValue, options), + nuint nativeUIntValue => Format(nativeUIntValue, options), + float floatValue => Format(floatValue, options), + double doubleValue => Format(doubleValue, options), + decimal decimalValue => Format(decimalValue, options), + BigInteger bigIntegerValue => Format(bigIntegerValue, options), + _ => FormatFallback(value, options?.FormatProvider) + }; + } + + internal static NumericFormatOptions NormalizeOptions(NumericFormatOptions? options) + { + var resolved = options ?? new NumericFormatOptions(); + + if (resolved.MaxDecimalPlaces < 0) + { + throw new ArgumentOutOfRangeException( + nameof(options), + resolved.MaxDecimalPlaces, + "MaxDecimalPlaces 不能小于 0。"); + } + + if (resolved.MinDecimalPlaces < 0) + { + throw new ArgumentOutOfRangeException( + nameof(options), + resolved.MinDecimalPlaces, + "MinDecimalPlaces 不能小于 0。"); + } + + if (resolved.MinDecimalPlaces > resolved.MaxDecimalPlaces) + { + throw new ArgumentException("MinDecimalPlaces 不能大于 MaxDecimalPlaces。", nameof(options)); + } + + if (resolved.CompactThreshold <= 0m) + { + throw new ArgumentOutOfRangeException( + nameof(options), + resolved.CompactThreshold, + "CompactThreshold 必须大于 0。"); + } + + return resolved; + } + + private static string FormatFallback(object value, IFormatProvider? provider) + { + return value switch + { + IFormattable formattable => formattable.ToString(null, provider ?? CultureInfo.CurrentCulture), + _ => value.ToString() ?? string.Empty + }; + } +} \ No newline at end of file diff --git a/GFramework.Core/Utility/Numeric/NumericSuffixFormatRule.cs b/GFramework.Core/Utility/Numeric/NumericSuffixFormatRule.cs new file mode 100644 index 0000000..3e1f706 --- /dev/null +++ b/GFramework.Core/Utility/Numeric/NumericSuffixFormatRule.cs @@ -0,0 +1,346 @@ +using System.Globalization; +using System.Numerics; +using GFramework.Core.Abstractions.Utility.Numeric; + +namespace GFramework.Core.Utility.Numeric; + +/// +/// 基于后缀阈值表的数值缩写规则。 +/// +public sealed class NumericSuffixFormatRule : INumericFormatRule +{ + private readonly NumericSuffixThreshold[] _thresholds; + + /// + /// 初始化后缀缩写规则。 + /// + /// 规则名称。 + /// 阈值表。 + public NumericSuffixFormatRule(string name, IEnumerable thresholds) + { + ArgumentException.ThrowIfNullOrEmpty(name); + ArgumentNullException.ThrowIfNull(thresholds); + + Name = name; + _thresholds = thresholds.OrderBy(entry => entry.Divisor).ToArray(); + + if (_thresholds.Length == 0) + { + throw new ArgumentException("至少需要一个缩写阈值。", nameof(thresholds)); + } + + ValidateThresholds(_thresholds); + } + + /// + /// 默认国际缩写规则。 + /// + public static NumericSuffixFormatRule InternationalCompact { get; } = new( + "compact", + [ + new NumericSuffixThreshold(1_000m, "K"), + new NumericSuffixThreshold(1_000_000m, "M"), + new NumericSuffixThreshold(1_000_000_000m, "B"), + new NumericSuffixThreshold(1_000_000_000_000m, "T") + ]); + + /// + public string Name { get; } + + /// + public bool TryFormat(T value, NumericFormatOptions options, out string result) + { + ArgumentNullException.ThrowIfNull(options); + NumericDisplayFormatter.NormalizeOptions(options); + + if (TryFormatSpecialFloatingPoint(value, options.FormatProvider, out result)) + { + return true; + } + + object? boxedValue = value; + if (boxedValue is null) + { + result = string.Empty; + return false; + } + + return boxedValue switch + { + byte byteValue => TryFormatDecimal(byteValue, options, out result), + sbyte sbyteValue => TryFormatDecimal(sbyteValue, options, out result), + short shortValue => TryFormatDecimal(shortValue, options, out result), + ushort ushortValue => TryFormatDecimal(ushortValue, options, out result), + int intValue => TryFormatDecimal(intValue, options, out result), + uint uintValue => TryFormatDecimal(uintValue, options, out result), + long longValue => TryFormatDecimal(longValue, options, out result), + ulong ulongValue => TryFormatDecimal(ulongValue, options, out result), + nint nativeIntValue => TryFormatDecimal(nativeIntValue, options, out result), + nuint nativeUIntValue => TryFormatDecimal(nativeUIntValue, options, out result), + decimal decimalValue => TryFormatDecimal(decimalValue, options, out result), + float floatValue => TryFormatDouble(floatValue, options, out result), + double doubleValue => TryFormatDouble(doubleValue, options, out result), + BigInteger bigIntegerValue => TryFormatBigInteger(bigIntegerValue, options, out result), + _ => TryFormatConvertible(boxedValue, options, out result) + }; + } + + private static void ValidateThresholds(IReadOnlyList thresholds) + { + decimal? previousDivisor = null; + + foreach (var threshold in thresholds) + { + if (threshold.Divisor <= 0m) + { + throw new ArgumentOutOfRangeException(nameof(thresholds), "阈值除数必须大于 0。"); + } + + if (string.IsNullOrWhiteSpace(threshold.Suffix)) + { + throw new ArgumentException("阈值后缀不能为空。", nameof(thresholds)); + } + + if (previousDivisor.HasValue && threshold.Divisor <= previousDivisor.Value) + { + throw new ArgumentException("阈值除数必须严格递增。", nameof(thresholds)); + } + + previousDivisor = threshold.Divisor; + } + } + + private static bool TryFormatSpecialFloatingPoint( + T value, + IFormatProvider? provider, + out string result) + { + object? boxedValue = value; + if (boxedValue is null) + { + result = string.Empty; + return false; + } + + switch (boxedValue) + { + case float floatValue when float.IsNaN(floatValue) || float.IsInfinity(floatValue): + result = floatValue.ToString(null, provider); + return true; + case double doubleValue when double.IsNaN(doubleValue) || double.IsInfinity(doubleValue): + result = doubleValue.ToString(null, provider); + return true; + default: + result = string.Empty; + return false; + } + } + + private bool TryFormatConvertible(object value, NumericFormatOptions options, out string result) + { + if (value is not IConvertible convertible) + { + result = string.Empty; + return false; + } + + try + { + var decimalValue = convertible.ToDecimal(options.FormatProvider ?? CultureInfo.InvariantCulture); + return TryFormatDecimal(decimalValue, options, out result); + } + catch + { + result = string.Empty; + return false; + } + } + + private bool TryFormatBigInteger(BigInteger value, NumericFormatOptions options, out string result) + { + try + { + return TryFormatDecimal((decimal)value, options, out result); + } + catch (OverflowException) + { + try + { + return TryFormatDouble((double)value, options, out result); + } + catch (OverflowException) + { + result = value.ToString(options.FormatProvider ?? CultureInfo.CurrentCulture); + return true; + } + } + } + + private bool TryFormatDecimal(decimal value, NumericFormatOptions options, out string result) + { + var absoluteValue = Math.Abs(value); + + if (absoluteValue < options.CompactThreshold) + { + result = FormatPlainDecimal(value, options); + return true; + } + + var suffixIndex = FindThresholdIndex(absoluteValue); + if (suffixIndex < 0) + { + result = FormatPlainDecimal(value, options); + return true; + } + + var scaledValue = RoundScaledDecimal(absoluteValue, suffixIndex, options, out suffixIndex); + result = ComposeResult(value < 0m, FormatDecimalCore(scaledValue, options, false), suffixIndex); + return true; + } + + private bool TryFormatDouble(double value, NumericFormatOptions options, out string result) + { + var absoluteValue = Math.Abs(value); + + if (absoluteValue < (double)options.CompactThreshold) + { + result = FormatPlainDouble(value, options); + return true; + } + + var suffixIndex = FindThresholdIndex(absoluteValue); + if (suffixIndex < 0) + { + result = FormatPlainDouble(value, options); + return true; + } + + var scaledValue = RoundScaledDouble(absoluteValue, suffixIndex, options, out suffixIndex); + result = ComposeResult(value < 0d, FormatDoubleCore(scaledValue, options, false), suffixIndex); + return true; + } + + private string ComposeResult(bool negative, string numericPart, int suffixIndex) + { + return $"{(negative ? "-" : string.Empty)}{numericPart}{_thresholds[suffixIndex].Suffix}"; + } + + private int FindThresholdIndex(decimal absoluteValue) + { + for (var i = _thresholds.Length - 1; i >= 0; i--) + { + if (absoluteValue >= _thresholds[i].Divisor) + { + return i; + } + } + + return -1; + } + + private int FindThresholdIndex(double absoluteValue) + { + for (var i = _thresholds.Length - 1; i >= 0; i--) + { + if (absoluteValue >= (double)_thresholds[i].Divisor) + { + return i; + } + } + + return -1; + } + + private decimal RoundScaledDecimal(decimal absoluteValue, int suffixIndex, NumericFormatOptions options, + out int resolvedIndex) + { + resolvedIndex = suffixIndex; + var roundedValue = RoundDecimal(absoluteValue / _thresholds[resolvedIndex].Divisor, options); + + while (resolvedIndex < _thresholds.Length - 1) + { + var promoteThreshold = _thresholds[resolvedIndex + 1].Divisor / _thresholds[resolvedIndex].Divisor; + if (roundedValue < promoteThreshold) + { + break; + } + + resolvedIndex++; + roundedValue = RoundDecimal(absoluteValue / _thresholds[resolvedIndex].Divisor, options); + } + + return roundedValue; + } + + private double RoundScaledDouble(double absoluteValue, int suffixIndex, NumericFormatOptions options, + out int resolvedIndex) + { + resolvedIndex = suffixIndex; + var roundedValue = RoundDouble(absoluteValue / (double)_thresholds[resolvedIndex].Divisor, options); + + while (resolvedIndex < _thresholds.Length - 1) + { + var promoteThreshold = + (double)(_thresholds[resolvedIndex + 1].Divisor / _thresholds[resolvedIndex].Divisor); + if (roundedValue < promoteThreshold) + { + break; + } + + resolvedIndex++; + roundedValue = RoundDouble(absoluteValue / (double)_thresholds[resolvedIndex].Divisor, options); + } + + return roundedValue; + } + + private static decimal RoundDecimal(decimal value, NumericFormatOptions options) + { + return Math.Round(value, options.MaxDecimalPlaces, options.MidpointRounding); + } + + private static double RoundDouble(double value, NumericFormatOptions options) + { + return Math.Round(value, options.MaxDecimalPlaces, options.MidpointRounding); + } + + private static string FormatPlainDecimal(decimal value, NumericFormatOptions options) + { + return FormatDecimalCore(RoundDecimal(value, options), options, options.UseGroupingBelowThreshold); + } + + private static string FormatPlainDouble(double value, NumericFormatOptions options) + { + return FormatDoubleCore(RoundDouble(value, options), options, options.UseGroupingBelowThreshold); + } + + private static string FormatDecimalCore(decimal value, NumericFormatOptions options, bool useGrouping) + { + return value.ToString(BuildFormatString(options, useGrouping), options.FormatProvider); + } + + private static string FormatDoubleCore(double value, NumericFormatOptions options, bool useGrouping) + { + return value.ToString(BuildFormatString(options, useGrouping), options.FormatProvider); + } + + private static string BuildFormatString(NumericFormatOptions options, bool useGrouping) + { + var integerPart = useGrouping ? "#,0" : "0"; + + if (options.MaxDecimalPlaces == 0) + { + return integerPart; + } + + if (!options.TrimTrailingZeros) + { + var fixedDigits = Math.Max(options.MaxDecimalPlaces, options.MinDecimalPlaces); + return $"{integerPart}.{new string('0', fixedDigits)}"; + } + + var requiredDigits = new string('0', options.MinDecimalPlaces); + var optionalDigits = new string('#', options.MaxDecimalPlaces - options.MinDecimalPlaces); + return $"{integerPart}.{requiredDigits}{optionalDigits}"; + } +} \ No newline at end of file