From b1f9acb9bf5b2a24e0645bfe6e1d0e9763360ed5 Mon Sep 17 00:00:00 2001 From: Wei Date: Mon, 15 Nov 2021 16:21:30 +0800 Subject: [PATCH] Fix bug : remove ExcelNumberFormat nuget to resolve error NU3037 --- MiniExcel.sln | 11 +- README.md | 2 +- README.zh-CN.md | 2 +- README.zh-Hant.md | 2 +- docs/README.md | 1 + docs/README.zh-CN.md | 1 + docs/README.zh-Hant.md | 1 + src/ExcelNumberFormat/AssemblyInfo.cs | 3 + src/ExcelNumberFormat/Color.cs | 7 + src/ExcelNumberFormat/CompatibleConvert.cs | 25 + src/ExcelNumberFormat/Condition.cs | 29 + src/ExcelNumberFormat/DecimalSection.cs | 102 +++ src/ExcelNumberFormat/Evaluator.cs | 110 +++ src/ExcelNumberFormat/ExcelDateTime.cs | 179 ++++ .../ExcelNumberFormat.csproj | 22 + src/ExcelNumberFormat/ExcelNumberFormat.snk | Bin 0 -> 596 bytes src/ExcelNumberFormat/ExponentialSection.cs | 51 ++ src/ExcelNumberFormat/Formatter.cs | 773 ++++++++++++++++++ src/ExcelNumberFormat/FractionSection.cs | 216 +++++ src/ExcelNumberFormat/NumberFormat.cs | 86 ++ src/ExcelNumberFormat/Parser.cs | 398 +++++++++ src/ExcelNumberFormat/Section.cs | 23 + src/ExcelNumberFormat/SectionType.cs | 13 + src/ExcelNumberFormat/Token.cs | 110 +++ src/ExcelNumberFormat/Tokenizer.cs | 121 +++ src/ExcelNumberFormat/icon.png | Bin 0 -> 1422 bytes src/MiniExcel/MiniExcelLibs.csproj | 2 +- 27 files changed, 2284 insertions(+), 6 deletions(-) create mode 100644 src/ExcelNumberFormat/AssemblyInfo.cs create mode 100644 src/ExcelNumberFormat/Color.cs create mode 100644 src/ExcelNumberFormat/CompatibleConvert.cs create mode 100644 src/ExcelNumberFormat/Condition.cs create mode 100644 src/ExcelNumberFormat/DecimalSection.cs create mode 100644 src/ExcelNumberFormat/Evaluator.cs create mode 100644 src/ExcelNumberFormat/ExcelDateTime.cs create mode 100644 src/ExcelNumberFormat/ExcelNumberFormat.csproj create mode 100644 src/ExcelNumberFormat/ExcelNumberFormat.snk create mode 100644 src/ExcelNumberFormat/ExponentialSection.cs create mode 100644 src/ExcelNumberFormat/Formatter.cs create mode 100644 src/ExcelNumberFormat/FractionSection.cs create mode 100644 src/ExcelNumberFormat/NumberFormat.cs create mode 100644 src/ExcelNumberFormat/Parser.cs create mode 100644 src/ExcelNumberFormat/Section.cs create mode 100644 src/ExcelNumberFormat/SectionType.cs create mode 100644 src/ExcelNumberFormat/Token.cs create mode 100644 src/ExcelNumberFormat/Tokenizer.cs create mode 100644 src/ExcelNumberFormat/icon.png diff --git a/MiniExcel.sln b/MiniExcel.sln index e22eb46..deabc6a 100644 --- a/MiniExcel.sln +++ b/MiniExcel.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 -VisualStudioVersion = 16.0.30804.86 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MiniExcelLibs", "src\MiniExcel\MiniExcelLibs.csproj", "{097903C9-1F81-4427-B4C8-530CB59687B8}" EndProject @@ -34,6 +34,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MiniExcel.Benchmarks", "ben EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "benchmarks", "benchmarks", "{77A8A169-168B-457F-AB5F-48F30D6BB33C}" EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ExcelNumberFormat", "src\ExcelNumberFormat\ExcelNumberFormat.csproj", "{7ECB1CFF-955F-4285-8486-756F1CDE2177}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -52,6 +54,10 @@ Global {1888285A-B130-48D5-A628-DC85B623FAB8}.Debug|Any CPU.Build.0 = Debug|Any CPU {1888285A-B130-48D5-A628-DC85B623FAB8}.Release|Any CPU.ActiveCfg = Release|Any CPU {1888285A-B130-48D5-A628-DC85B623FAB8}.Release|Any CPU.Build.0 = Release|Any CPU + {7ECB1CFF-955F-4285-8486-756F1CDE2177}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7ECB1CFF-955F-4285-8486-756F1CDE2177}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7ECB1CFF-955F-4285-8486-756F1CDE2177}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7ECB1CFF-955F-4285-8486-756F1CDE2177}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -60,6 +66,7 @@ Global {097903C9-1F81-4427-B4C8-530CB59687B8} = {CC1E0601-AEC9-42D7-8F6A-3FB3939EED16} {77F2C86B-0F17-4370-AB38-A089F9DF4ED5} = {359A7094-3353-48F2-B3E1-FE9E59698318} {1888285A-B130-48D5-A628-DC85B623FAB8} = {77A8A169-168B-457F-AB5F-48F30D6BB33C} + {7ECB1CFF-955F-4285-8486-756F1CDE2177} = {CC1E0601-AEC9-42D7-8F6A-3FB3939EED16} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {51DF25DA-2DCB-4883-90FE-399DA950D4F2} diff --git a/README.md b/README.md index 95a48a8..c7fa73a 100644 --- a/README.md +++ b/README.md @@ -1387,7 +1387,7 @@ public static DataTable QueryAsDataTableWithoutEmptyRow(Stream stream, bool useH ### Reference -[ExcelDataReader](https://github.com/ExcelDataReader/ExcelDataReader) / [ClosedXML](https://github.com/ClosedXML/ClosedXML) / [Dapper](https://github.com/DapperLib/Dapper) +[ExcelDataReader](https://github.com/ExcelDataReader/ExcelDataReader) / [ClosedXML](https://github.com/ClosedXML/ClosedXML) / [Dapper](https://github.com/DapperLib/Dapper) / [ExcelNumberFormat](https://github.com/andersnm/ExcelNumberFormat) diff --git a/README.zh-CN.md b/README.zh-CN.md index d74bac5..0ad7775 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1387,7 +1387,7 @@ public static DataTable QueryAsDataTableWithoutEmptyRow(Stream stream, bool useH ### 参考 -[ExcelDataReader](https://github.com/ExcelDataReader/ExcelDataReader) / [ClosedXML](https://github.com/ClosedXML/ClosedXML) / [Dapper](https://github.com/DapperLib/Dapper) +[ExcelDataReader](https://github.com/ExcelDataReader/ExcelDataReader) / [ClosedXML](https://github.com/ClosedXML/ClosedXML) / [Dapper](https://github.com/DapperLib/Dapper) / [ExcelNumberFormat](https://github.com/andersnm/ExcelNumberFormat) diff --git a/README.zh-Hant.md b/README.zh-Hant.md index 6724350..86f5caa 100644 --- a/README.zh-Hant.md +++ b/README.zh-Hant.md @@ -1389,7 +1389,7 @@ public static DataTable QueryAsDataTableWithoutEmptyRow(Stream stream, bool useH ### 參考 -[ExcelDataReader](https://github.com/ExcelDataReader/ExcelDataReader) / [ClosedXML](https://github.com/ClosedXML/ClosedXML) / [Dapper](https://github.com/DapperLib/Dapper) +[ExcelDataReader](https://github.com/ExcelDataReader/ExcelDataReader) / [ClosedXML](https://github.com/ClosedXML/ClosedXML) / [Dapper](https://github.com/DapperLib/Dapper) / [ExcelNumberFormat](https://github.com/andersnm/ExcelNumberFormat) diff --git a/docs/README.md b/docs/README.md index ea6277f..d80fb41 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,6 +18,7 @@ ### 0.19.1 - [New] QueryAsDataTable remove empty column keys. #298 +- [Bug] Error NU3037: ExcelNumberFormat 1.1.0 #302 ### 0.19.0 - [New] SaveAs default style with autoFilter mode. #190 diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index f88433f..850279a 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -25,6 +25,7 @@ ### 0.19.1 - [New] QueryAsDataTable 删除空白 Column keys. #298 +- [Bug] Error NU3037: ExcelNumberFormat 1.1.0 #302 ### 0.19.0 - [New] SaveAs 预设样式增加筛选功能. #190 diff --git a/docs/README.zh-Hant.md b/docs/README.zh-Hant.md index 5c6a713..f006227 100644 --- a/docs/README.zh-Hant.md +++ b/docs/README.zh-Hant.md @@ -19,6 +19,7 @@ ### 0.19.1 - [New] QueryAsDataTable 刪除空白 Column keys. #298 +- [Bug] Error NU3037: ExcelNumberFormat 1.1.0 #302 ### 0.19.0 - [New] SaveAs 預設樣式增加篩選功能. #190 diff --git a/src/ExcelNumberFormat/AssemblyInfo.cs b/src/ExcelNumberFormat/AssemblyInfo.cs new file mode 100644 index 0000000..6c88b09 --- /dev/null +++ b/src/ExcelNumberFormat/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System; + +[assembly:CLSCompliant(true)] \ No newline at end of file diff --git a/src/ExcelNumberFormat/Color.cs b/src/ExcelNumberFormat/Color.cs new file mode 100644 index 0000000..5cd8a64 --- /dev/null +++ b/src/ExcelNumberFormat/Color.cs @@ -0,0 +1,7 @@ +namespace ExcelNumberFormat +{ + internal class Color + { + public string Value { get; set; } + } +} diff --git a/src/ExcelNumberFormat/CompatibleConvert.cs b/src/ExcelNumberFormat/CompatibleConvert.cs new file mode 100644 index 0000000..434a421 --- /dev/null +++ b/src/ExcelNumberFormat/CompatibleConvert.cs @@ -0,0 +1,25 @@ +using System; + +namespace ExcelNumberFormat +{ + internal static class CompatibleConvert + { + /// + /// A backward-compatible version of . + /// Starting from .net Core 3.0 the default precision used for formatting floating point number has changed. + /// To always format numbers the same way, no matter what version of runtime is used, we specify the precision explicitly. + /// + public static string ToString(object value, IFormatProvider provider) + { + switch (value) + { + case double d: + return d.ToString("G15", provider); + case float f: + return f.ToString("G7", provider); + default: + return Convert.ToString(value, provider); + } + } + } +} \ No newline at end of file diff --git a/src/ExcelNumberFormat/Condition.cs b/src/ExcelNumberFormat/Condition.cs new file mode 100644 index 0000000..4bd0012 --- /dev/null +++ b/src/ExcelNumberFormat/Condition.cs @@ -0,0 +1,29 @@ +namespace ExcelNumberFormat +{ + internal class Condition + { + public string Operator { get; set; } + public double Value { get; set; } + + public bool Evaluate(double lhs) + { + switch (Operator) + { + case "<": + return lhs < Value; + case "<=": + return lhs <= Value; + case ">": + return lhs > Value; + case ">=": + return lhs >= Value; + case "<>": + return lhs != Value; + case "=": + return lhs == Value; + } + + return false; + } + } +} diff --git a/src/ExcelNumberFormat/DecimalSection.cs b/src/ExcelNumberFormat/DecimalSection.cs new file mode 100644 index 0000000..cf0d05b --- /dev/null +++ b/src/ExcelNumberFormat/DecimalSection.cs @@ -0,0 +1,102 @@ +using System.Collections.Generic; + +namespace ExcelNumberFormat +{ + internal class DecimalSection + { + public bool ThousandSeparator { get; set; } + + public double ThousandDivisor { get; set; } + + public double PercentMultiplier { get; set; } + + public List BeforeDecimal { get; set; } + + public bool DecimalSeparator { get; set; } + + public List AfterDecimal { get; set; } + + public static bool TryParse(List tokens, out DecimalSection format) + { + if (Parser.ParseNumberTokens(tokens, 0, out var beforeDecimal, out var decimalSeparator, out var afterDecimal) == tokens.Count) + { + bool thousandSeparator; + var divisor = GetTrailingCommasDivisor(tokens, out thousandSeparator); + var multiplier = GetPercentMultiplier(tokens); + + format = new DecimalSection() + { + BeforeDecimal = beforeDecimal, + DecimalSeparator = decimalSeparator, + AfterDecimal = afterDecimal, + PercentMultiplier = multiplier, + ThousandDivisor = divisor, + ThousandSeparator = thousandSeparator + }; + + return true; + } + + format = null; + return false; + } + + static double GetPercentMultiplier(List tokens) + { + // If there is a percentage literal in the part list, multiply the result by 100 + foreach (var token in tokens) + { + if (token == "%") + return 100; + } + + return 1; + } + + static double GetTrailingCommasDivisor(List tokens, out bool thousandSeparator) + { + // This parses all comma literals in the part list: + // Each comma after the last digit placeholder divides the result by 1000. + // If there are any other commas, display the result with thousand separators. + bool hasLastPlaceholder = false; + var divisor = 1.0; + + for (var j = 0; j < tokens.Count; j++) + { + var tokenIndex = tokens.Count - 1 - j; + var token = tokens[tokenIndex]; + + if (!hasLastPlaceholder) + { + if (Token.IsPlaceholder(token)) + { + // Each trailing comma multiplies the divisor by 1000 + for (var k = tokenIndex + 1; k < tokens.Count; k++) + { + token = tokens[k]; + if (token == ",") + divisor *= 1000.0; + else + break; + } + + // Continue scanning backwards from the last digit placeholder, + // but now look for a thousand separator comma + hasLastPlaceholder = true; + } + } + else + { + if (token == ",") + { + thousandSeparator = true; + return divisor; + } + } + } + + thousandSeparator = false; + return divisor; + } + } +} \ No newline at end of file diff --git a/src/ExcelNumberFormat/Evaluator.cs b/src/ExcelNumberFormat/Evaluator.cs new file mode 100644 index 0000000..423fcf2 --- /dev/null +++ b/src/ExcelNumberFormat/Evaluator.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace ExcelNumberFormat +{ + internal static class Evaluator + { + public static Section GetSection(List
sections, object value) + { + // Standard format has up to 4 sections: + // Positive;Negative;Zero;Text + switch (value) + { + case string s: + if (sections.Count >= 4) + return sections[3]; + + return null; + + case DateTime dt: + // TODO: Check date conditions need date helpers and Date1904 knowledge + return GetFirstSection(sections, SectionType.Date); + + case TimeSpan ts: + return GetNumericSection(sections, ts.TotalDays); + + case double d: + return GetNumericSection(sections, d); + + case int i: + return GetNumericSection(sections, i); + + case short s: + return GetNumericSection(sections, s); + + default: + return null; + } + } + + public static Section GetFirstSection(List
sections, SectionType type) + { + foreach (var section in sections) + if (section.Type == type) + return section; + return null; + } + + private static Section GetNumericSection(List
sections, double value) + { + // First section applies if + // - Has a condition: + // - There is 1 section, or + // - There are 2 sections, and the value is 0 or positive, or + // - There are >2 sections, and the value is positive + if (sections.Count < 1) + { + return null; + } + + var section0 = sections[0]; + + if (section0.Condition != null) + { + if (section0.Condition.Evaluate(value)) + { + return section0; + } + } + else if (sections.Count == 1 || (sections.Count == 2 && value >= 0) || (sections.Count >= 2 && value > 0)) + { + return section0; + } + + if (sections.Count < 2) + { + return null; + } + + var section1 = sections[1]; + + // First condition didnt match, or was a negative number. Second condition applies if: + // - Has a condition, or + // - Value is negative, or + // - There are two sections, and the first section had a non-matching condition + if (section1.Condition != null) + { + if (section1.Condition.Evaluate(value)) + { + return section1; + } + } + else if (value < 0 || (sections.Count == 2 && section0.Condition != null)) + { + return section1; + } + + // Second condition didnt match, or was positive. The following + // sections cannot have conditions, always fall back to the third + // section (for zero formatting) if specified. + if (sections.Count < 3) + { + return null; + } + + return sections[2]; + } + } +} diff --git a/src/ExcelNumberFormat/ExcelDateTime.cs b/src/ExcelNumberFormat/ExcelDateTime.cs new file mode 100644 index 0000000..a366985 --- /dev/null +++ b/src/ExcelNumberFormat/ExcelDateTime.cs @@ -0,0 +1,179 @@ +using System; +using System.Globalization; + +namespace ExcelNumberFormat +{ + /// + /// Similar to regular .NET DateTime, but also supports 0/1 1900 and 29/2 1900. + /// + internal class ExcelDateTime + { + /// + /// The closest .NET DateTime to the specified excel date. + /// + public DateTime AdjustedDateTime { get; } + + /// + /// Number of days to adjust by in post. + /// + public int AdjustDaysPost { get; } + + /// + /// Constructs a new ExcelDateTime from a numeric value. + /// + public ExcelDateTime(double numericDate, bool isDate1904) + { + if (isDate1904) + { + numericDate += 1462.0; + AdjustedDateTime = new DateTime(DoubleDateToTicks(numericDate), DateTimeKind.Unspecified); + } + else + { + // internal dates before 30/12/1899 should add two days to get the real date + // internal dates on 30/12 19899 should add two days, but subtract a day post to get the real date + // internal dates before 28/2/1900 should add one day to get the real date + // internal dates on 28/2 1900 should use the same date, but add a day post to get the real date + + var internalDateTime = new DateTime(DoubleDateToTicks(numericDate), DateTimeKind.Unspecified); + if (internalDateTime < Excel1900ZeroethMinDate) + { + AdjustDaysPost = 0; + AdjustedDateTime = internalDateTime.AddDays(2); + } + + else if (internalDateTime < Excel1900ZeroethMaxDate) + { + AdjustDaysPost = -1; + AdjustedDateTime = internalDateTime.AddDays(2); + } + + else if (internalDateTime < Excel1900LeapMinDate) + { + AdjustDaysPost = 0; + AdjustedDateTime = internalDateTime.AddDays(1); + } + + else if (internalDateTime < Excel1900LeapMaxDate) + { + AdjustDaysPost = 1; + AdjustedDateTime = internalDateTime; + } + else + { + AdjustDaysPost = 0; + AdjustedDateTime = internalDateTime; + } + } + } + + static DateTime Excel1900LeapMinDate = new DateTime(1900, 2, 28); + static DateTime Excel1900LeapMaxDate = new DateTime(1900, 3, 1); + static DateTime Excel1900ZeroethMinDate = new DateTime(1899, 12, 30); + static DateTime Excel1900ZeroethMaxDate = new DateTime(1899, 12, 31); + + /// + /// Wraps a regular .NET datetime. + /// + /// + public ExcelDateTime(DateTime value) + { + AdjustedDateTime = value; + AdjustDaysPost = 0; + } + + public int Year => AdjustedDateTime.Year; + + public int Month => AdjustedDateTime.Month; + + public int Day => AdjustedDateTime.Day + AdjustDaysPost; + + public int Hour => AdjustedDateTime.Hour; + + public int Minute => AdjustedDateTime.Minute; + + public int Second => AdjustedDateTime.Second; + + public int Millisecond => AdjustedDateTime.Millisecond; + + public DayOfWeek DayOfWeek => AdjustedDateTime.DayOfWeek; + + public string ToString(string numberFormat, CultureInfo culture) + { + return AdjustedDateTime.ToString(numberFormat, culture); + } + + public static bool TryConvert(object value, bool isDate1904, CultureInfo culture, out ExcelDateTime result) + { + if (value is double doubleValue) + { + result = new ExcelDateTime(doubleValue, isDate1904); + return true; + } + if (value is int intValue) + { + result = new ExcelDateTime(intValue, isDate1904); + return true; + } + if (value is short shortValue) + { + result = new ExcelDateTime(shortValue, isDate1904); + return true; + } + else if (value is DateTime dateTimeValue) + { + result = new ExcelDateTime(dateTimeValue); + return true; + } + + result = null; + return false; + } + + // From DateTime class to enable OADate in PCL + // Number of 100ns ticks per time unit + private const long TicksPerMillisecond = 10000; + private const long TicksPerSecond = TicksPerMillisecond * 1000; + private const long TicksPerMinute = TicksPerSecond * 60; + private const long TicksPerHour = TicksPerMinute * 60; + private const long TicksPerDay = TicksPerHour * 24; + + private const int MillisPerSecond = 1000; + private const int MillisPerMinute = MillisPerSecond * 60; + private const int MillisPerHour = MillisPerMinute * 60; + private const int MillisPerDay = MillisPerHour * 24; + + // Number of days in a non-leap year + private const int DaysPerYear = 365; + + // Number of days in 4 years + private const int DaysPer4Years = DaysPerYear * 4 + 1; + + // Number of days in 100 years + private const int DaysPer100Years = DaysPer4Years * 25 - 1; + + // Number of days in 400 years + private const int DaysPer400Years = DaysPer100Years * 4 + 1; + + // Number of days from 1/1/0001 to 12/30/1899 + private const int DaysTo1899 = DaysPer400Years * 4 + DaysPer100Years * 3 - 367; + + private const long DoubleDateOffset = DaysTo1899 * TicksPerDay; + + internal static long DoubleDateToTicks(double value) + { + long millis = (long)(value * MillisPerDay + (value >= 0 ? 0.5 : -0.5)); + + // The interesting thing here is when you have a value like 12.5 it all positive 12 days and 12 hours from 01/01/1899 + // However if you a value of -12.25 it is minus 12 days but still positive 6 hours, almost as though you meant -11.75 all negative + // This line below fixes up the millis in the negative case + if (millis < 0) + { + millis -= millis % MillisPerDay * 2; + } + + millis += DoubleDateOffset / TicksPerMillisecond; + return millis * TicksPerMillisecond; + } + } +} diff --git a/src/ExcelNumberFormat/ExcelNumberFormat.csproj b/src/ExcelNumberFormat/ExcelNumberFormat.csproj new file mode 100644 index 0000000..a508f59 --- /dev/null +++ b/src/ExcelNumberFormat/ExcelNumberFormat.csproj @@ -0,0 +1,22 @@ + + + + net45;netstandard2.0;net5.0 + 1.1.0 + true + true + .NET library to parse ECMA-376 number format strings and format values like Excel and other spreadsheet softwares. + ExcelNumberFormat developers + excel,formatting,numfmt,formatcode + https://github.com/andersnm/ExcelNumberFormat + icon.png + ExcelNumberFormat.snk + true + true + MIT + + + + + + \ No newline at end of file diff --git a/src/ExcelNumberFormat/ExcelNumberFormat.snk b/src/ExcelNumberFormat/ExcelNumberFormat.snk new file mode 100644 index 0000000000000000000000000000000000000000..46188ac82c72695001553e2a6bce5f95a8a602d5 GIT binary patch literal 596 zcmV-a0;~N80ssI2Bme+XQ$aES1ONa50098|7o0?LT+?vBa5lUQWo7(O9bhc0=?zvG zjxIg1*APqe%?*aH;2CQ!``~4mDq_d3z9_=ttZq|uUg31=RZs_@q@y7SGX2%2hP3@e zM~i&pn15+8x9oh=jZuoTE#f3L{StbN3+-|9zmw?H=V`g5ZOWKj4nyO=v>A#s}qNf3^WE(heZX2g`R#)TCbKwE_PduhDQhI`wF|uS+m(X7(Kk5iq;BQjW zM}O18D<3RE3@ycZBFrH%yhFPnj5I56n46Y5QHIh#Y$Y`y!vogJ35SWiA~q4n2o1;5 zn4ptyxC@S!+EV|Uk0z++D~?oez|oi@HeG7PXoB}4B2A`20ZR#Z7^at&;dm8sA%fPw z!Y3SGj+!2suYo595+E_8g%9w?nc2dWX^S_~bWb3zzL@ i=%JVhqSo74xZ))yQ8Yuj|MmEIQE{yI@aW^p^pQB6dnFbC literal 0 HcmV?d00001 diff --git a/src/ExcelNumberFormat/ExponentialSection.cs b/src/ExcelNumberFormat/ExponentialSection.cs new file mode 100644 index 0000000..9fc8215 --- /dev/null +++ b/src/ExcelNumberFormat/ExponentialSection.cs @@ -0,0 +1,51 @@ +using System.Collections.Generic; + +namespace ExcelNumberFormat +{ + internal class ExponentialSection + { + public List BeforeDecimal { get; set; } + + public bool DecimalSeparator { get; set; } + + public List AfterDecimal { get; set; } + + public string ExponentialToken { get; set; } + + public List Power { get; set; } + + public static bool TryParse(List tokens, out ExponentialSection format) + { + format = null; + + string exponentialToken; + + int partCount = Parser.ParseNumberTokens(tokens, 0, out var beforeDecimal, out var decimalSeparator, out var afterDecimal); + + if (partCount == 0) + return false; + + int position = partCount; + if (position < tokens.Count && Token.IsExponent(tokens[position])) + { + exponentialToken = tokens[position]; + position++; + } + else + { + return false; + } + + format = new ExponentialSection() + { + BeforeDecimal = beforeDecimal, + DecimalSeparator = decimalSeparator, + AfterDecimal = afterDecimal, + ExponentialToken = exponentialToken, + Power = tokens.GetRange(position, tokens.Count - position) + }; + + return true; + } + } +} \ No newline at end of file diff --git a/src/ExcelNumberFormat/Formatter.cs b/src/ExcelNumberFormat/Formatter.cs new file mode 100644 index 0000000..78776c3 --- /dev/null +++ b/src/ExcelNumberFormat/Formatter.cs @@ -0,0 +1,773 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Text; + +namespace ExcelNumberFormat +{ + static internal class Formatter + { + static public string Format(object value, string formatString, CultureInfo culture, bool isDate1904) + { + var format = new NumberFormat(formatString); + if (!format.IsValid) + return CompatibleConvert.ToString(value, culture); + + var section = Evaluator.GetSection(format.Sections, value); + if (section == null) + return CompatibleConvert.ToString(value, culture); + + return Format(value, section, culture, isDate1904); + } + + static public string Format(object value, Section node, CultureInfo culture, bool isDate1904) + { + switch (node.Type) + { + case SectionType.Number: + // Hide sign under certain conditions and section index + var number = Convert.ToDouble(value, culture); + if ((node.SectionIndex == 0 && node.Condition != null) || node.SectionIndex == 1) + number = Math.Abs(number); + + return FormatNumber(number, node.Number, culture); + + case SectionType.Date: + if (ExcelDateTime.TryConvert(value, isDate1904, culture, out var excelDateTime)) + { + return FormatDate(excelDateTime, node.GeneralTextDateDurationParts, culture); + } + else + { + throw new FormatException("Unexpected date value"); + } + + case SectionType.Duration: + if (value is TimeSpan ts) + { + return FormatTimeSpan(ts, node.GeneralTextDateDurationParts, culture); + } + else + { + var d = Convert.ToDouble(value); + return FormatTimeSpan(TimeSpan.FromDays(d), node.GeneralTextDateDurationParts, culture); + } + + case SectionType.General: + case SectionType.Text: + return FormatGeneralText(CompatibleConvert.ToString(value, culture), node.GeneralTextDateDurationParts); + + case SectionType.Exponential: + return FormatExponential(Convert.ToDouble(value, culture), node, culture); + + case SectionType.Fraction: + return FormatFraction(Convert.ToDouble(value, culture), node, culture); + + default: + throw new InvalidOperationException("Unknown number format section"); + } + } + + static string FormatGeneralText(string text, List tokens) + { + var result = new StringBuilder(); + for (var i = 0; i < tokens.Count; i++) + { + var token = tokens[i]; + if (Token.IsGeneral(token) || token == "@") + { + result.Append(text); + } + else + { + FormatLiteral(token, result); + } + } + return result.ToString(); + } + + private static string FormatTimeSpan(TimeSpan timeSpan, List tokens, CultureInfo culture) + { + // NOTE/TODO: assumes there is exactly one [hh], [mm] or [ss] using the integer part of TimeSpan.TotalXXX when formatting. + // The timeSpan input is then truncated to the remainder fraction, which is used to format mm and/or ss. + var result = new StringBuilder(); + var containsMilliseconds = false; + for (var i = tokens.Count - 1; i >= 0; i--) + { + if (tokens[i].StartsWith(".0")) + { + containsMilliseconds = true; + break; + } + } + + for (var i = 0; i < tokens.Count; i++) + { + var token = tokens[i]; + + if (token.StartsWith("m", StringComparison.OrdinalIgnoreCase)) + { + var value = timeSpan.Minutes; + var digits = token.Length; + result.Append(value.ToString("D" + digits)); + } + else if (token.StartsWith("s", StringComparison.OrdinalIgnoreCase)) + { + // If format does not include ms, then include ms in seconds and round before printing + var formatMs = containsMilliseconds ? 0 : timeSpan.Milliseconds / 1000D; + var value = (int)Math.Round(timeSpan.Seconds + formatMs, 0, MidpointRounding.AwayFromZero); + var digits = token.Length; + result.Append(value.ToString("D" + digits)); + } + else if (token.StartsWith("[h", StringComparison.OrdinalIgnoreCase)) + { + var value = (int)timeSpan.TotalHours; + var digits = token.Length - 2; + result.Append(value.ToString("D" + digits)); + timeSpan = new TimeSpan(0, 0, Math.Abs(timeSpan.Minutes), Math.Abs(timeSpan.Seconds), Math.Abs(timeSpan.Milliseconds)); + } + else if (token.StartsWith("[m", StringComparison.OrdinalIgnoreCase)) + { + var value = (int)timeSpan.TotalMinutes; + var digits = token.Length - 2; + result.Append(value.ToString("D" + digits)); + timeSpan = new TimeSpan(0, 0, 0, Math.Abs(timeSpan.Seconds), Math.Abs(timeSpan.Milliseconds)); + } + else if (token.StartsWith("[s", StringComparison.OrdinalIgnoreCase)) + { + var value = (int)timeSpan.TotalSeconds; + var digits = token.Length - 2; + result.Append(value.ToString("D" + digits)); + timeSpan = new TimeSpan(0, 0, 0, 0, Math.Abs(timeSpan.Milliseconds)); + } + else if (token.StartsWith(".0")) { + var value = timeSpan.Milliseconds; + var digits = token.Length - 1; + result.Append("." + value.ToString("D" + digits)); + } + else + { + FormatLiteral(token, result); + } + } + + return result.ToString(); + } + + private static string FormatDate(ExcelDateTime date, List tokens, CultureInfo culture) + { + var containsAmPm = ContainsAmPm(tokens); + + var result = new StringBuilder(); + for (var i = 0; i < tokens.Count; i++) + { + var token = tokens[i]; + + if (token.StartsWith("y", StringComparison.OrdinalIgnoreCase)) + { + // year + var digits = token.Length; + if (digits < 2) + digits = 2; + if (digits == 3) + digits = 4; + + var year = date.Year; + if (digits == 2) + year = year % 100; + + result.Append(year.ToString("D" + digits)); + } + else if (token.StartsWith("m", StringComparison.OrdinalIgnoreCase)) + { + // If "m" or "mm" code is used immediately after the "h" or "hh" code (for hours) or immediately before + // the "ss" code (for seconds), the application shall display minutes instead of the month. + if (LookBackDatePart(tokens, i - 1, "h") || LookAheadDatePart(tokens, i + 1, "s")) + { + var digits = token.Length; + result.Append(date.Minute.ToString("D" + digits)); + } + else + { + var digits = token.Length; + if (digits == 3) + { + result.Append(culture.DateTimeFormat.AbbreviatedMonthNames[date.Month - 1]); + } + else if (digits == 4) + { + result.Append(culture.DateTimeFormat.MonthNames[date.Month - 1]); + } + else if (digits == 5) + { + result.Append(culture.DateTimeFormat.MonthNames[date.Month - 1][0]); + } + else + { + result.Append(date.Month.ToString("D" + digits)); + } + } + } + else if (token.StartsWith("d", StringComparison.OrdinalIgnoreCase)) + { + var digits = token.Length; + if (digits == 3) + { + // Sun-Sat + result.Append(culture.DateTimeFormat.AbbreviatedDayNames[(int)date.DayOfWeek]); + } + else if (digits == 4) + { + // Sunday-Saturday + result.Append(culture.DateTimeFormat.DayNames[(int)date.DayOfWeek]); + } + else + { + result.Append(date.Day.ToString("D" + digits)); + } + } + else if (token.StartsWith("h", StringComparison.OrdinalIgnoreCase)) + { + var digits = token.Length; + if (containsAmPm) + result.Append(((date.Hour + 11) % 12 + 1).ToString("D" + digits)); + else + result.Append(date.Hour.ToString("D" + digits)); + } + else if (token.StartsWith("s", StringComparison.OrdinalIgnoreCase)) + { + var digits = token.Length; + result.Append(date.Second.ToString("D" + digits)); + } + else if (token.StartsWith("g", StringComparison.OrdinalIgnoreCase)) + { + var era = culture.DateTimeFormat.Calendar.GetEra(date.AdjustedDateTime); + var digits = token.Length; + if (digits < 3) + { + result.Append(culture.DateTimeFormat.GetAbbreviatedEraName(era)); + } + else + { + result.Append(culture.DateTimeFormat.GetEraName(era)); + } + } + else if (string.Compare(token, "am/pm", StringComparison.OrdinalIgnoreCase) == 0) + { + var ampm = date.ToString("tt", CultureInfo.InvariantCulture); + result.Append(ampm.ToUpperInvariant()); + } + else if (string.Compare(token, "a/p", StringComparison.OrdinalIgnoreCase) == 0) + { + var ampm = date.ToString("%t", CultureInfo.InvariantCulture); + if (char.IsUpper(token[0])) + { + result.Append(ampm.ToUpperInvariant()); + } + else + { + result.Append(ampm.ToLowerInvariant()); + } + } + else if (token.StartsWith(".0")) + { + var value = date.Millisecond; + var digits = token.Length - 1; + result.Append("." + value.ToString("D" + digits)); + } + else if (token == "/") + { +#if NETSTANDARD1_0 + result.Append(DateTime.MaxValue.ToString("/d", culture)[0]); +#else + result.Append(culture.DateTimeFormat.DateSeparator); +#endif + } + else if (token == ",") + { + while (i < tokens.Count - 1 && tokens[i + 1] == ",") + { + i++; + } + + result.Append(","); + } + else + { + FormatLiteral(token, result); + } + } + + return result.ToString(); + } + + private static bool LookAheadDatePart(List tokens, int fromIndex, string startsWith) + { + for (var i = fromIndex; i < tokens.Count; i++) + { + var token = tokens[i]; + if (token.StartsWith(startsWith, StringComparison.OrdinalIgnoreCase)) + return true; + if (Token.IsDatePart(token)) + return false; + } + + return false; + } + + private static bool LookBackDatePart(List tokens, int fromIndex, string startsWith) + { + for (var i = fromIndex; i >= 0; i--) + { + var token = tokens[i]; + if (token.StartsWith(startsWith, StringComparison.OrdinalIgnoreCase)) + return true; + if (Token.IsDatePart(token)) + return false; + } + + return false; + } + + private static bool ContainsAmPm(List tokens) + { + foreach (var token in tokens) + { + if (string.Compare(token, "am/pm", StringComparison.OrdinalIgnoreCase) == 0) + { + return true; + } + + if (string.Compare(token, "a/p", StringComparison.OrdinalIgnoreCase) == 0) + { + return true; + } + } + + return false; + } + + static string FormatNumber(double value, DecimalSection format, CultureInfo culture) + { + bool thousandSeparator = format.ThousandSeparator; + value = value / format.ThousandDivisor; + value = value * format.PercentMultiplier; + + var result = new StringBuilder(); + FormatNumber(value, format.BeforeDecimal, format.DecimalSeparator, format.AfterDecimal, thousandSeparator, culture, result); + return result.ToString(); + } + + static void FormatNumber(double value, List beforeDecimal, bool decimalSeparator, List afterDecimal, bool thousandSeparator, CultureInfo culture, StringBuilder result) + { + int signitificantDigits = 0; + if (afterDecimal != null) + signitificantDigits = GetDigitCount(afterDecimal); + + var valueString = Math.Abs(value).ToString("F" + signitificantDigits, CultureInfo.InvariantCulture); + var valueStrings = valueString.Split('.'); + var thousandsString = valueStrings[0]; + var decimalString = valueStrings.Length > 1 ? valueStrings[1].TrimEnd('0') : ""; + + if (value < 0) + { + result.Append("-"); + } + + if (beforeDecimal != null) + { + FormatThousands(thousandsString, thousandSeparator, false, beforeDecimal, culture, result); + } + + if (decimalSeparator) { + result.Append(culture.NumberFormat.NumberDecimalSeparator); + } + + if (afterDecimal != null) + { + FormatDecimals(decimalString, afterDecimal, result); + } + } + + /// + /// Prints right-aligned, left-padded integer before the decimal separator. With optional most-significant zero. + /// + public static void FormatThousands(string valueString, bool thousandSeparator, bool significantZero, List tokens, CultureInfo culture, StringBuilder result) + { + var significant = false; + var formatDigits = GetDigitCount(tokens); + valueString = valueString.PadLeft(formatDigits, '0'); + + // Print literals occurring before any placeholders + var tokenIndex = 0; + for (; tokenIndex < tokens.Count; tokenIndex++) + { + var token = tokens[tokenIndex]; + if (Token.IsPlaceholder(token)) + break; + else + FormatLiteral(token, result); + } + + // Print value digits until there are as many digits remaining as there are placeholders + var digitIndex = 0; + for (; digitIndex < (valueString.Length - formatDigits); digitIndex++) + { + significant = true; + result.Append(valueString[digitIndex]); + + if (thousandSeparator) + FormatThousandSeparator(valueString, digitIndex, culture, result); + } + + // Print remaining value digits and format literals + for (; tokenIndex < tokens.Count; ++tokenIndex) + { + var token = tokens[tokenIndex]; + if (Token.IsPlaceholder(token)) + { + var c = valueString[digitIndex]; + if (c != '0' || (significantZero && digitIndex == valueString.Length - 1)) significant = true; + + FormatPlaceholder(token, c, significant, result); + + if (thousandSeparator && (significant || token.Equals("0"))) + FormatThousandSeparator(valueString, digitIndex, culture, result); + + digitIndex++; + } + else + { + FormatLiteral(token, result); + } + } + } + + static void FormatThousandSeparator(string valueString, int digit, CultureInfo culture, StringBuilder result) + { + var positionInTens = valueString.Length - 1 - digit; + if (positionInTens > 0 && (positionInTens % 3) == 0) + { + result.Append(culture.NumberFormat.NumberGroupSeparator); + } + } + + /// + /// Prints left-aligned, right-padded integer after the decimal separator. Does not print significant zero. + /// + public static void FormatDecimals(string valueString, List tokens, StringBuilder result) + { + var significant = true; + var unpaddedDigits = valueString.Length; + var formatDigits = GetDigitCount(tokens); + + valueString = valueString.PadRight(formatDigits, '0'); + + // Print all format digits + var valueIndex = 0; + for (var tokenIndex = 0; tokenIndex < tokens.Count; ++tokenIndex) + { + var token = tokens[tokenIndex]; + if (Token.IsPlaceholder(token)) + { + var c = valueString[valueIndex]; + significant = valueIndex < unpaddedDigits; + + FormatPlaceholder(token, c, significant, result); + valueIndex++; + } + else + { + FormatLiteral(token, result); + } + } + } + + static string FormatExponential(double value, Section format, CultureInfo culture) + { + // The application shall display a number to the right of + // the "E" symbol that corresponds to the number of places that + // the decimal point was moved. + + var baseDigits = 0; + if (format.Exponential.BeforeDecimal != null) + { + baseDigits = GetDigitCount(format.Exponential.BeforeDecimal); + } + + var exponent = (int)Math.Floor(Math.Log10(Math.Abs(value))); + var mantissa = value / Math.Pow(10, exponent); + + var shift = Math.Abs(exponent) % baseDigits; + if (shift > 0) + { + if (exponent < 0) + shift = (baseDigits - shift); + + mantissa *= Math.Pow(10, shift); + exponent -= shift; + } + + var result = new StringBuilder(); + FormatNumber(mantissa, format.Exponential.BeforeDecimal, format.Exponential.DecimalSeparator, format.Exponential.AfterDecimal, false, culture, result); + + result.Append(format.Exponential.ExponentialToken[0]); + + if (format.Exponential.ExponentialToken[1] == '+' && exponent >= 0) + { + result.Append("+"); + } + else if (exponent < 0) + { + result.Append("-"); + } + + FormatThousands(Math.Abs(exponent).ToString(CultureInfo.InvariantCulture), false, false, format.Exponential.Power, culture, result); + return result.ToString(); + } + + static string FormatFraction(double value, Section format, CultureInfo culture) + { + int integral = 0; + int numerator, denominator; + + bool sign = value < 0; + + if (format.Fraction.IntegerPart != null) + { + integral = (int)Math.Truncate(value); + value = Math.Abs(value - integral); + } + + if (format.Fraction.DenominatorConstant != 0) + { + denominator = format.Fraction.DenominatorConstant; + var rr = Math.Round(value * denominator); + var b = Math.Floor(rr / denominator); + numerator = (int)(rr - b * denominator); + } + else + { + var denominatorDigits = Math.Min(GetDigitCount(format.Fraction.Denominator), 7); + GetFraction(value, (int)Math.Pow(10, denominatorDigits) - 1, out numerator, out denominator); + } + + // Don't hide fraction if at least one zero in the numerator format + var numeratorZeros = GetZeroCount(format.Fraction.Numerator); + var hideFraction = (format.Fraction.IntegerPart != null && numerator == 0 && numeratorZeros == 0); + + var result = new StringBuilder(); + + if (sign) + result.Append("-"); + + // Print integer part with significant zero if fraction part is hidden + if (format.Fraction.IntegerPart != null) + FormatThousands(Math.Abs(integral).ToString("F0", CultureInfo.InvariantCulture), false, hideFraction, format.Fraction.IntegerPart, culture, result); + + var numeratorString = Math.Abs(numerator).ToString("F0", CultureInfo.InvariantCulture); + var denominatorString = denominator.ToString("F0", CultureInfo.InvariantCulture); + + var fraction = new StringBuilder(); + + FormatThousands(numeratorString, false, true, format.Fraction.Numerator, culture, fraction); + + fraction.Append("/"); + + if (format.Fraction.DenominatorPrefix != null) + FormatThousands("", false, false, format.Fraction.DenominatorPrefix, culture, fraction); + + if (format.Fraction.DenominatorConstant != 0) + fraction.Append(format.Fraction.DenominatorConstant.ToString()); + else + FormatDenominator(denominatorString, format.Fraction.Denominator, fraction); + + if (format.Fraction.DenominatorSuffix != null) + FormatThousands("", false, false, format.Fraction.DenominatorSuffix, culture, fraction); + + if (hideFraction) + result.Append(new string(' ', fraction.ToString().Length)); + else + result.Append(fraction.ToString()); + + if (format.Fraction.FractionSuffix != null) + FormatThousands("", false, false, format.Fraction.FractionSuffix, culture, result); + + return result.ToString(); + } + + // Adapted from ssf.js 'frac()' helper + static void GetFraction(double x, int D, out int nom, out int den) + { + var sgn = x < 0 ? -1 : 1; + var B = x * sgn; + var P_2 = 0.0; + var P_1 = 1.0; + var P = 0.0; + var Q_2 = 1.0; + var Q_1 = 0.0; + var Q = 0.0; + var A = Math.Floor(B); + while (Q_1 < D) + { + A = Math.Floor(B); + P = A * P_1 + P_2; + Q = A * Q_1 + Q_2; + if ((B - A) < 0.00000005) break; + B = 1 / (B - A); + P_2 = P_1; P_1 = P; + Q_2 = Q_1; Q_1 = Q; + } + if (Q > D) { if (Q_1 > D) { Q = Q_2; P = P_2; } else { Q = Q_1; P = P_1; } } + nom = (int)(sgn * P); + den = (int)Q; + } + + /// + /// Prints left-aligned, left-padded fraction integer denominator. + /// Assumes tokens contain only placeholders, valueString has fewer or equal number of digits as tokens. + /// + public static void FormatDenominator(string valueString, List tokens, StringBuilder result) + { + var formatDigits = GetDigitCount(tokens); + valueString = valueString.PadLeft(formatDigits, '0'); + + bool significant = false; + var valueIndex = 0; + for (var tokenIndex = 0; tokenIndex < tokens.Count; ++tokenIndex) + { + var token = tokens[tokenIndex]; + char c; + if (valueIndex < valueString.Length) { + c = GetLeftAlignedValueDigit(token, valueString, valueIndex, significant, out valueIndex); + + if (c != '0') + significant = true; + } else { + c = '0'; + significant = false; + } + + FormatPlaceholder(token, c, significant, result); + } + } + + /// + /// Returns the first digit from valueString. If the token is '?' + /// returns the first significant digit from valueString, or '0' if there are no significant digits. + /// The out valueIndex parameter contains the offset to the next digit in valueString. + /// + static char GetLeftAlignedValueDigit(string token, string valueString, int startIndex, bool significant, out int valueIndex) + { + char c; + valueIndex = startIndex; + if (valueIndex < valueString.Length) + { + c = valueString[valueIndex]; + valueIndex++; + + if (c != '0') + significant = true; + + if (token == "?" && !significant) + { + // Eat insignificant zeros to left align denominator + while (valueIndex < valueString.Length) + { + c = valueString[valueIndex]; + valueIndex++; + + if (c != '0') + { + significant = true; + break; + } + } + } + } + else + { + c = '0'; + significant = false; + } + + return c; + } + + static void FormatPlaceholder(string token, char c, bool significant, StringBuilder result) + { + if (token == "0") + { + if (significant) + result.Append(c); + else + result.Append("0"); + } + else if (token == "#") + { + if (significant) + result.Append(c); + } + else if (token == "?") + { + if (significant) + result.Append(c); + else + result.Append(" "); + } + } + + static int GetDigitCount(List tokens) + { + var counter = 0; + foreach (var token in tokens) + { + if (Token.IsPlaceholder(token)) + { + counter++; + } + } + return counter; + } + + static int GetZeroCount(List tokens) + { + var counter = 0; + foreach (var token in tokens) + { + if (token == "0") + { + counter++; + } + } + return counter; + } + + static void FormatLiteral(string token, StringBuilder result) + { + string literal = string.Empty; + if (token == ",") + { + ; // skip commas + } + else if (token.Length == 2 && (token[0] == '*' || token[0] == '\\')) + { + // TODO: * = repeat to fill cell + literal = token[1].ToString(); + } + else if (token.Length == 2 && token[0] == '_') + { + literal = " "; + } + else if (token.StartsWith("\"")) + { + literal = token.Substring(1, token.Length - 2); + } + else + { + literal = token; + } + result.Append(literal); + } + } +} diff --git a/src/ExcelNumberFormat/FractionSection.cs b/src/ExcelNumberFormat/FractionSection.cs new file mode 100644 index 0000000..33dbd08 --- /dev/null +++ b/src/ExcelNumberFormat/FractionSection.cs @@ -0,0 +1,216 @@ +using System.Collections.Generic; +using System.Text; + +namespace ExcelNumberFormat +{ + internal class FractionSection + { + public List IntegerPart { get; set; } + + public List Numerator { get; set; } + + public List DenominatorPrefix { get; set; } + + public List Denominator { get; set; } + + public int DenominatorConstant { get; set; } + + public List DenominatorSuffix { get; set; } + + public List FractionSuffix { get; set; } + + static public bool TryParse(List tokens, out FractionSection format) + { + List numeratorParts = null; + List denominatorParts = null; + + for (var i = 0; i < tokens.Count; i++) + { + var part = tokens[i]; + if (part == "/") + { + numeratorParts = tokens.GetRange(0, i); + i++; + denominatorParts = tokens.GetRange(i, tokens.Count - i); + break; + } + } + + if (numeratorParts == null) + { + format = null; + return false; + } + + GetNumerator(numeratorParts, out var integerPart, out var numeratorPart); + + if (!TryGetDenominator(denominatorParts, out var denominatorPrefix, out var denominatorPart, out var denominatorConstant, out var denominatorSuffix, out var fractionSuffix)) + { + format = null; + return false; + } + + format = new FractionSection() + { + IntegerPart = integerPart, + Numerator = numeratorPart, + DenominatorPrefix = denominatorPrefix, + Denominator = denominatorPart, + DenominatorConstant = denominatorConstant, + DenominatorSuffix = denominatorSuffix, + FractionSuffix = fractionSuffix + }; + + return true; + } + + static void GetNumerator(List tokens, out List integerPart, out List numeratorPart) + { + var hasPlaceholder = false; + var hasSpace = false; + var hasIntegerPart = false; + var numeratorIndex = -1; + var index = tokens.Count - 1; + while (index >= 0) + { + var token = tokens[index]; + if (Token.IsPlaceholder(token)) + { + hasPlaceholder = true; + + if (hasSpace) + { + hasIntegerPart = true; + break; + } + } + else + { + if (hasPlaceholder && !hasSpace) + { + // First time we get here marks the end of the integer part + hasSpace = true; + numeratorIndex = index + 1; + } + } + index--; + } + + if (hasIntegerPart) + { + integerPart = tokens.GetRange(0, numeratorIndex); + numeratorPart = tokens.GetRange(numeratorIndex, tokens.Count - numeratorIndex); + } + else + { + integerPart = null; + numeratorPart = tokens; + } + } + + static bool TryGetDenominator(List tokens, out List denominatorPrefix, out List denominatorPart, out int denominatorConstant, out List denominatorSuffix, out List fractionSuffix) + { + var index = 0; + var hasPlaceholder = false; + var hasConstant = false; + + var constant = new StringBuilder(); + + // Read literals until the first number placeholder or digit + while (index < tokens.Count) + { + var token = tokens[index]; + if (Token.IsPlaceholder(token)) + { + hasPlaceholder = true; + break; + } + else + if (Token.IsDigit19(token)) + { + hasConstant = true; + break; + } + index++; + } + + if (!hasPlaceholder && !hasConstant) + { + denominatorPrefix = null; + denominatorPart = null; + denominatorConstant = 0; + denominatorSuffix = null; + fractionSuffix = null; + return false; + } + + // The denominator starts here, keep the index + var denominatorIndex = index; + + // Read placeholders or digits in sequence + while (index < tokens.Count) + { + var token = tokens[index]; + if (hasPlaceholder && Token.IsPlaceholder(token)) + { + ; // OK + } + else + if (hasConstant && (Token.IsDigit09(token))) + { + constant.Append(token); + } + else + { + break; + } + index++; + } + + // 'index' is now at the first token after the denominator placeholders. + // The remaining, if anything, is to be treated in one or two parts: + // Any ultimately terminating literals are considered the "Fraction suffix". + // Anything between the denominator and the fraction suffix is the "Denominator suffix". + // Placeholders in the denominator suffix are treated as insignificant zeros. + + // Scan backwards to determine the fraction suffix + int fractionSuffixIndex = tokens.Count; + while (fractionSuffixIndex > index) + { + var token = tokens[fractionSuffixIndex - 1]; + if (Token.IsPlaceholder(token)) + { + break; + } + + fractionSuffixIndex--; + } + + // Finally extract the detected token ranges + + if (denominatorIndex > 0) + denominatorPrefix = tokens.GetRange(0, denominatorIndex); + else + denominatorPrefix = null; + + if (hasConstant) + denominatorConstant = int.Parse(constant.ToString()); + else + denominatorConstant = 0; + + denominatorPart = tokens.GetRange(denominatorIndex, index - denominatorIndex); + + if (index < fractionSuffixIndex) + denominatorSuffix = tokens.GetRange(index, fractionSuffixIndex - index); + else + denominatorSuffix = null; + + if (fractionSuffixIndex < tokens.Count) + fractionSuffix = tokens.GetRange(fractionSuffixIndex, tokens.Count - fractionSuffixIndex); + else + fractionSuffix = null; + + return true; + } + } +} \ No newline at end of file diff --git a/src/ExcelNumberFormat/NumberFormat.cs b/src/ExcelNumberFormat/NumberFormat.cs new file mode 100644 index 0000000..749adbb --- /dev/null +++ b/src/ExcelNumberFormat/NumberFormat.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace ExcelNumberFormat +{ + /// + /// Parse ECMA-376 number format strings and format values like Excel and other spreadsheet softwares. + /// + public class NumberFormat + { + /// + /// Initializes a new instance of the class. + /// + /// The number format string. + public NumberFormat(string formatString) + { + var sections = Parser.ParseSections(formatString, out bool syntaxError); + + IsValid = !syntaxError; + FormatString = formatString; + + if (IsValid) + { + Sections = sections; + IsDateTimeFormat = Evaluator.GetFirstSection(Sections, SectionType.Date) != null; + IsTimeSpanFormat = Evaluator.GetFirstSection(Sections, SectionType.Duration) != null; + } + else + { + Sections = new List
(); + } + } + + /// + /// Gets a value indicating whether the number format string is valid. + /// + public bool IsValid { get; } + + /// + /// Gets the number format string. + /// + public string FormatString { get; } + + /// + /// Gets a value indicating whether the format represents a DateTime + /// + public bool IsDateTimeFormat { get; } + + /// + /// Gets a value indicating whether the format represents a TimeSpan + /// + public bool IsTimeSpanFormat { get; } + + internal List
Sections { get; } + + /// + /// Formats a value with this number format in a specified culture. + /// + /// The value to format. + /// The culture to use for formatting. + /// If false, numeric dates start on January 0 1900 and include February 29 1900 - like Excel on PC. If true, numeric dates start on January 1 1904 - like Excel on Mac. + /// The formatted string. + public string Format(object value, CultureInfo culture, bool isDate1904 = false) + { + var section = Evaluator.GetSection(Sections, value); + if (section == null) + return CompatibleConvert.ToString(value, culture); + + try + { + return Formatter.Format(value, section, culture, isDate1904); + } + catch (InvalidCastException) + { + // TimeSpan cast exception + return CompatibleConvert.ToString(value, culture); + } + catch (FormatException) + { + // Convert.ToDouble/ToDateTime exceptions + return CompatibleConvert.ToString(value, culture); + } + } + } +} diff --git a/src/ExcelNumberFormat/Parser.cs b/src/ExcelNumberFormat/Parser.cs new file mode 100644 index 0000000..d02c058 --- /dev/null +++ b/src/ExcelNumberFormat/Parser.cs @@ -0,0 +1,398 @@ +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace ExcelNumberFormat +{ + internal static class Parser + { + public static List
ParseSections(string formatString, out bool syntaxError) + { + var tokenizer = new Tokenizer(formatString); + var sections = new List
(); + syntaxError = false; + while (true) + { + var section = ParseSection(tokenizer, sections.Count, out var sectionSyntaxError); + + if (sectionSyntaxError) + syntaxError = true; + + if (section == null) + break; + + sections.Add(section); + } + + return sections; + } + + private static Section ParseSection(Tokenizer reader, int index, out bool syntaxError) + { + bool hasDateParts = false; + bool hasDurationParts = false; + bool hasGeneralPart = false; + bool hasTextPart = false; + bool hasPlaceholders = false; + Condition condition = null; + Color color = null; + string token; + List tokens = new List(); + + syntaxError = false; + while ((token = ReadToken(reader, out syntaxError)) != null) + { + if (token == ";") + break; + + hasPlaceholders |= Token.IsPlaceholder(token); + + if (Token.IsDatePart(token)) + { + hasDateParts |= true; + hasDurationParts |= Token.IsDurationPart(token); + tokens.Add(token); + } + else if (Token.IsGeneral(token)) + { + hasGeneralPart |= true; + tokens.Add(token); + } + else if (token == "@") + { + hasTextPart |= true; + tokens.Add(token); + } + else if (token.StartsWith("[")) + { + // Does not add to tokens. Absolute/elapsed time tokens + // also start with '[', but handled as date part above + var expression = token.Substring(1, token.Length - 2); + if (TryParseCondition(expression, out var parseCondition)) + condition = parseCondition; + else if (TryParseColor(expression, out var parseColor)) + color = parseColor; + else if (TryParseCurrencySymbol(expression, out var parseCurrencySymbol)) + tokens.Add("\"" + parseCurrencySymbol + "\""); + } + else + { + tokens.Add(token); + } + } + + if (syntaxError || tokens.Count == 0) + { + return null; + } + + if ( + (hasDateParts && (hasGeneralPart || hasTextPart)) || + (hasGeneralPart && (hasDateParts || hasTextPart)) || + (hasTextPart && (hasGeneralPart || hasDateParts))) + { + // Cannot mix date, general and/or text parts + syntaxError = true; + return null; + } + + SectionType type; + FractionSection fraction = null; + ExponentialSection exponential = null; + DecimalSection number = null; + List generalTextDateDuration = null; + + if (hasDateParts) + { + if (hasDurationParts) + { + type = SectionType.Duration; + } + else + { + type = SectionType.Date; + } + + ParseMilliseconds(tokens, out generalTextDateDuration); + } + else if (hasGeneralPart) + { + type = SectionType.General; + generalTextDateDuration = tokens; + } + else if (hasTextPart || !hasPlaceholders) + { + type = SectionType.Text; + generalTextDateDuration = tokens; + } + else if (FractionSection.TryParse(tokens, out fraction)) + { + type = SectionType.Fraction; + } + else if (ExponentialSection.TryParse(tokens, out exponential)) + { + type = SectionType.Exponential; + } + else if (DecimalSection.TryParse(tokens, out number)) + { + type = SectionType.Number; + } + else + { + // Unable to parse format string + syntaxError = true; + return null; + } + + return new Section() + { + Type = type, + SectionIndex = index, + Color = color, + Condition = condition, + Fraction = fraction, + Exponential = exponential, + Number = number, + GeneralTextDateDurationParts = generalTextDateDuration + }; + } + + /// + /// Parses as many placeholders and literals needed to format a number with optional decimals. + /// Returns number of tokens parsed, or 0 if the tokens didn't form a number. + /// + internal static int ParseNumberTokens(List tokens, int startPosition, out List beforeDecimal, out bool decimalSeparator, out List afterDecimal) + { + beforeDecimal = null; + afterDecimal = null; + decimalSeparator = false; + + List remainder = new List(); + var index = 0; + for (index = 0; index < tokens.Count; ++index) + { + var token = tokens[index]; + if (token == "." && beforeDecimal == null) + { + decimalSeparator = true; + beforeDecimal = tokens.GetRange(0, index); // TODO: why not remainder? has only valid tokens... + + remainder = new List(); + } + else if (Token.IsNumberLiteral(token)) + { + remainder.Add(token); + } + else if (token.StartsWith("[")) + { + // ignore + } + else + { + break; + } + } + + if (remainder.Count > 0) + { + if (beforeDecimal != null) + { + afterDecimal = remainder; + } + else + { + beforeDecimal = remainder; + } + } + + return index; + } + + private static void ParseMilliseconds(List tokens, out List result) + { + // if tokens form .0 through .000.., combine to single subsecond token + result = new List(); + for (var i = 0; i < tokens.Count; i++) + { + var token = tokens[i]; + if (token == ".") + { + var zeros = 0; + while (i + 1 < tokens.Count && tokens[i + 1] == "0") + { + i++; + zeros++; + } + + if (zeros > 0) + result.Add("." + new string('0', zeros)); + else + result.Add("."); + } + else + { + result.Add(token); + } + } + } + + private static string ReadToken(Tokenizer reader, out bool syntaxError) + { + var offset = reader.Position; + if ( + ReadLiteral(reader) || + reader.ReadEnclosed('[', ']') || + + // Symbols + reader.ReadOneOf("#?,!&%+-$€£0123456789{}():;/.@ ") || + reader.ReadString("e+", true) || + reader.ReadString("e-", true) || + reader.ReadString("General", true) || + + // Date + reader.ReadString("am/pm", true) || + reader.ReadString("a/p", true) || + reader.ReadOneOrMore('y') || + reader.ReadOneOrMore('Y') || + reader.ReadOneOrMore('m') || + reader.ReadOneOrMore('M') || + reader.ReadOneOrMore('d') || + reader.ReadOneOrMore('D') || + reader.ReadOneOrMore('h') || + reader.ReadOneOrMore('H') || + reader.ReadOneOrMore('s') || + reader.ReadOneOrMore('S') || + reader.ReadOneOrMore('g') || + reader.ReadOneOrMore('G')) + { + syntaxError = false; + var length = reader.Position - offset; + return reader.Substring(offset, length); + } + + syntaxError = reader.Position < reader.Length; + return null; + } + + private static bool ReadLiteral(Tokenizer reader) + { + if (reader.Peek() == '\\' || reader.Peek() == '*' || reader.Peek() == '_') + { + reader.Advance(2); + return true; + } + else if (reader.ReadEnclosed('"', '"')) + { + return true; + } + + return false; + } + + private static bool TryParseCondition(string token, out Condition result) + { + var tokenizer = new Tokenizer(token); + + if (tokenizer.ReadString("<=") || + tokenizer.ReadString("<>") || + tokenizer.ReadString("<") || + tokenizer.ReadString(">=") || + tokenizer.ReadString(">") || + tokenizer.ReadString("=")) + { + var conditionPosition = tokenizer.Position; + var op = tokenizer.Substring(0, conditionPosition); + + if (ReadConditionValue(tokenizer)) + { + var valueString = tokenizer.Substring(conditionPosition, tokenizer.Position - conditionPosition); + + result = new Condition() + { + Operator = op, + Value = double.Parse(valueString, CultureInfo.InvariantCulture) + }; + return true; + } + } + + result = null; + return false; + } + + private static bool ReadConditionValue(Tokenizer tokenizer) + { + // NFPartCondNum = [ASCII-HYPHEN-MINUS] NFPartIntNum [INTL-CHAR-DECIMAL-SEP NFPartIntNum] [NFPartExponential NFPartIntNum] + tokenizer.ReadString("-"); + while (tokenizer.ReadOneOf("0123456789")) + { + } + + if (tokenizer.ReadString(".")) + { + while (tokenizer.ReadOneOf("0123456789")) + { + } + } + + if (tokenizer.ReadString("e+", true) || tokenizer.ReadString("e-", true)) + { + if (tokenizer.ReadOneOf("0123456789")) + { + while (tokenizer.ReadOneOf("0123456789")) + { + } + } + else + { + return false; + } + } + + return true; + } + + private static bool TryParseColor(string token, out Color color) + { + // TODO: Color1..59 + var tokenizer = new Tokenizer(token); + if ( + tokenizer.ReadString("black", true) || + tokenizer.ReadString("blue", true) || + tokenizer.ReadString("cyan", true) || + tokenizer.ReadString("green", true) || + tokenizer.ReadString("magenta", true) || + tokenizer.ReadString("red", true) || + tokenizer.ReadString("white", true) || + tokenizer.ReadString("yellow", true)) + { + color = new Color() + { + Value = tokenizer.Substring(0, tokenizer.Position) + }; + return true; + } + + color = null; + return false; + } + + private static bool TryParseCurrencySymbol(string token, out string currencySymbol) + { + if (string.IsNullOrEmpty(token) + || !token.StartsWith("$")) + { + currencySymbol = null; + return false; + } + + + if (token.Contains("-")) + currencySymbol = token.Substring(1, token.IndexOf('-') - 1); + else + currencySymbol = token.Substring(1); + + return true; + } + } +} diff --git a/src/ExcelNumberFormat/Section.cs b/src/ExcelNumberFormat/Section.cs new file mode 100644 index 0000000..758b605 --- /dev/null +++ b/src/ExcelNumberFormat/Section.cs @@ -0,0 +1,23 @@ +using System.Collections.Generic; + +namespace ExcelNumberFormat +{ + internal class Section + { + public int SectionIndex { get; set; } + + public SectionType Type { get; set; } + + public Color Color { get; set; } + + public Condition Condition { get; set; } + + public ExponentialSection Exponential { get; set; } + + public FractionSection Fraction { get; set; } + + public DecimalSection Number { get; set; } + + public List GeneralTextDateDurationParts { get; set; } + } +} \ No newline at end of file diff --git a/src/ExcelNumberFormat/SectionType.cs b/src/ExcelNumberFormat/SectionType.cs new file mode 100644 index 0000000..a4c2660 --- /dev/null +++ b/src/ExcelNumberFormat/SectionType.cs @@ -0,0 +1,13 @@ +namespace ExcelNumberFormat +{ + internal enum SectionType + { + General, + Number, + Fraction, + Exponential, + Date, + Duration, + Text, + } +} \ No newline at end of file diff --git a/src/ExcelNumberFormat/Token.cs b/src/ExcelNumberFormat/Token.cs new file mode 100644 index 0000000..d29b64b --- /dev/null +++ b/src/ExcelNumberFormat/Token.cs @@ -0,0 +1,110 @@ +using System; + +namespace ExcelNumberFormat +{ + internal static class Token + { + public static bool IsExponent(string token) + { + return + (string.Compare(token, "e+", StringComparison.OrdinalIgnoreCase) == 0) || + (string.Compare(token, "e-", StringComparison.OrdinalIgnoreCase) == 0); + } + + public static bool IsLiteral(string token) + { + return + token.StartsWith("_") || + token.StartsWith("\\") || + token.StartsWith("\"") || + token.StartsWith("*") || + token == "," || + token == "!" || + token == "&" || + token == "%" || + token == "+" || + token == "-" || + token == "$" || + token == "€" || + token == "£" || + token == "1" || + token == "2" || + token == "3" || + token == "4" || + token == "5" || + token == "6" || + token == "7" || + token == "8" || + token == "9" || + token == "{" || + token == "}" || + token == "(" || + token == ")" || + token == " "; + } + + public static bool IsNumberLiteral(string token) + { + return + IsPlaceholder(token) || + IsLiteral(token) || + token == "."; + } + + public static bool IsPlaceholder(string token) + { + return token == "0" || token == "#" || token == "?"; + } + + public static bool IsGeneral(string token) + { + return string.Compare(token, "general", StringComparison.OrdinalIgnoreCase) == 0; + } + + public static bool IsDatePart(string token) + { + return + token.StartsWith("y", StringComparison.OrdinalIgnoreCase) || + token.StartsWith("m", StringComparison.OrdinalIgnoreCase) || + token.StartsWith("d", StringComparison.OrdinalIgnoreCase) || + token.StartsWith("s", StringComparison.OrdinalIgnoreCase) || + token.StartsWith("h", StringComparison.OrdinalIgnoreCase) || + (token.StartsWith("g", StringComparison.OrdinalIgnoreCase) && !IsGeneral(token)) || + string.Compare(token, "am/pm", StringComparison.OrdinalIgnoreCase) == 0 || + string.Compare(token, "a/p", StringComparison.OrdinalIgnoreCase) == 0 || + IsDurationPart(token); + } + + public static bool IsDurationPart(string token) + { + return + token.StartsWith("[h", StringComparison.OrdinalIgnoreCase) || + token.StartsWith("[m", StringComparison.OrdinalIgnoreCase) || + token.StartsWith("[s", StringComparison.OrdinalIgnoreCase); + } + + public static bool IsDigit09(string token) + { + return token == "0" || IsDigit19(token); + } + + public static bool IsDigit19(string token) + { + switch (token) + { + case "1": + case "2": + case "3": + case "4": + case "5": + case "6": + case "7": + case "8": + case "9": + return true; + default: + return false; + } + } + } +} diff --git a/src/ExcelNumberFormat/Tokenizer.cs b/src/ExcelNumberFormat/Tokenizer.cs new file mode 100644 index 0000000..549e0a4 --- /dev/null +++ b/src/ExcelNumberFormat/Tokenizer.cs @@ -0,0 +1,121 @@ +using System; + +namespace ExcelNumberFormat +{ + internal class Tokenizer + { + private string formatString; + private int formatStringPosition = 0; + + public Tokenizer(string fmt) + { + formatString = fmt; + } + + public int Position => formatStringPosition; + + public int Length => formatString?.Length ?? 0; + + public string Substring(int startIndex, int length) + { + return formatString.Substring(startIndex, length); + } + + public int Peek(int offset = 0) + { + if (formatStringPosition + offset >= Length) + return -1; + return formatString[formatStringPosition + offset]; + } + + public int PeekUntil(int startOffset, int until) + { + int offset = startOffset; + while (true) + { + var c = Peek(offset++); + if (c == -1) + break; + if (c == until) + return offset - startOffset; + } + return 0; + } + + public bool PeekOneOf(int offset, string s) + { + foreach (var c in s) + { + if (Peek(offset) == c) + { + return true; + } + } + return false; + } + + public void Advance(int characters = 1) + { + formatStringPosition = Math.Min(formatStringPosition + characters, formatString.Length); + } + + public bool ReadOneOrMore(int c) + { + if (Peek() != c) + return false; + + while (Peek() == c) + Advance(); + + return true; + } + + public bool ReadOneOf(string s) + { + if (PeekOneOf(0, s)) + { + Advance(); + return true; + } + return false; + } + + public bool ReadString(string s, bool ignoreCase = false) + { + if (formatStringPosition + s.Length > Length) + return false; + + for (var i = 0; i < s.Length; i++) + { + var c1 = s[i]; + var c2 = (char)Peek(i); + if (ignoreCase) + { + if (char.ToLower(c1) != char.ToLower(c2)) return false; + } + else + { + if (c1 != c2) return false; + } + } + + Advance(s.Length); + return true; + } + + public bool ReadEnclosed(char open, char close) + { + if (Peek() == open) + { + int length = PeekUntil(1, close); + if (length > 0) + { + Advance(1 + length); + return true; + } + } + + return false; + } + } +} diff --git a/src/ExcelNumberFormat/icon.png b/src/ExcelNumberFormat/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..f9b622962329ca344bc4463b3b2c4caa23ecb93c GIT binary patch literal 1422 zcmbtU`#02i82)^}b75x8FcwXWHMw-P!=7%kBi|8*LgN<0kagO$BC{JA+SYdzrK~!s ztfG??B7@3k!R;F&-uHQ*=R7|>?|a@HZUBc&v?Kxm z$i6Wkd6KLy3TvV@Uo_xtI8ZQ_SjAFD_a}bWwPicR+B-I zK`HvPLJoIqtb-**W1_%cFpe}FsLJjPDx|bY-@LyhfX1f~-Pw%x`$5-0j6#EFSnF63 zab1)I*c%HNF(K-tp@GAu;BJJJ62f~+n%I{S(4d)!USVtE(z9+n;?+B@c2sV$uBg`j zmYTmzx~#QJnIrna-pPzG5YbW7d^({izh69J3&QJ}uw_S5Q)&XWign5Y8Uqzo1ErXp zg6P5G<0mnp=CyD@uc#GX5`RPBi4K0mL$XTf{v~Rjba?8kdzr^N^@0SV$8#!km*-wm zS(^^_lK>vL$n&@vT_2@uvTrCrC~yHDfidkX*=CuKC+1*5Xq8!4(A_#UXEpbWIV3h& z=Y>hUre_kelAEj96D)bgGpTJhPw`t)3)2&8ZfajC=PjN=it}w#?bI~g3Dj9&k60FP z(m3j^3wPXa5v&bel60MsrUNfZp+sOSU?9D_Z`-4N=r%3&;qPcmp)C$6TGV09jRSr{@~W{%5{Rg&aU$>R;gOZw8$3Qs>f{8_e(@c~BTTd@L+avElO6KQ=SBS=Y{=@y%S1S?sIsoFH$Tkafyq^;!x zzjpN-+y#vCeM>+#0qi4Np?j(N?HLMt9RUp}xmGr|$_J1k^mnBQqhyNm*)STqsb-Ok zI};gi&b{v4?5^Z)uYjA#wSmAH%uvi=)xs9o7;VHePd#mOb0Z9`B)wZR#uG@5!z_0@ zzHBr!x!AR=dCh|M48#^0V>KgEh_7ioUor^YLvcS4g%G}qm(Cvve zdYF%CJ<)5cq{wWbkwfK1?9OdI)ENV%FCtPoPUhq2ldFTUTIdj!d)=$KM#UZ7ygLVp zIv$zAhvg%a>yR_ZDojgJVsHnHCSqLVBoC}B4V6pTG6(pAiP56@%4zP(z|trO<(fR9 zX8^^1i!VXt(c;0C?SZmTy^D;nl6*FAn71QT_}#9K7zg&A7{>FKk73VZR|zDP2X>e7 zCCJrX4OYIgEZ5$=1le{g)`3j)9FDOjY`y=XU~0XptfZn)lGK$Ek;g-6 - +