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