Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions src/ExcelDataReader.Tests/ExcelOpenXmlReaderTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -552,6 +552,98 @@ public void GitIssue711_RowHeightParsing(string filename)
Assert.That(actualRowsHeights, Is.EqualTo(expectedRowHeights));
}

[Test]
public void GitIssue461_Format14WithEnUsCultureReturnsCorrectFormatString()
{
using var stream = Configuration.GetTestWorkbook("Test_git_issue_461.xlsx");
using var reader = ExcelReaderFactory.CreateOpenXmlReader(stream, new ExcelReaderConfiguration
{
Culture = new System.Globalization.CultureInfo("en-US"),
});

Assert.That(reader.Read(), Is.True);
Assert.That(reader.GetNumberFormatString(0), Is.EqualTo("m/d/yyyy"));
}

[Test]
public void GitIssue461_Format14WithEnGbCultureReturnsCorrectFormatString()
{
using var stream = Configuration.GetTestWorkbook("Test_git_issue_461.xlsx");
using var reader = ExcelReaderFactory.CreateOpenXmlReader(stream, new ExcelReaderConfiguration
{
Culture = new System.Globalization.CultureInfo("en-GB"),
});

Assert.That(reader.Read(), Is.True);
Assert.That(reader.GetNumberFormatString(0), Is.EqualTo("dd/mm/yyyy"));
}

[Test]
public void GitIssue461_Format14DefaultBehaviorUnchanged()
{
using var stream = Configuration.GetTestWorkbook("Test_git_issue_461.xlsx");
using var reader = ExcelReaderFactory.CreateOpenXmlReader(stream);

Assert.That(reader.Read(), Is.True);
Assert.That(reader.GetNumberFormatString(0), Is.EqualTo("d/m/yyyy"));
}

[Test]
public void GitIssue461_Formats15To17WithEnUsCultureUsesSlashSeparator()
{
using var stream = Configuration.GetTestWorkbook("Test_git_issue_461.xlsx");
using var reader = ExcelReaderFactory.CreateOpenXmlReader(stream, new ExcelReaderConfiguration
{
Culture = new System.Globalization.CultureInfo("en-US"),
});

Assert.That(reader.Read(), Is.True);
Assert.That(reader.GetNumberFormatString(1), Is.EqualTo("d/mmm/yy")); // format 15
Assert.That(reader.GetNumberFormatString(2), Is.EqualTo("d/mmm")); // format 16
Assert.That(reader.GetNumberFormatString(3), Is.EqualTo("mmm/yy")); // format 17
}

[Test]
public void GitIssue461_Formats15To17WithDeDeCultureUsesDotSeparator()
{
using var stream = Configuration.GetTestWorkbook("Test_git_issue_461.xlsx");
using var reader = ExcelReaderFactory.CreateOpenXmlReader(stream, new ExcelReaderConfiguration
{
Culture = new System.Globalization.CultureInfo("de-DE"),
});

Assert.That(reader.Read(), Is.True);
Assert.That(reader.GetNumberFormatString(1), Is.EqualTo("d.mmm.yy")); // format 15
Assert.That(reader.GetNumberFormatString(2), Is.EqualTo("d.mmm")); // format 16
Assert.That(reader.GetNumberFormatString(3), Is.EqualTo("mmm.yy")); // format 17
}

[Test]
public void GitIssue461_Formats15To17DefaultBehaviorUnchanged()
{
using var stream = Configuration.GetTestWorkbook("Test_git_issue_461.xlsx");
using var reader = ExcelReaderFactory.CreateOpenXmlReader(stream);

Assert.That(reader.Read(), Is.True);
Assert.That(reader.GetNumberFormatString(1), Is.EqualTo("d-mmm-yy")); // format 15
Assert.That(reader.GetNumberFormatString(2), Is.EqualTo("d-mmm")); // format 16
Assert.That(reader.GetNumberFormatString(3), Is.EqualTo("mmm-yy")); // format 17
}

[Test]
public void GitIssue461_CellValueIsDateTimeRegardlessOfCulture()
{
// Whether culture is set or not, cells with format 14 should be returned as DateTime
using var stream = Configuration.GetTestWorkbook("Test_git_issue_461.xlsx");
using var reader = ExcelReaderFactory.CreateOpenXmlReader(stream, new ExcelReaderConfiguration
{
Culture = new System.Globalization.CultureInfo("en-US"),
});

Assert.That(reader.Read(), Is.True);
Assert.That(reader.GetValue(0), Is.EqualTo(new DateTime(2023, 1, 1)));
}

protected override IExcelDataReader OpenReader(Stream stream, ExcelReaderConfiguration configuration = null)
=> ExcelReaderFactory.CreateOpenXmlReader(stream, configuration);

Expand Down
28 changes: 26 additions & 2 deletions src/ExcelDataReader.Tests/FormatReaderTest.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
using ExcelDataReader.Core.NumberFormat;
using ExcelDataReader.Core.NumberFormat;

namespace ExcelDataReader.Tests;

Expand Down Expand Up @@ -487,4 +487,28 @@ static void TestValid(string format)
Assert.That(to.IsValid, Is.True, $"Invalid format: {format}");
}
}
}

/// <summary>
/// Verifies that the format strings produced by the culture-specific conversion (for locales such
/// as en-US and en-GB) are themselves valid date/time format strings that ExcelDataReader will
/// recognise as date values rather than raw doubles.
/// </summary>
[Test]
public void GitIssue461_CultureDerivedFormatStringsAreValidDateFormats()
{
// Expected outputs of DatePatternToExcel / TimePatternToExcel for common locales
Assert.That(IsDateFormatString("m/d/yyyy"), Is.True, "en-US short date (format 14)");
Assert.That(IsDateFormatString("dd/mm/yyyy"), Is.True, "en-GB short date (format 14)");
Assert.That(IsDateFormatString("dd.mm.yyyy"), Is.True, "de-DE short date (format 14)");
Assert.That(IsDateFormatString("d/mmm/yy"), Is.True, "en-US format 15");
Assert.That(IsDateFormatString("d.mmm.yy"), Is.True, "de-DE format 15");
Assert.That(IsDateFormatString("d/mmm"), Is.True, "en-US format 16");
Assert.That(IsDateFormatString("mmm/yy"), Is.True, "en-US format 17");
Assert.That(IsDateFormatString("m/d/yyyy h:mm AM/PM"), Is.True, "en-US short date+time (format 22)");
Assert.That(IsDateFormatString("dd/mm/yyyy hh:mm"), Is.True, "en-GB short date+time (format 22)");
Assert.That(IsDateFormatString("dd.mm.yyyy hh:mm"), Is.True, "de-DE short date+time (format 22)");

static bool IsDateFormatString(string formatString) =>
new NumberFormatString(formatString).IsDateTimeFormat;
}
}
124 changes: 124 additions & 0 deletions src/ExcelDataReader/Core/BuiltinNumberFormat.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
#nullable enable

using System.Globalization;
using System.Text;
using ExcelDataReader.Core.NumberFormat;

namespace ExcelDataReader.Core;
Expand Down Expand Up @@ -55,4 +57,126 @@ internal static class BuiltinNumberFormat

return null;
}

public static NumberFormatString? GetBuiltinNumberFormat(int numFmtId, CultureInfo culture)
{
if (numFmtId == 14)
return new NumberFormatString(DatePatternToExcel(culture.DateTimeFormat.ShortDatePattern));

if (numFmtId == 15 || numFmtId == 16 || numFmtId == 17)
{
var sep = EscapeExcelLiteral(culture.DateTimeFormat.DateSeparator);
return numFmtId switch
{
15 => new NumberFormatString($"d{sep}mmm{sep}yy"),
16 => new NumberFormatString($"d{sep}mmm"),
_ => new NumberFormatString($"mmm{sep}yy"),
};
}

if (numFmtId == 22)
{
var date = DatePatternToExcel(culture.DateTimeFormat.ShortDatePattern);
var time = TimePatternToExcel(culture.DateTimeFormat.ShortTimePattern);
return new NumberFormatString($"{date} {time}");
}

if (Formats.TryGetValue(numFmtId, out var result))
return result;

return null;
}

// Wraps a date separator in double quotes if it contains any letter that would be
// misinterpreted as a date/time format specifier in an Excel format string.
// In practice, date separators are always punctuation (/, ., -) so quoting is rarely needed.
private static string EscapeExcelLiteral(string s)
{
foreach (char c in s)
{
if (char.IsLetter(c) || c == '"')
return $"\"{s}\"";
}

return s;
}

// Converts a .NET date pattern (e.g. "M/d/yyyy") to an Excel format string (e.g. "m/d/yyyy").
// .NET uses uppercase M for months; Excel uses lowercase m.
// Single-quoted literals 'text' are converted to double-quoted "text" without modifying their content.
// Backslash-escaped characters \x are converted to "x".
private static string DatePatternToExcel(string dotNetPattern)
{
var sb = new StringBuilder(dotNetPattern.Length + 4);
for (int i = 0; i < dotNetPattern.Length; i++)
{
char c = dotNetPattern[i];
if (c == '\'')
{
sb.Append('"');
i++;
while (i < dotNetPattern.Length && dotNetPattern[i] != '\'')
sb.Append(dotNetPattern[i++]);
sb.Append('"');
}
else if (c == '\\' && i + 1 < dotNetPattern.Length)
{
sb.Append('"').Append(dotNetPattern[++i]).Append('"');
}
else if (c == 'M')
{
sb.Append('m');
}
else
{
sb.Append(c);
}
}

return sb.ToString();
}

// Converts a .NET time pattern (e.g. "h:mm tt") to an Excel format string (e.g. "h:mm AM/PM").
// .NET uses uppercase H for 24-hour clock; Excel uses lowercase h.
// .NET uses tt/t for AM/PM designator; Excel uses AM/PM and A/P.
// Single-quoted literals and backslash escapes are handled as in DatePatternToExcel.
private static string TimePatternToExcel(string dotNetPattern)
{
var sb = new StringBuilder(dotNetPattern.Length + 8);
for (int i = 0; i < dotNetPattern.Length; i++)
{
char c = dotNetPattern[i];
if (c == '\'')
{
sb.Append('"');
i++;
while (i < dotNetPattern.Length && dotNetPattern[i] != '\'')
sb.Append(dotNetPattern[i++]);
sb.Append('"');
}
else if (c == '\\' && i + 1 < dotNetPattern.Length)
{
sb.Append('"').Append(dotNetPattern[++i]).Append('"');
}
else if (c == 'H')
{
sb.Append('h');
}
else if (c == 't' && i + 1 < dotNetPattern.Length && dotNetPattern[i + 1] == 't')
{
sb.Append("AM/PM");
i++;
}
else if (c == 't')
{
sb.Append("A/P");
}
else
{
sb.Append(c);
}
}

return sb.ToString();
}
}
17 changes: 15 additions & 2 deletions src/ExcelDataReader/Core/CommonWorkbook.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
using ExcelDataReader.Core.NumberFormat;
#nullable enable

using System.Globalization;
using ExcelDataReader.Core.NumberFormat;

namespace ExcelDataReader.Core;

Expand All @@ -23,6 +26,12 @@ internal class CommonWorkbook
/// </summary>
public List<ExtendedFormat> CellStyleExtendedFormats { get; } = [];

/// <summary>
/// Gets or sets the culture to use for locale-dependent built-in number format indices.
/// When null (the default), hardcoded format strings are used.
/// </summary>
public CultureInfo? Culture { get; set; }

private NumberFormatString GeneralNumberFormat { get; } = new("General");

public ExtendedFormat GetEffectiveCellStyle(int xfIndex, int numberFormatFromCell)
Expand Down Expand Up @@ -54,7 +63,11 @@ public NumberFormatString GetNumberFormatString(int numberFormatIndex)
return numberFormat;
}

numberFormat = BuiltinNumberFormat.GetBuiltinNumberFormat(numberFormatIndex);
numberFormat = Culture != null
? BuiltinNumberFormat.GetBuiltinNumberFormat(numberFormatIndex, Culture)
#pragma warning disable CA1304 // Intentional: null Culture means use hardcoded formats for backward compatibility
: BuiltinNumberFormat.GetBuiltinNumberFormat(numberFormatIndex);
#pragma warning restore CA1304
if (numberFormat != null)
{
return numberFormat;
Expand Down
4 changes: 3 additions & 1 deletion src/ExcelDataReader/ExcelBinaryReader.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
using System.Globalization;
using System.Text;
using ExcelDataReader.Core.BinaryFormat;

namespace ExcelDataReader;

internal sealed class ExcelBinaryReader : ExcelDataReader<XlsWorkbook, XlsWorksheet>
{
public ExcelBinaryReader(Stream stream, string password, Encoding fallbackEncoding)
public ExcelBinaryReader(Stream stream, string password, Encoding fallbackEncoding, CultureInfo culture = null)
{
Workbook = new XlsWorkbook(stream, password, fallbackEncoding);
Workbook.Culture = culture;

// By default, the data reader is positioned on the first result.
Reset();
Expand Down
4 changes: 3 additions & 1 deletion src/ExcelDataReader/ExcelOpenXmlReader.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
using System.Globalization;
using ExcelDataReader.Core.OpenXmlFormat;

namespace ExcelDataReader;

internal sealed class ExcelOpenXmlReader : ExcelDataReader<XlsxWorkbook, XlsxWorksheet>
{
public ExcelOpenXmlReader(Stream stream)
public ExcelOpenXmlReader(Stream stream, CultureInfo culture = null)
{
Document = new(stream);
Workbook = new XlsxWorkbook(Document);
Workbook.Culture = culture;

// By default, the data reader is positioned on the first result.
Reset();
Expand Down
10 changes: 9 additions & 1 deletion src/ExcelDataReader/ExcelReaderConfiguration.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Text;
using System.Globalization;
using System.Text;

namespace ExcelDataReader;

Expand Down Expand Up @@ -44,4 +45,11 @@ public class ExcelReaderConfiguration
/// Default: 0 - analyzes the entire file (CSV only, has no effect on other formats).
/// </summary>
public int AnalyzeInitialCsvRows { get; set; }

/// <summary>
/// Gets or sets the culture to use when mapping locale-dependent built-in number format indices
/// to format strings. Affects indices 14 (short date) and 22 (short date and time).
/// When null (the default), hardcoded format strings are used for backward compatibility.
/// </summary>
public CultureInfo Culture { get; set; }
}
Loading