WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content

Commit 8e1369b

Browse files
committed
Added support for numeric dates and durations. Added a new isDate1904-flag in the Format method. Bump minor version.
1 parent bdd0c3a commit 8e1369b

File tree

6 files changed

+245
-17
lines changed

6 files changed

+245
-17
lines changed

README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ Console.WriteLine(format.Format(1234.56, CultureInfo.InvariantCulture));
2727
- Parses and formats most custom number formats as expected: decimal, percent, thousands, exponential, fraction, currency, date/time, duration, text.
2828
- Supports multiple sections with conditions.
2929
- Formats values with relevant constants from CultureInfo.
30-
- Formats dates and durations using DateTime and TimeSpan values instead of numeric values like Excel.
30+
- Supports DateTime, TimeSpan and numeric values for date and duration formats.
31+
- Supports both 1900- and 1904-based numeric datetimes (Excel on Mac uses 1904-based dates).
3132
- Targets net20 and netstandard1.0 for max compatibility.
3233

3334
## Formatting .NET types
@@ -39,14 +40,15 @@ Format Kind | Example | .NET type|Conversion strategy
3940
Number | 0.00 |double|Convert.ToDouble()
4041
Fraction | 0/0 |double|Convert.ToDouble()
4142
Exponent | \#0.0E+0 |double|Convert.ToDouble()
42-
Date/Time| hh\:mm |DateTime|Convert.ToDateTime()
43-
Duration | \[hh\]\:mm|TimeSpan|Cast to TimeSpan
43+
Date/Time| hh\:mm |DateTime|ExcelDateTime.TryConvert()
44+
Duration | \[hh\]\:mm|TimeSpan|Cast or TimeSpan.FromDays()
4445
General | General |(any)|CompatibleConvert.ToString()
4546
Text | ;;;"Text: "@|string|Convert.ToString()
4647

4748
In case of errors, `Format()` returns the value from `CompatibleConvert.ToString()`.
4849

4950
`CompatibleConvert.ToString()` formats floats and doubles with explicit precision, or falls back to `Convert.ToString()` for any other types.
51+
`ExcelDateTime.TryConvert()` uses DateTimes as is, or converts numeric values to a DateTime with adjustments for legacy Excel behaviors.
5052

5153
## TODO/notes
5254

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
using System;
2+
using System.Globalization;
3+
4+
namespace ExcelNumberFormat
5+
{
6+
/// <summary>
7+
/// Similar to regular .NET DateTime, but also supports 0/1 1900 and 29/2 1900.
8+
/// </summary>
9+
internal class ExcelDateTime
10+
{
11+
/// <summary>
12+
/// The closest .NET DateTime to the specified excel date.
13+
/// </summary>
14+
public DateTime AdjustedDateTime { get; }
15+
16+
/// <summary>
17+
/// Number of days to adjust by in post.
18+
/// </summary>
19+
public int AdjustDaysPost { get; }
20+
21+
/// <summary>
22+
/// Constructs a new ExcelDateTime from a numeric value.
23+
/// </summary>
24+
public ExcelDateTime(double numericDate, bool isDate1904)
25+
{
26+
if (isDate1904)
27+
{
28+
numericDate += 1462.0;
29+
AdjustedDateTime = new DateTime(DoubleDateToTicks(numericDate), DateTimeKind.Unspecified);
30+
}
31+
else
32+
{
33+
// internal dates before 30/12/1899 should add two days to get the real date
34+
// internal dates on 30/12 19899 should add two days, but subtract a day post to get the real date
35+
// internal dates before 28/2/1900 should add one day to get the real date
36+
// internal dates on 28/2 1900 should use the same date, but add a day post to get the real date
37+
38+
var internalDateTime = new DateTime(DoubleDateToTicks(numericDate), DateTimeKind.Unspecified);
39+
if (internalDateTime < Excel1900ZeroethMinDate)
40+
{
41+
AdjustDaysPost = 0;
42+
AdjustedDateTime = internalDateTime.AddDays(2);
43+
}
44+
45+
else if (internalDateTime < Excel1900ZeroethMaxDate)
46+
{
47+
AdjustDaysPost = -1;
48+
AdjustedDateTime = internalDateTime.AddDays(2);
49+
}
50+
51+
else if (internalDateTime < Excel1900LeapMinDate)
52+
{
53+
AdjustDaysPost = 0;
54+
AdjustedDateTime = internalDateTime.AddDays(1);
55+
}
56+
57+
else if (internalDateTime < Excel1900LeapMaxDate)
58+
{
59+
AdjustDaysPost = 1;
60+
AdjustedDateTime = internalDateTime;
61+
}
62+
else
63+
{
64+
AdjustDaysPost = 0;
65+
AdjustedDateTime = internalDateTime;
66+
}
67+
}
68+
}
69+
70+
static DateTime Excel1900LeapMinDate = new DateTime(1900, 2, 28);
71+
static DateTime Excel1900LeapMaxDate = new DateTime(1900, 3, 1);
72+
static DateTime Excel1900ZeroethMinDate = new DateTime(1899, 12, 30);
73+
static DateTime Excel1900ZeroethMaxDate = new DateTime(1899, 12, 31);
74+
75+
/// <summary>
76+
/// Wraps a regular .NET datetime.
77+
/// </summary>
78+
/// <param name="value"></param>
79+
public ExcelDateTime(DateTime value)
80+
{
81+
AdjustedDateTime = value;
82+
AdjustDaysPost = 0;
83+
}
84+
85+
public int Year => AdjustedDateTime.Year;
86+
87+
public int Month => AdjustedDateTime.Month;
88+
89+
public int Day => AdjustedDateTime.Day + AdjustDaysPost;
90+
91+
public int Hour => AdjustedDateTime.Hour;
92+
93+
public int Minute => AdjustedDateTime.Minute;
94+
95+
public int Second => AdjustedDateTime.Second;
96+
97+
public int Millisecond => AdjustedDateTime.Millisecond;
98+
99+
public DayOfWeek DayOfWeek => AdjustedDateTime.DayOfWeek;
100+
101+
public string ToString(string numberFormat, CultureInfo culture)
102+
{
103+
return AdjustedDateTime.ToString(numberFormat, culture);
104+
}
105+
106+
public static bool TryConvert(object value, bool isDate1904, CultureInfo culture, out ExcelDateTime result)
107+
{
108+
if (value is double doubleValue)
109+
{
110+
result = new ExcelDateTime(doubleValue, isDate1904);
111+
return true;
112+
}
113+
if (value is int intValue)
114+
{
115+
result = new ExcelDateTime(intValue, isDate1904);
116+
return true;
117+
}
118+
if (value is short shortValue)
119+
{
120+
result = new ExcelDateTime(shortValue, isDate1904);
121+
return true;
122+
}
123+
else if (value is DateTime dateTimeValue)
124+
{
125+
result = new ExcelDateTime(dateTimeValue);
126+
return true;
127+
}
128+
129+
result = null;
130+
return false;
131+
}
132+
133+
// From DateTime class to enable OADate in PCL
134+
// Number of 100ns ticks per time unit
135+
private const long TicksPerMillisecond = 10000;
136+
private const long TicksPerSecond = TicksPerMillisecond * 1000;
137+
private const long TicksPerMinute = TicksPerSecond * 60;
138+
private const long TicksPerHour = TicksPerMinute * 60;
139+
private const long TicksPerDay = TicksPerHour * 24;
140+
141+
private const int MillisPerSecond = 1000;
142+
private const int MillisPerMinute = MillisPerSecond * 60;
143+
private const int MillisPerHour = MillisPerMinute * 60;
144+
private const int MillisPerDay = MillisPerHour * 24;
145+
146+
// Number of days in a non-leap year
147+
private const int DaysPerYear = 365;
148+
149+
// Number of days in 4 years
150+
private const int DaysPer4Years = DaysPerYear * 4 + 1;
151+
152+
// Number of days in 100 years
153+
private const int DaysPer100Years = DaysPer4Years * 25 - 1;
154+
155+
// Number of days in 400 years
156+
private const int DaysPer400Years = DaysPer100Years * 4 + 1;
157+
158+
// Number of days from 1/1/0001 to 12/30/1899
159+
private const int DaysTo1899 = DaysPer400Years * 4 + DaysPer100Years * 3 - 367;
160+
161+
private const long DoubleDateOffset = DaysTo1899 * TicksPerDay;
162+
163+
internal static long DoubleDateToTicks(double value)
164+
{
165+
long millis = (long)(value * MillisPerDay + (value >= 0 ? 0.5 : -0.5));
166+
167+
// 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
168+
// 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
169+
// This line below fixes up the millis in the negative case
170+
if (millis < 0)
171+
{
172+
millis -= millis % MillisPerDay * 2;
173+
}
174+
175+
millis += DoubleDateOffset / TicksPerMillisecond;
176+
return millis * TicksPerMillisecond;
177+
}
178+
}
179+
}

src/ExcelNumberFormat/ExcelNumberFormat.csproj

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
<PropertyGroup>
44
<TargetFrameworks>net20;netstandard1.0;netstandard2.0</TargetFrameworks>
5-
<VersionPrefix>1.0.11</VersionPrefix>
5+
<VersionPrefix>1.1.0</VersionPrefix>
66
<GenerateDocumentationFile>true</GenerateDocumentationFile>
77
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
88
<Description>.NET library to parse ECMA-376 number format strings and format values like Excel and other spreadsheet softwares.</Description>

src/ExcelNumberFormat/Formatter.cs

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ namespace ExcelNumberFormat
77
{
88
static internal class Formatter
99
{
10-
static public string Format(object value, string formatString, CultureInfo culture)
10+
static public string Format(object value, string formatString, CultureInfo culture, bool isDate1904)
1111
{
1212
var format = new NumberFormat(formatString);
1313
if (!format.IsValid)
@@ -17,10 +17,10 @@ static public string Format(object value, string formatString, CultureInfo cultu
1717
if (section == null)
1818
return CompatibleConvert.ToString(value, culture);
1919

20-
return Format(value, section, culture);
20+
return Format(value, section, culture, isDate1904);
2121
}
2222

23-
static public string Format(object value, Section node, CultureInfo culture)
23+
static public string Format(object value, Section node, CultureInfo culture, bool isDate1904)
2424
{
2525
switch (node.Type)
2626
{
@@ -33,10 +33,25 @@ static public string Format(object value, Section node, CultureInfo culture)
3333
return FormatNumber(number, node.Number, culture);
3434

3535
case SectionType.Date:
36-
return FormatDate(Convert.ToDateTime(value, culture), node.GeneralTextDateDurationParts, culture);
36+
if (ExcelDateTime.TryConvert(value, isDate1904, culture, out var excelDateTime))
37+
{
38+
return FormatDate(excelDateTime, node.GeneralTextDateDurationParts, culture);
39+
}
40+
else
41+
{
42+
throw new FormatException("Unexpected date value");
43+
}
3744

3845
case SectionType.Duration:
39-
return FormatTimeSpan((TimeSpan)value, node.GeneralTextDateDurationParts, culture);
46+
if (value is TimeSpan ts)
47+
{
48+
return FormatTimeSpan(ts, node.GeneralTextDateDurationParts, culture);
49+
}
50+
else
51+
{
52+
var d = Convert.ToDouble(value);
53+
return FormatTimeSpan(TimeSpan.FromDays(d), node.GeneralTextDateDurationParts, culture);
54+
}
4055

4156
case SectionType.General:
4257
case SectionType.Text:
@@ -139,7 +154,7 @@ private static string FormatTimeSpan(TimeSpan timeSpan, List<string> tokens, Cul
139154
return result.ToString();
140155
}
141156

142-
private static string FormatDate(DateTime date, List<string> tokens, CultureInfo culture)
157+
private static string FormatDate(ExcelDateTime date, List<string> tokens, CultureInfo culture)
143158
{
144159
var containsAmPm = ContainsAmPm(tokens);
145160

@@ -226,7 +241,7 @@ private static string FormatDate(DateTime date, List<string> tokens, CultureInfo
226241
}
227242
else if (token.StartsWith("g", StringComparison.OrdinalIgnoreCase))
228243
{
229-
var era = culture.DateTimeFormat.Calendar.GetEra(date);
244+
var era = culture.DateTimeFormat.Calendar.GetEra(date.AdjustedDateTime);
230245
var digits = token.Length;
231246
if (digits < 3)
232247
{

src/ExcelNumberFormat/NumberFormat.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,16 +59,17 @@ public NumberFormat(string formatString)
5959
/// </summary>
6060
/// <param name="value">The value to format.</param>
6161
/// <param name="culture">The culture to use for formatting.</param>
62+
/// <param name="isDate1904">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.</param>
6263
/// <returns>The formatted string.</returns>
63-
public string Format(object value, CultureInfo culture)
64+
public string Format(object value, CultureInfo culture, bool isDate1904 = false)
6465
{
6566
var section = Evaluator.GetSection(Sections, value);
6667
if (section == null)
6768
return CompatibleConvert.ToString(value, culture);
6869

6970
try
7071
{
71-
return Formatter.Format(value, section, culture);
72+
return Formatter.Format(value, section, culture, isDate1904);
7273
}
7374
catch (InvalidCastException)
7475
{

test/ExcelNumberFormat.Tests/Class1.cs

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,11 @@ namespace ExcelNumberFormat.Tests
1414
public class Class1
1515
{
1616

17-
string Format(object value, string formatString, CultureInfo culture)
17+
string Format(object value, string formatString, CultureInfo culture, bool isDate1904 = false)
1818
{
1919
var format = new NumberFormat(formatString);
2020
if (format.IsValid)
21-
return format.Format(value, culture);
21+
return format.Format(value, culture, isDate1904);
2222

2323
return null;
2424
}
@@ -145,6 +145,37 @@ public void TestDate()
145145
Test(new DateTime(2020, 1, 1, 12, 35, 55), "m/d/yyyy\\ hh:mm:ss AM/PM;@", "1/1/2020 12:35:55 PM");
146146
}
147147

148+
[TestMethod]
149+
public void TestNumericDate1900()
150+
{
151+
// Test("0", "dd/mm/yyyy", "00/01/1900"); // not work: strings are always formatted as text using the third section
152+
Test(0, "dd/mm/yyyy", "00/01/1900");
153+
Test(0d, "dd/mm/yyyy", "00/01/1900");
154+
Test((short)0, "dd/mm/yyyy", "00/01/1900");
155+
Test(1, "dd/mm/yyyy", "01/01/1900");
156+
Test(60, "dd/mm/yyyy", "29/02/1900");
157+
Test(61, "dd/mm/yyyy", "01/03/1900");
158+
}
159+
160+
[TestMethod]
161+
public void TestNumericDate1904()
162+
{
163+
Test(0, "dd/mm/yyyy", "01/01/1904", true);
164+
Test(0d, "dd/mm/yyyy", "01/01/1904", true);
165+
Test((short)0, "dd/mm/yyyy", "01/01/1904", true);
166+
Test(1, "dd/mm/yyyy", "02/01/1904", true);
167+
Test(60, "dd/mm/yyyy", "01/03/1904", true);
168+
Test(61, "dd/mm/yyyy", "02/03/1904", true);
169+
}
170+
171+
[TestMethod]
172+
public void TestNumericDuration()
173+
{
174+
Test(0, "[hh]:mm", "00:00");
175+
Test(1, "[hh]:mm", "24:00");
176+
Test(1.5, "[hh]:mm", "36:00");
177+
}
178+
148179
[TestMethod]
149180
public void TestTimeSpan()
150181
{
@@ -162,9 +193,9 @@ public void TestTimeSpan()
162193
Test(new TimeSpan(0, -2, -31, -44, -500), "[hh]:mm:ss.000", "-02:31:44.500");
163194
}
164195

165-
void Test(object value, string format, string expected)
196+
void Test(object value, string format, string expected, bool isDate1904 = false)
166197
{
167-
var result = Format(value, format, CultureInfo.InvariantCulture);
198+
var result = Format(value, format, CultureInfo.InvariantCulture, isDate1904);
168199
Assert.AreEqual(expected, result);
169200
}
170201

0 commit comments

Comments
 (0)