Skip to content

Commit

Permalink
Added support for numeric dates and durations. Added a new isDate1904…
Browse files Browse the repository at this point in the history
…-flag in the Format method. Bump minor version.
  • Loading branch information
andersnm committed Oct 2, 2020
1 parent bdd0c3a commit 8e1369b
Show file tree
Hide file tree
Showing 6 changed files with 245 additions and 17 deletions.
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ Console.WriteLine(format.Format(1234.56, CultureInfo.InvariantCulture));
- Parses and formats most custom number formats as expected: decimal, percent, thousands, exponential, fraction, currency, date/time, duration, text.
- Supports multiple sections with conditions.
- Formats values with relevant constants from CultureInfo.
- Formats dates and durations using DateTime and TimeSpan values instead of numeric values like Excel.
- Supports DateTime, TimeSpan and numeric values for date and duration formats.
- Supports both 1900- and 1904-based numeric datetimes (Excel on Mac uses 1904-based dates).
- Targets net20 and netstandard1.0 for max compatibility.

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

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

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

## TODO/notes

Expand Down
179 changes: 179 additions & 0 deletions src/ExcelNumberFormat/ExcelDateTime.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
using System;
using System.Globalization;

namespace ExcelNumberFormat
{
/// <summary>
/// Similar to regular .NET DateTime, but also supports 0/1 1900 and 29/2 1900.
/// </summary>
internal class ExcelDateTime
{
/// <summary>
/// The closest .NET DateTime to the specified excel date.
/// </summary>
public DateTime AdjustedDateTime { get; }

/// <summary>
/// Number of days to adjust by in post.
/// </summary>
public int AdjustDaysPost { get; }

/// <summary>
/// Constructs a new ExcelDateTime from a numeric value.
/// </summary>
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);

/// <summary>
/// Wraps a regular .NET datetime.
/// </summary>
/// <param name="value"></param>
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;
}
}
}
2 changes: 1 addition & 1 deletion src/ExcelNumberFormat/ExcelNumberFormat.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

<PropertyGroup>
<TargetFrameworks>net20;netstandard1.0;netstandard2.0</TargetFrameworks>
<VersionPrefix>1.0.11</VersionPrefix>
<VersionPrefix>1.1.0</VersionPrefix>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<GeneratePackageOnBuild>true</GeneratePackageOnBuild>
<Description>.NET library to parse ECMA-376 number format strings and format values like Excel and other spreadsheet softwares.</Description>
Expand Down
29 changes: 22 additions & 7 deletions src/ExcelNumberFormat/Formatter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace ExcelNumberFormat
{
static internal class Formatter
{
static public string Format(object value, string formatString, CultureInfo culture)
static public string Format(object value, string formatString, CultureInfo culture, bool isDate1904)
{
var format = new NumberFormat(formatString);
if (!format.IsValid)
Expand All @@ -17,10 +17,10 @@ static public string Format(object value, string formatString, CultureInfo cultu
if (section == null)
return CompatibleConvert.ToString(value, culture);

return Format(value, section, culture);
return Format(value, section, culture, isDate1904);
}

static public string Format(object value, Section node, CultureInfo culture)
static public string Format(object value, Section node, CultureInfo culture, bool isDate1904)
{
switch (node.Type)
{
Expand All @@ -33,10 +33,25 @@ static public string Format(object value, Section node, CultureInfo culture)
return FormatNumber(number, node.Number, culture);

case SectionType.Date:
return FormatDate(Convert.ToDateTime(value, culture), node.GeneralTextDateDurationParts, culture);
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:
return FormatTimeSpan((TimeSpan)value, node.GeneralTextDateDurationParts, culture);
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:
Expand Down Expand Up @@ -139,7 +154,7 @@ private static string FormatTimeSpan(TimeSpan timeSpan, List<string> tokens, Cul
return result.ToString();
}

private static string FormatDate(DateTime date, List<string> tokens, CultureInfo culture)
private static string FormatDate(ExcelDateTime date, List<string> tokens, CultureInfo culture)
{
var containsAmPm = ContainsAmPm(tokens);

Expand Down Expand Up @@ -226,7 +241,7 @@ private static string FormatDate(DateTime date, List<string> tokens, CultureInfo
}
else if (token.StartsWith("g", StringComparison.OrdinalIgnoreCase))
{
var era = culture.DateTimeFormat.Calendar.GetEra(date);
var era = culture.DateTimeFormat.Calendar.GetEra(date.AdjustedDateTime);
var digits = token.Length;
if (digits < 3)
{
Expand Down
5 changes: 3 additions & 2 deletions src/ExcelNumberFormat/NumberFormat.cs
Original file line number Diff line number Diff line change
Expand Up @@ -59,16 +59,17 @@ public NumberFormat(string formatString)
/// </summary>
/// <param name="value">The value to format.</param>
/// <param name="culture">The culture to use for formatting.</param>
/// <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>
/// <returns>The formatted string.</returns>
public string Format(object value, CultureInfo culture)
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);
return Formatter.Format(value, section, culture, isDate1904);
}
catch (InvalidCastException)
{
Expand Down
39 changes: 35 additions & 4 deletions test/ExcelNumberFormat.Tests/Class1.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,11 @@ namespace ExcelNumberFormat.Tests
public class Class1
{

string Format(object value, string formatString, CultureInfo culture)
string Format(object value, string formatString, CultureInfo culture, bool isDate1904 = false)
{
var format = new NumberFormat(formatString);
if (format.IsValid)
return format.Format(value, culture);
return format.Format(value, culture, isDate1904);

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

[TestMethod]
public void TestNumericDate1900()
{
// Test("0", "dd/mm/yyyy", "00/01/1900"); // not work: strings are always formatted as text using the third section
Test(0, "dd/mm/yyyy", "00/01/1900");
Test(0d, "dd/mm/yyyy", "00/01/1900");
Test((short)0, "dd/mm/yyyy", "00/01/1900");
Test(1, "dd/mm/yyyy", "01/01/1900");
Test(60, "dd/mm/yyyy", "29/02/1900");
Test(61, "dd/mm/yyyy", "01/03/1900");
}

[TestMethod]
public void TestNumericDate1904()
{
Test(0, "dd/mm/yyyy", "01/01/1904", true);
Test(0d, "dd/mm/yyyy", "01/01/1904", true);
Test((short)0, "dd/mm/yyyy", "01/01/1904", true);
Test(1, "dd/mm/yyyy", "02/01/1904", true);
Test(60, "dd/mm/yyyy", "01/03/1904", true);
Test(61, "dd/mm/yyyy", "02/03/1904", true);
}

[TestMethod]
public void TestNumericDuration()
{
Test(0, "[hh]:mm", "00:00");
Test(1, "[hh]:mm", "24:00");
Test(1.5, "[hh]:mm", "36:00");
}

[TestMethod]
public void TestTimeSpan()
{
Expand All @@ -162,9 +193,9 @@ public void TestTimeSpan()
Test(new TimeSpan(0, -2, -31, -44, -500), "[hh]:mm:ss.000", "-02:31:44.500");
}

void Test(object value, string format, string expected)
void Test(object value, string format, string expected, bool isDate1904 = false)
{
var result = Format(value, format, CultureInfo.InvariantCulture);
var result = Format(value, format, CultureInfo.InvariantCulture, isDate1904);
Assert.AreEqual(expected, result);
}

Expand Down

0 comments on commit 8e1369b

Please sign in to comment.