diff --git a/src/ExcelDataReader.Tests/ExcelOpenXmlReaderTest.cs b/src/ExcelDataReader.Tests/ExcelOpenXmlReaderTest.cs
index e029c3a5..e87252ab 100644
--- a/src/ExcelDataReader.Tests/ExcelOpenXmlReaderTest.cs
+++ b/src/ExcelDataReader.Tests/ExcelOpenXmlReaderTest.cs
@@ -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);
diff --git a/src/ExcelDataReader.Tests/FormatReaderTest.cs b/src/ExcelDataReader.Tests/FormatReaderTest.cs
index e5463f5c..6a333ab8 100644
--- a/src/ExcelDataReader.Tests/FormatReaderTest.cs
+++ b/src/ExcelDataReader.Tests/FormatReaderTest.cs
@@ -1,4 +1,4 @@
-using ExcelDataReader.Core.NumberFormat;
+using ExcelDataReader.Core.NumberFormat;
namespace ExcelDataReader.Tests;
@@ -487,4 +487,28 @@ static void TestValid(string format)
Assert.That(to.IsValid, Is.True, $"Invalid format: {format}");
}
}
-}
+
+ ///
+ /// 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.
+ ///
+ [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;
+ }
+}
\ No newline at end of file
diff --git a/src/ExcelDataReader/Core/BuiltinNumberFormat.cs b/src/ExcelDataReader/Core/BuiltinNumberFormat.cs
index fc5ff3c1..788982b0 100644
--- a/src/ExcelDataReader/Core/BuiltinNumberFormat.cs
+++ b/src/ExcelDataReader/Core/BuiltinNumberFormat.cs
@@ -1,5 +1,7 @@
#nullable enable
+using System.Globalization;
+using System.Text;
using ExcelDataReader.Core.NumberFormat;
namespace ExcelDataReader.Core;
@@ -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();
+ }
}
diff --git a/src/ExcelDataReader/Core/CommonWorkbook.cs b/src/ExcelDataReader/Core/CommonWorkbook.cs
index 619a1dcb..c750d9be 100644
--- a/src/ExcelDataReader/Core/CommonWorkbook.cs
+++ b/src/ExcelDataReader/Core/CommonWorkbook.cs
@@ -1,4 +1,7 @@
-using ExcelDataReader.Core.NumberFormat;
+#nullable enable
+
+using System.Globalization;
+using ExcelDataReader.Core.NumberFormat;
namespace ExcelDataReader.Core;
@@ -23,6 +26,12 @@ internal class CommonWorkbook
///
public List CellStyleExtendedFormats { get; } = [];
+ ///
+ /// Gets or sets the culture to use for locale-dependent built-in number format indices.
+ /// When null (the default), hardcoded format strings are used.
+ ///
+ public CultureInfo? Culture { get; set; }
+
private NumberFormatString GeneralNumberFormat { get; } = new("General");
public ExtendedFormat GetEffectiveCellStyle(int xfIndex, int numberFormatFromCell)
@@ -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;
diff --git a/src/ExcelDataReader/ExcelBinaryReader.cs b/src/ExcelDataReader/ExcelBinaryReader.cs
index 58fe1258..f19e50bf 100644
--- a/src/ExcelDataReader/ExcelBinaryReader.cs
+++ b/src/ExcelDataReader/ExcelBinaryReader.cs
@@ -1,3 +1,4 @@
+using System.Globalization;
using System.Text;
using ExcelDataReader.Core.BinaryFormat;
@@ -5,9 +6,10 @@ namespace ExcelDataReader;
internal sealed class ExcelBinaryReader : ExcelDataReader
{
- 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();
diff --git a/src/ExcelDataReader/ExcelOpenXmlReader.cs b/src/ExcelDataReader/ExcelOpenXmlReader.cs
index 944aad56..fbe9c835 100644
--- a/src/ExcelDataReader/ExcelOpenXmlReader.cs
+++ b/src/ExcelDataReader/ExcelOpenXmlReader.cs
@@ -1,13 +1,15 @@
+using System.Globalization;
using ExcelDataReader.Core.OpenXmlFormat;
namespace ExcelDataReader;
internal sealed class ExcelOpenXmlReader : ExcelDataReader
{
- 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();
diff --git a/src/ExcelDataReader/ExcelReaderConfiguration.cs b/src/ExcelDataReader/ExcelReaderConfiguration.cs
index 78b4a560..256a5fdf 100644
--- a/src/ExcelDataReader/ExcelReaderConfiguration.cs
+++ b/src/ExcelDataReader/ExcelReaderConfiguration.cs
@@ -1,4 +1,5 @@
-using System.Text;
+using System.Globalization;
+using System.Text;
namespace ExcelDataReader;
@@ -44,4 +45,11 @@ public class ExcelReaderConfiguration
/// Default: 0 - analyzes the entire file (CSV only, has no effect on other formats).
///
public int AnalyzeInitialCsvRows { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public CultureInfo Culture { get; set; }
}
\ No newline at end of file
diff --git a/src/ExcelDataReader/ExcelReaderFactory.cs b/src/ExcelDataReader/ExcelReaderFactory.cs
index a1051283..e1fe49ed 100644
--- a/src/ExcelDataReader/ExcelReaderFactory.cs
+++ b/src/ExcelDataReader/ExcelReaderFactory.cs
@@ -42,12 +42,12 @@ public static IExcelDataReader CreateReader(Stream fileStream, ExcelReaderConfig
var document = new CompoundDocument(fileStream);
if (TryGetWorkbook(fileStream, document, out var stream))
{
- return new ExcelBinaryReader(stream, configuration.Password, configuration.FallbackEncoding);
+ return new ExcelBinaryReader(stream, configuration.Password, configuration.FallbackEncoding, configuration.Culture);
}
if (TryGetEncryptedPackage(fileStream, document, configuration.Password, out stream))
{
- return new ExcelOpenXmlReader(stream);
+ return new ExcelOpenXmlReader(stream, configuration.Culture);
}
throw new ExcelReaderException(Errors.ErrorStreamWorkbookNotFound);
@@ -55,13 +55,13 @@ public static IExcelDataReader CreateReader(Stream fileStream, ExcelReaderConfig
if (XlsWorkbook.IsRawBiffStream(probe))
{
- return new ExcelBinaryReader(fileStream, configuration.Password, configuration.FallbackEncoding);
+ return new ExcelBinaryReader(fileStream, configuration.Password, configuration.FallbackEncoding, configuration.Culture);
}
if (probe[0] == 0x50 && probe[1] == 0x4B)
{
// zip files start with 'PK'
- return new ExcelOpenXmlReader(fileStream);
+ return new ExcelOpenXmlReader(fileStream, configuration.Culture);
}
throw new HeaderException(Errors.ErrorHeaderSignature);
@@ -92,7 +92,7 @@ public static IExcelDataReader CreateBinaryReader(Stream fileStream, ExcelReader
var document = new CompoundDocument(fileStream);
if (TryGetWorkbook(fileStream, document, out var stream))
{
- return new ExcelBinaryReader(stream, configuration.Password, configuration.FallbackEncoding);
+ return new ExcelBinaryReader(stream, configuration.Password, configuration.FallbackEncoding, configuration.Culture);
}
else
{
@@ -101,7 +101,7 @@ public static IExcelDataReader CreateBinaryReader(Stream fileStream, ExcelReader
}
else if (XlsWorkbook.IsRawBiffStream(probe))
{
- return new ExcelBinaryReader(fileStream, configuration.Password, configuration.FallbackEncoding);
+ return new ExcelBinaryReader(fileStream, configuration.Password, configuration.FallbackEncoding, configuration.Culture);
}
else
{
@@ -135,7 +135,7 @@ public static IExcelDataReader CreateOpenXmlReader(Stream fileStream, ExcelReade
var document = new CompoundDocument(fileStream);
if (TryGetEncryptedPackage(fileStream, document, configuration.Password, out var stream))
{
- return new ExcelOpenXmlReader(stream);
+ return new ExcelOpenXmlReader(stream, configuration.Culture);
}
throw new ExcelReaderException(Errors.ErrorCompoundNoOpenXml);
@@ -144,7 +144,7 @@ public static IExcelDataReader CreateOpenXmlReader(Stream fileStream, ExcelReade
if (probe[0] == 0x50 && probe[1] == 0x4B)
{
// Zip files start with 'PK'
- return new ExcelOpenXmlReader(fileStream);
+ return new ExcelOpenXmlReader(fileStream, configuration.Culture);
}
throw new HeaderException(Errors.ErrorHeaderSignature);
diff --git a/src/TestData/Test_git_issue_461.xlsx b/src/TestData/Test_git_issue_461.xlsx
new file mode 100644
index 00000000..58a7537a
Binary files /dev/null and b/src/TestData/Test_git_issue_461.xlsx differ