From 474630ec478bde80b2abc95d9931cd1db99d1c19 Mon Sep 17 00:00:00 2001 From: Johan Appelgren Date: Sun, 21 Sep 2025 21:00:45 +0200 Subject: [PATCH 01/19] Bump version --- Directory.build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.build.props b/Directory.build.props index d79eee79..4e52fd30 100644 --- a/Directory.build.props +++ b/Directory.build.props @@ -9,7 +9,7 @@ - 3.8.0 + 3.9.0 net462;netstandard2.0;netstandard2.1;net8.0 latest-recommended ..\ExcelDataReader.snk From 6f8cd46e5a6981e8c0854549680b873bc297ee60 Mon Sep 17 00:00:00 2001 From: Johan Appelgren Date: Sun, 5 Oct 2025 12:34:37 +0200 Subject: [PATCH 02/19] Fix IsAotCompatible for net 8.0 target --- Directory.build.props | 2 +- src/ExcelDataReader.DataSet/packages.lock.json | 6 ++++++ src/ExcelDataReader/ExcelDataReader.cs | 6 ++++++ src/ExcelDataReader/packages.lock.json | 6 ++++++ 4 files changed, 19 insertions(+), 1 deletion(-) diff --git a/Directory.build.props b/Directory.build.props index 4e52fd30..9ed01bc9 100644 --- a/Directory.build.props +++ b/Directory.build.props @@ -24,7 +24,7 @@ $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb MIT README.md - true + true diff --git a/src/ExcelDataReader.DataSet/packages.lock.json b/src/ExcelDataReader.DataSet/packages.lock.json index 684cac8a..e9c32a6b 100644 --- a/src/ExcelDataReader.DataSet/packages.lock.json +++ b/src/ExcelDataReader.DataSet/packages.lock.json @@ -174,6 +174,12 @@ } }, "net8.0": { + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[8.0.20, )", + "resolved": "8.0.20", + "contentHash": "Rhcto2AjGvTO62+/VTmBpumBOmqIGp7nYEbTbmEXkCq4yPGxV8whju3/HsIA/bKyo2+DggaYk5+/8sxb1AbPTw==" + }, "Microsoft.SourceLink.GitHub": { "type": "Direct", "requested": "[8.0.0, )", diff --git a/src/ExcelDataReader/ExcelDataReader.cs b/src/ExcelDataReader/ExcelDataReader.cs index 121e83f5..d6e07335 100644 --- a/src/ExcelDataReader/ExcelDataReader.cs +++ b/src/ExcelDataReader/ExcelDataReader.cs @@ -1,4 +1,7 @@ using System.Data; +#if NET8_0_OR_GREATER +using System.Diagnostics.CodeAnalysis; +#endif using ExcelDataReader.Core; namespace ExcelDataReader; @@ -82,6 +85,9 @@ public long GetChars(int i, long fieldoffset, char[] buffer, int bufferoffset, i public double GetDouble(int i) => (double)GetValue(i); +#if NET8_0_OR_GREATER + [return: DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties | DynamicallyAccessedMemberTypes.PublicFields)] +#endif public Type GetFieldType(int i) => GetValue(i)?.GetType(); public float GetFloat(int i) => (float)GetValue(i); diff --git a/src/ExcelDataReader/packages.lock.json b/src/ExcelDataReader/packages.lock.json index 36e27863..ff38ed8f 100644 --- a/src/ExcelDataReader/packages.lock.json +++ b/src/ExcelDataReader/packages.lock.json @@ -162,6 +162,12 @@ } }, "net8.0": { + "Microsoft.NET.ILLink.Tasks": { + "type": "Direct", + "requested": "[8.0.20, )", + "resolved": "8.0.20", + "contentHash": "Rhcto2AjGvTO62+/VTmBpumBOmqIGp7nYEbTbmEXkCq4yPGxV8whju3/HsIA/bKyo2+DggaYk5+/8sxb1AbPTw==" + }, "Microsoft.SourceLink.GitHub": { "type": "Direct", "requested": "[8.0.0, )", From d0e231c363941ddfc6d7f41c6eaf5b2b04252c8d Mon Sep 17 00:00:00 2001 From: Johan Appelgren Date: Sun, 5 Oct 2025 12:38:26 +0200 Subject: [PATCH 03/19] Set ContinuousIntegrationBuild for ci builds --- Directory.build.props | 1 + 1 file changed, 1 insertion(+) diff --git a/Directory.build.props b/Directory.build.props index 9ed01bc9..c27eb35e 100644 --- a/Directory.build.props +++ b/Directory.build.props @@ -25,6 +25,7 @@ MIT README.md true + true From bfecb79c702b5ab02ec2d6de6d1f1a8974b86c8b Mon Sep 17 00:00:00 2001 From: Johan Appelgren Date: Sun, 5 Oct 2025 12:39:02 +0200 Subject: [PATCH 04/19] Remove explicit Microsoft.SourceLink.GitHub reference, it is incldued by default starting with .NET 8.0 SDK --- Directory.build.props | 1 - .../packages.lock.json | 80 ------------------- src/ExcelDataReader.Sample/packages.lock.json | 44 +--------- src/ExcelDataReader.Tests/packages.lock.json | 44 +--------- src/ExcelDataReader/packages.lock.json | 80 ------------------- 5 files changed, 4 insertions(+), 245 deletions(-) diff --git a/Directory.build.props b/Directory.build.props index c27eb35e..2eeae924 100644 --- a/Directory.build.props +++ b/Directory.build.props @@ -50,7 +50,6 @@ - diff --git a/src/ExcelDataReader.DataSet/packages.lock.json b/src/ExcelDataReader.DataSet/packages.lock.json index e9c32a6b..eb584325 100644 --- a/src/ExcelDataReader.DataSet/packages.lock.json +++ b/src/ExcelDataReader.DataSet/packages.lock.json @@ -11,16 +11,6 @@ "Microsoft.NETFramework.ReferenceAssemblies.net462": "1.0.3" } }, - "Microsoft.SourceLink.GitHub": { - "type": "Direct", - "requested": "[8.0.0, )", - "resolved": "8.0.0", - "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "8.0.0", - "Microsoft.SourceLink.Common": "8.0.0" - } - }, "PolySharp": { "type": "Direct", "requested": "[1.15.0, )", @@ -42,21 +32,11 @@ "resolved": "4.5.0", "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ==" }, - "Microsoft.Build.Tasks.Git": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" - }, "Microsoft.NETFramework.ReferenceAssemblies.net462": { "type": "Transitive", "resolved": "1.0.3", "contentHash": "IzAV30z22ESCeQfxP29oVf4qEo8fBGXLXSU6oacv/9Iqe6PzgHDKCaWfwMBak7bSJQM0F5boXWoZS+kChztRIQ==" }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" - }, "StyleCop.Analyzers.Unstable": { "type": "Transitive", "resolved": "1.2.0.556", @@ -70,16 +50,6 @@ } }, ".NETStandard,Version=v2.0": { - "Microsoft.SourceLink.GitHub": { - "type": "Direct", - "requested": "[8.0.0, )", - "resolved": "8.0.0", - "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "8.0.0", - "Microsoft.SourceLink.Common": "8.0.0" - } - }, "NETStandard.Library": { "type": "Direct", "requested": "[2.0.3, )", @@ -104,21 +74,11 @@ "StyleCop.Analyzers.Unstable": "1.2.0.556" } }, - "Microsoft.Build.Tasks.Git": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" - }, "Microsoft.NETCore.Platforms": { "type": "Transitive", "resolved": "1.1.0", "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" - }, "StyleCop.Analyzers.Unstable": { "type": "Transitive", "resolved": "1.2.0.556", @@ -129,16 +89,6 @@ } }, ".NETStandard,Version=v2.1": { - "Microsoft.SourceLink.GitHub": { - "type": "Direct", - "requested": "[8.0.0, )", - "resolved": "8.0.0", - "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "8.0.0", - "Microsoft.SourceLink.Common": "8.0.0" - } - }, "PolySharp": { "type": "Direct", "requested": "[1.15.0, )", @@ -154,16 +104,6 @@ "StyleCop.Analyzers.Unstable": "1.2.0.556" } }, - "Microsoft.Build.Tasks.Git": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" - }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" - }, "StyleCop.Analyzers.Unstable": { "type": "Transitive", "resolved": "1.2.0.556", @@ -180,16 +120,6 @@ "resolved": "8.0.20", "contentHash": "Rhcto2AjGvTO62+/VTmBpumBOmqIGp7nYEbTbmEXkCq4yPGxV8whju3/HsIA/bKyo2+DggaYk5+/8sxb1AbPTw==" }, - "Microsoft.SourceLink.GitHub": { - "type": "Direct", - "requested": "[8.0.0, )", - "resolved": "8.0.0", - "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "8.0.0", - "Microsoft.SourceLink.Common": "8.0.0" - } - }, "PolySharp": { "type": "Direct", "requested": "[1.15.0, )", @@ -205,16 +135,6 @@ "StyleCop.Analyzers.Unstable": "1.2.0.556" } }, - "Microsoft.Build.Tasks.Git": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" - }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" - }, "StyleCop.Analyzers.Unstable": { "type": "Transitive", "resolved": "1.2.0.556", diff --git a/src/ExcelDataReader.Sample/packages.lock.json b/src/ExcelDataReader.Sample/packages.lock.json index 751d7d61..f570cde6 100644 --- a/src/ExcelDataReader.Sample/packages.lock.json +++ b/src/ExcelDataReader.Sample/packages.lock.json @@ -11,16 +11,6 @@ "Microsoft.NETFramework.ReferenceAssemblies.net462": "1.0.3" } }, - "Microsoft.SourceLink.GitHub": { - "type": "Direct", - "requested": "[8.0.0, )", - "resolved": "8.0.0", - "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "8.0.0", - "Microsoft.SourceLink.Common": "8.0.0" - } - }, "StyleCop.Analyzers": { "type": "Direct", "requested": "[1.2.0-beta.556, )", @@ -30,21 +20,11 @@ "StyleCop.Analyzers.Unstable": "1.2.0.556" } }, - "Microsoft.Build.Tasks.Git": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" - }, "Microsoft.NETFramework.ReferenceAssemblies.net462": { "type": "Transitive", "resolved": "1.0.3", "contentHash": "IzAV30z22ESCeQfxP29oVf4qEo8fBGXLXSU6oacv/9Iqe6PzgHDKCaWfwMBak7bSJQM0F5boXWoZS+kChztRIQ==" }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" - }, "StyleCop.Analyzers.Unstable": { "type": "Transitive", "resolved": "1.2.0.556", @@ -64,23 +44,13 @@ "exceldatareader.dataset": { "type": "Project", "dependencies": { - "ExcelDataReader": "[3.8.0, )", + "ExcelDataReader": "[3.9.0, )", "System.ValueTuple": "[4.5.0, )" } } }, ".NETFramework,Version=v4.6.2/win-x86": {}, "net8.0-windows7.0": { - "Microsoft.SourceLink.GitHub": { - "type": "Direct", - "requested": "[8.0.0, )", - "resolved": "8.0.0", - "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "8.0.0", - "Microsoft.SourceLink.Common": "8.0.0" - } - }, "StyleCop.Analyzers": { "type": "Direct", "requested": "[1.2.0-beta.556, )", @@ -90,16 +60,6 @@ "StyleCop.Analyzers.Unstable": "1.2.0.556" } }, - "Microsoft.Build.Tasks.Git": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" - }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" - }, "StyleCop.Analyzers.Unstable": { "type": "Transitive", "resolved": "1.2.0.556", @@ -111,7 +71,7 @@ "exceldatareader.dataset": { "type": "Project", "dependencies": { - "ExcelDataReader": "[3.8.0, )" + "ExcelDataReader": "[3.9.0, )" } } }, diff --git a/src/ExcelDataReader.Tests/packages.lock.json b/src/ExcelDataReader.Tests/packages.lock.json index aa77734c..40662d09 100644 --- a/src/ExcelDataReader.Tests/packages.lock.json +++ b/src/ExcelDataReader.Tests/packages.lock.json @@ -20,16 +20,6 @@ "Microsoft.NETFramework.ReferenceAssemblies.net462": "1.0.3" } }, - "Microsoft.SourceLink.GitHub": { - "type": "Direct", - "requested": "[8.0.0, )", - "resolved": "8.0.0", - "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "8.0.0", - "Microsoft.SourceLink.Common": "8.0.0" - } - }, "NUnit": { "type": "Direct", "requested": "[4.4.0, )", @@ -73,11 +63,6 @@ "System.Diagnostics.DiagnosticSource": "5.0.0" } }, - "Microsoft.Build.Tasks.Git": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" - }, "Microsoft.CodeCoverage": { "type": "Transitive", "resolved": "17.14.1", @@ -88,11 +73,6 @@ "resolved": "1.0.3", "contentHash": "IzAV30z22ESCeQfxP29oVf4qEo8fBGXLXSU6oacv/9Iqe6PzgHDKCaWfwMBak7bSJQM0F5boXWoZS+kChztRIQ==" }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" - }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "1.7.3", @@ -222,7 +202,7 @@ "exceldatareader.dataset": { "type": "Project", "dependencies": { - "ExcelDataReader": "[3.8.0, )", + "ExcelDataReader": "[3.9.0, )", "System.ValueTuple": "[4.5.0, )" } } @@ -238,16 +218,6 @@ "Microsoft.TestPlatform.TestHost": "17.14.1" } }, - "Microsoft.SourceLink.GitHub": { - "type": "Direct", - "requested": "[8.0.0, )", - "resolved": "8.0.0", - "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "8.0.0", - "Microsoft.SourceLink.Common": "8.0.0" - } - }, "NUnit": { "type": "Direct", "requested": "[4.4.0, )", @@ -287,21 +257,11 @@ "System.Diagnostics.DiagnosticSource": "5.0.0" } }, - "Microsoft.Build.Tasks.Git": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" - }, "Microsoft.CodeCoverage": { "type": "Transitive", "resolved": "17.14.1", "contentHash": "pmTrhfFIoplzFVbhVwUquT+77CbGH+h4/3mBpdmIlYtBi9nAB+kKI6dN3A/nV4DFi3wLLx/BlHIPK+MkbQ6Tpg==" }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" - }, "Microsoft.Testing.Extensions.Telemetry": { "type": "Transitive", "resolved": "1.7.3", @@ -400,7 +360,7 @@ "exceldatareader.dataset": { "type": "Project", "dependencies": { - "ExcelDataReader": "[3.8.0, )" + "ExcelDataReader": "[3.9.0, )" } } } diff --git a/src/ExcelDataReader/packages.lock.json b/src/ExcelDataReader/packages.lock.json index ff38ed8f..16c72787 100644 --- a/src/ExcelDataReader/packages.lock.json +++ b/src/ExcelDataReader/packages.lock.json @@ -11,16 +11,6 @@ "Microsoft.NETFramework.ReferenceAssemblies.net462": "1.0.3" } }, - "Microsoft.SourceLink.GitHub": { - "type": "Direct", - "requested": "[8.0.0, )", - "resolved": "8.0.0", - "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "8.0.0", - "Microsoft.SourceLink.Common": "8.0.0" - } - }, "PolySharp": { "type": "Direct", "requested": "[1.15.0, )", @@ -42,21 +32,11 @@ "resolved": "4.5.0", "contentHash": "okurQJO6NRE/apDIP23ajJ0hpiNmJ+f0BwOlB/cSqTLQlw5upkf+5+96+iG2Jw40G1fCVCyPz/FhIABUjMR+RQ==" }, - "Microsoft.Build.Tasks.Git": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" - }, "Microsoft.NETFramework.ReferenceAssemblies.net462": { "type": "Transitive", "resolved": "1.0.3", "contentHash": "IzAV30z22ESCeQfxP29oVf4qEo8fBGXLXSU6oacv/9Iqe6PzgHDKCaWfwMBak7bSJQM0F5boXWoZS+kChztRIQ==" }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" - }, "StyleCop.Analyzers.Unstable": { "type": "Transitive", "resolved": "1.2.0.556", @@ -64,16 +44,6 @@ } }, ".NETStandard,Version=v2.0": { - "Microsoft.SourceLink.GitHub": { - "type": "Direct", - "requested": "[8.0.0, )", - "resolved": "8.0.0", - "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "8.0.0", - "Microsoft.SourceLink.Common": "8.0.0" - } - }, "NETStandard.Library": { "type": "Direct", "requested": "[2.0.3, )", @@ -98,21 +68,11 @@ "StyleCop.Analyzers.Unstable": "1.2.0.556" } }, - "Microsoft.Build.Tasks.Git": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" - }, "Microsoft.NETCore.Platforms": { "type": "Transitive", "resolved": "1.1.0", "contentHash": "kz0PEW2lhqygehI/d6XsPCQzD7ff7gUJaVGPVETX611eadGsA3A877GdSlU0LRVMCTH/+P3o2iDTak+S08V2+A==" }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" - }, "StyleCop.Analyzers.Unstable": { "type": "Transitive", "resolved": "1.2.0.556", @@ -120,16 +80,6 @@ } }, ".NETStandard,Version=v2.1": { - "Microsoft.SourceLink.GitHub": { - "type": "Direct", - "requested": "[8.0.0, )", - "resolved": "8.0.0", - "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "8.0.0", - "Microsoft.SourceLink.Common": "8.0.0" - } - }, "PolySharp": { "type": "Direct", "requested": "[1.15.0, )", @@ -145,16 +95,6 @@ "StyleCop.Analyzers.Unstable": "1.2.0.556" } }, - "Microsoft.Build.Tasks.Git": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" - }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" - }, "StyleCop.Analyzers.Unstable": { "type": "Transitive", "resolved": "1.2.0.556", @@ -168,16 +108,6 @@ "resolved": "8.0.20", "contentHash": "Rhcto2AjGvTO62+/VTmBpumBOmqIGp7nYEbTbmEXkCq4yPGxV8whju3/HsIA/bKyo2+DggaYk5+/8sxb1AbPTw==" }, - "Microsoft.SourceLink.GitHub": { - "type": "Direct", - "requested": "[8.0.0, )", - "resolved": "8.0.0", - "contentHash": "G5q7OqtwIyGTkeIOAc3u2ZuV/kicQaec5EaRnc0pIeSnh9LUjj+PYQrJYBURvDt7twGl2PKA7nSN0kz1Zw5bnQ==", - "dependencies": { - "Microsoft.Build.Tasks.Git": "8.0.0", - "Microsoft.SourceLink.Common": "8.0.0" - } - }, "PolySharp": { "type": "Direct", "requested": "[1.15.0, )", @@ -193,16 +123,6 @@ "StyleCop.Analyzers.Unstable": "1.2.0.556" } }, - "Microsoft.Build.Tasks.Git": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "bZKfSIKJRXLTuSzLudMFte/8CempWjVamNUR5eHJizsy+iuOuO/k2gnh7W0dHJmYY0tBf+gUErfluCv5mySAOQ==" - }, - "Microsoft.SourceLink.Common": { - "type": "Transitive", - "resolved": "8.0.0", - "contentHash": "dk9JPxTCIevS75HyEQ0E4OVAFhB2N+V9ShCXf8Q6FkUQZDkgLI12y679Nym1YqsiSysuQskT7Z+6nUf3yab6Vw==" - }, "StyleCop.Analyzers.Unstable": { "type": "Transitive", "resolved": "1.2.0.556", From 250bb0178eee7a32d205397379557c82c1bfe837 Mon Sep 17 00:00:00 2001 From: Johan Appelgren Date: Sun, 5 Oct 2025 12:59:23 +0200 Subject: [PATCH 05/19] Enable determenistic builds --- Directory.build.props | 1 + 1 file changed, 1 insertion(+) diff --git a/Directory.build.props b/Directory.build.props index 2eeae924..089d42c6 100644 --- a/Directory.build.props +++ b/Directory.build.props @@ -26,6 +26,7 @@ README.md true true + true From 34539ecad299abc7b41f7f2a43102066ae7bc3f8 Mon Sep 17 00:00:00 2001 From: Hugh Bellamy Date: Fri, 31 Oct 2025 14:33:58 +0000 Subject: [PATCH 06/19] Fix build and tests on mac --- .../ExcelCsvReaderTest.cs | 38 +++++++++---------- .../ExcelDataReader.Tests.csproj | 3 +- .../ExcelOpenXmlStrictReaderTest.cs | 2 +- 3 files changed, 22 insertions(+), 21 deletions(-) diff --git a/src/ExcelDataReader.Tests/ExcelCsvReaderTest.cs b/src/ExcelDataReader.Tests/ExcelCsvReaderTest.cs index 23f51482..645510e2 100644 --- a/src/ExcelDataReader.Tests/ExcelCsvReaderTest.cs +++ b/src/ExcelDataReader.Tests/ExcelCsvReaderTest.cs @@ -10,7 +10,7 @@ public class ExcelCsvReaderTest [Test] public void CsvCommaInQuotes() { - using var excelReader = ExcelReaderFactory.CreateCsvReader(Configuration.GetTestWorkbook("csv\\comma_in_quotes.csv")); + using var excelReader = ExcelReaderFactory.CreateCsvReader(Configuration.GetTestWorkbook(Path.Combine("csv", "comma_in_quotes.csv"))); var ds = excelReader.AsDataSet(); Assert.That(ds.Tables[0].Rows[0][0], Is.EqualTo("first")); Assert.That(ds.Tables[0].Rows[0][1], Is.EqualTo("last")); @@ -28,7 +28,7 @@ public void CsvCommaInQuotes() [Test] public void CsvEscapedQuotes() { - using var excelReader = ExcelReaderFactory.CreateCsvReader(Configuration.GetTestWorkbook("csv\\escaped_quotes.csv")); + using var excelReader = ExcelReaderFactory.CreateCsvReader(Configuration.GetTestWorkbook(Path.Combine("csv", "escaped_quotes.csv"))); var ds = excelReader.AsDataSet(); Assert.That(ds.Tables[0].Rows[0][0], Is.EqualTo("a")); Assert.That(ds.Tables[0].Rows[0][1], Is.EqualTo("b")); @@ -140,7 +140,7 @@ static void TestNewlines(string newLine) [Test] public void CsvWhitespaceNull() { - using var excelReader = ExcelReaderFactory.CreateCsvReader(Configuration.GetTestWorkbook("csv\\simple_whitespace_null.csv")); + using var excelReader = ExcelReaderFactory.CreateCsvReader(Configuration.GetTestWorkbook(Path.Combine("csv", "simple_whitespace_null.csv"))); var ds = excelReader.AsDataSet(); Assert.That(ds.Tables[0].Rows[0][0], Is.EqualTo("a")); // ignore spaces Assert.That(ds.Tables[0].Rows[0][1], Is.EqualTo("\0b\0")); @@ -154,11 +154,11 @@ public void CsvWhitespaceNull() [Test] public void CsvEncoding() { - TestEncoding("csv\\utf8.csv", "ʤ"); - TestEncoding("csv\\utf8_bom.csv", "ʤ"); - TestEncoding("csv\\utf16le_bom.csv", "ʤ"); - TestEncoding("csv\\utf16be_bom.csv", "ʤ"); - TestEncoding("csv\\cp1252.csv", "æøå"); + TestEncoding(Path.Combine("csv", "utf8.csv"), "ʤ"); + TestEncoding(Path.Combine("csv", "utf8_bom.csv"), "ʤ"); + TestEncoding(Path.Combine("csv", "utf16le_bom.csv"), "ʤ"); + TestEncoding(Path.Combine("csv", "utf16be_bom.csv"), "ʤ"); + TestEncoding(Path.Combine("csv", "cp1252.csv"), "æøå"); static void TestEncoding(string workbook, string specialString) { @@ -186,7 +186,7 @@ public void CsvWrongEncoding() FallbackEncoding = Encoding.UTF8 }; - using (ExcelReaderFactory.CreateCsvReader(Configuration.GetTestWorkbook("csv\\cp1252.csv"), configuration)) + using (ExcelReaderFactory.CreateCsvReader(Configuration.GetTestWorkbook(Path.Combine("csv", "cp1252.csv")), configuration)) { } }); @@ -195,7 +195,7 @@ public void CsvWrongEncoding() [Test] public void CsvBigSheet() { - using var excelReader = ExcelReaderFactory.CreateCsvReader(Configuration.GetTestWorkbook("csv\\MOCK_DATA.csv")); + using var excelReader = ExcelReaderFactory.CreateCsvReader(Configuration.GetTestWorkbook(Path.Combine("csv", "MOCK_DATA.csv"))); var ds = excelReader.AsDataSet(); Assert.That(ds.Tables[0].Rows[0][0], Is.EqualTo("id")); Assert.That(ds.Tables[0].Rows[1000][5], Is.EqualTo("111.4.88.155")); @@ -249,7 +249,7 @@ public void TestNoSeparator(ExcelReaderConfiguration configuration) [Test] public void GitIssue323DoubleClose() { - using var reader = ExcelReaderFactory.CreateCsvReader(Configuration.GetTestWorkbook("csv\\MOCK_DATA.csv")); + using var reader = ExcelReaderFactory.CreateCsvReader(Configuration.GetTestWorkbook(Path.Combine("csv", "MOCK_DATA.csv"))); reader.Read(); reader.Close(); } @@ -257,7 +257,7 @@ public void GitIssue323DoubleClose() [Test] public void GitIssue333EanQuotes() { - using var reader = ExcelReaderFactory.CreateCsvReader(Configuration.GetTestWorkbook("csv\\ean.txt")); + using var reader = ExcelReaderFactory.CreateCsvReader(Configuration.GetTestWorkbook(Path.Combine("csv", "ean.txt"))); reader.Read(); Assert.That(reader.RowCount, Is.EqualTo(2)); Assert.That(reader.FieldCount, Is.EqualTo(24)); @@ -281,7 +281,7 @@ public void GitIssue351LastLineWithoutLineFeed() [Test] public void ColumnWidthsTest() { - using var reader = ExcelReaderFactory.CreateCsvReader(Configuration.GetTestWorkbook("csv\\column_widths_test.csv")); + using var reader = ExcelReaderFactory.CreateCsvReader(Configuration.GetTestWorkbook(Path.Combine("csv", "column_widths_test.csv"))); reader.Read(); Assert.That(reader.GetColumnWidth(0), Is.EqualTo(8.43)); Assert.That(reader.GetColumnWidth(1), Is.EqualTo(8.43)); @@ -304,7 +304,7 @@ public void CsvDisposed() { // Verify the file stream is closed and disposed by the reader { - var stream = Configuration.GetTestWorkbook("csv\\MOCK_DATA.csv"); + var stream = Configuration.GetTestWorkbook(Path.Combine("csv", "MOCK_DATA.csv")); using (IExcelDataReader excelReader = ExcelReaderFactory.CreateCsvReader(stream)) { _ = excelReader.AsDataSet(); @@ -319,7 +319,7 @@ public void CsvLeaveOpen() { // Verify the file stream is not disposed by the reader { - var stream = Configuration.GetTestWorkbook("csv\\MOCK_DATA.csv"); + var stream = Configuration.GetTestWorkbook(Path.Combine("csv", "MOCK_DATA.csv")); using (IExcelDataReader excelReader = ExcelReaderFactory.CreateCsvReader(stream, new ExcelReaderConfiguration() { LeaveOpen = true @@ -338,7 +338,7 @@ public void CsvLeaveOpen() public void CsvRowCountAnalyzeRowsThrows() { { - var stream = Configuration.GetTestWorkbook("csv\\MOCK_DATA.csv"); + var stream = Configuration.GetTestWorkbook(Path.Combine("csv", "MOCK_DATA.csv")); using IExcelDataReader reader = ExcelReaderFactory.CreateCsvReader(stream, new ExcelReaderConfiguration() { AnalyzeInitialCsvRows = 100 @@ -353,7 +353,7 @@ public void CsvRowCountAnalyzeRowsThrows() [Test] public void GitIssue578() { - var stream = Configuration.GetTestWorkbook(@"csv\Test_git_issue578.csv"); + var stream = Configuration.GetTestWorkbook(Path.Combine("csv", "Test_git_issue578.csv")); using IExcelDataReader excelReader = ExcelReaderFactory.CreateCsvReader(stream, new ExcelReaderConfiguration()); excelReader.Read(); var values = new object[excelReader.FieldCount]; @@ -467,7 +467,7 @@ public void GitIssue463() [Test] public void GitIssue642_ActiveSheet() { - using var reader = ExcelReaderFactory.CreateCsvReader(Configuration.GetTestWorkbook("csv\\MOCK_DATA.csv")); + using var reader = ExcelReaderFactory.CreateCsvReader(Configuration.GetTestWorkbook(Path.Combine("csv", "MOCK_DATA.csv"))); var dataSet = reader.AsDataSet(new ExcelDataSetConfiguration() { FilterSheet = (tableReader, sheetIndex) => tableReader.IsActiveSheet @@ -562,7 +562,7 @@ public void GitIssue566_ParseCsvWithoutTrimmingWhiteSpace() TrimWhiteSpace = false, }; - using var excelReader = ExcelReaderFactory.CreateCsvReader(Configuration.GetTestWorkbook("csv\\test_issue_566.csv"), keepSpaceConfig); + using var excelReader = ExcelReaderFactory.CreateCsvReader(Configuration.GetTestWorkbook(Path.Combine("csv", "test_issue_566.csv")), keepSpaceConfig); var ds = excelReader.AsDataSet(); Assert.That(ds.Tables[0].Rows[0][0], Is.EqualTo("c1")); diff --git a/src/ExcelDataReader.Tests/ExcelDataReader.Tests.csproj b/src/ExcelDataReader.Tests/ExcelDataReader.Tests.csproj index 4d442380..10862905 100644 --- a/src/ExcelDataReader.Tests/ExcelDataReader.Tests.csproj +++ b/src/ExcelDataReader.Tests/ExcelDataReader.Tests.csproj @@ -2,7 +2,8 @@ ExcelDataReader.Tests - net462;net8.0 + net8.0 + $(TargetFrameworks);net462 ExcelDataReader.Tests ExcelDataReader.Tests true diff --git a/src/ExcelDataReader.Tests/ExcelOpenXmlStrictReaderTest.cs b/src/ExcelDataReader.Tests/ExcelOpenXmlStrictReaderTest.cs index 22c45f44..565cec0f 100644 --- a/src/ExcelDataReader.Tests/ExcelOpenXmlStrictReaderTest.cs +++ b/src/ExcelDataReader.Tests/ExcelOpenXmlStrictReaderTest.cs @@ -29,6 +29,6 @@ protected override IExcelDataReader OpenReader(Stream stream, ExcelReaderConfigu protected override Stream OpenStream(string name) { - return Configuration.GetTestWorkbook("strict\\" + name + ".xlsx"); + return Configuration.GetTestWorkbook(Path.Combine("strict", name + ".xlsx")); } } \ No newline at end of file From 1b3bd8793039f03f6e12ce56968d9e3a8233dbeb Mon Sep 17 00:00:00 2001 From: Hugh Bellamy Date: Sat, 1 Nov 2025 21:40:22 +0000 Subject: [PATCH 07/19] Add support for reading XLM (Macro Sheet) files --- .../ExcelBinaryReaderTest.cs | 38 +++++++++++- .../Core/BinaryFormat/Enums.cs | 58 +++++++++++++++--- .../Core/BinaryFormat/XlsBiffBlankCell.cs | 2 +- .../Core/BinaryFormat/XlsBiffLabelCell.cs | 2 +- .../Core/BinaryFormat/XlsBiffStream.cs | 4 +- .../Core/BinaryFormat/XlsWorkbook.cs | 27 ++++---- .../Core/BinaryFormat/XlsWorksheet.cs | 6 +- src/TestData/xls/MACRO1.XLM | Bin 0 -> 1155 bytes src/TestData/xls/SIMPLE.XLS | Bin 0 -> 907 bytes 9 files changed, 108 insertions(+), 29 deletions(-) create mode 100755 src/TestData/xls/MACRO1.XLM create mode 100755 src/TestData/xls/SIMPLE.XLS diff --git a/src/ExcelDataReader.Tests/ExcelBinaryReaderTest.cs b/src/ExcelDataReader.Tests/ExcelBinaryReaderTest.cs index 1531b60d..60d8c002 100644 --- a/src/ExcelDataReader.Tests/ExcelBinaryReaderTest.cs +++ b/src/ExcelDataReader.Tests/ExcelBinaryReaderTest.cs @@ -658,7 +658,7 @@ public void GitIssue368Header() // - Non-standard header with size=6, and version=0 // - Mixes record identifiers from different BIFF versions: // - Uses NUMBER (BIFF3-8) and NUMBER_OLD (BIFF2) records - // - Uses LABEL (BIFF3-5) and LABEL_OLD (BIFF2) records + // - Uses LABEL (BIFF3-5) and LABEL_V2 (BIFF2) records // - Uses RK (BIFF3-5) and INTEGER (BIFF2) records // - Uses FORMAT_V23 (BIFF2-3) and FORMAT (BIFF4-8) records using var reader = ExcelReaderFactory.CreateBinaryReader(Configuration.GetTestWorkbook("Test_git_issue_368_header.xls")); @@ -1093,6 +1093,42 @@ public void GitIssue642_ActiveSheet_SingleWorksheet() Assert.That(dataSet.Tables[0].TableName, Is.EqualTo("List1")); } + [Test] + public void Read_XlsExcel20() + { + using var stream = Configuration.GetTestWorkbook(Path.Combine("xls", "SIMPLE.XLS")); + using var reader = OpenReader(stream); + + reader.Read(); + Assert.That(reader.GetString(0), Is.EqualTo("Value_A1")); + Assert.That(reader.GetString(1), Is.EqualTo("Value_B1")); + + reader.Read(); + Assert.That(reader.GetString(0), Is.EqualTo("Value_A2")); + Assert.That(reader.GetString(1), Is.EqualTo("Value_B2")); + } + + [Test] + public void Read_XlsmExcel20() + { + using var stream = Configuration.GetTestWorkbook(Path.Combine("xls", "MACRO1.XLM")); + using var reader = OpenReader(stream); + + reader.Read(); + Assert.That(reader.GetValue(0), Is.EqualTo("Record1")); + Assert.That(reader.GetValue(2), Is.EqualTo(null)); + + reader.Read(); + Assert.That(reader.GetValue(0), Is.EqualTo(double.NaN)); + Assert.That(reader.GetValue(2), Is.EqualTo(1)); + Assert.That(reader.GetValue(3), Is.EqualTo(3)); + + reader.Read(); + Assert.That(reader.GetValue(0), Is.EqualTo(double.NaN)); + Assert.That(reader.GetValue(2), Is.EqualTo(2)); + Assert.That(reader.GetValue(3), Is.EqualTo(4)); + } + protected override IExcelDataReader OpenReader(Stream stream, ExcelReaderConfiguration configuration = null) { return ExcelReaderFactory.CreateBinaryReader(stream, configuration); diff --git a/src/ExcelDataReader/Core/BinaryFormat/Enums.cs b/src/ExcelDataReader/Core/BinaryFormat/Enums.cs index b4ea01e8..b612016d 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/Enums.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/Enums.cs @@ -8,15 +8,26 @@ internal enum BIFFTYPE : ushort Worksheet = 0x0010, Chart = 0x0020, #pragma warning disable SA1300 // Element must begin with upper-case letter - v4MacroSheet = 0x0040, + MacroSheet = 0x0040, v4WorkbookGlobals = 0x0100 #pragma warning restore SA1300 // Element must begin with upper-case letter } internal enum BIFFRECORDTYPE : ushort { - INTERFACEHDR = 0x00E1, + EXTERNCOUNT = 0x0016, + EXTERNSHEET = 0x0017, + DEFINEDNAME_V2 = 0x0018, // BIFF2. Defined name record. + BUILTINFMTCOUNT_V2 = 0x001F, // BIFF2. Number of number of following FORMAT records that contain built-in number format. + WINDOW2_V2 = 0x003E, // BIFF2. Window 2 record. + CRN = 0x005A, + FILESHARING = 0x005B, + WRITEPROTECT = 0x0086, + UNKNOWN191 = 0x00BF, // Not documented. + UNKNOWN192 = 0x00C0, // Not documented. MMS = 0x00C1, + OBPROJ = 0x00D3, + INTERFACEHDR = 0x00E1, MERGECELLS = 0x00E5, // Record containing list of merged cell ranges INTERFACEEND = 0x00E2, WRITEACCESS = 0x005C, @@ -24,6 +35,11 @@ internal enum BIFFRECORDTYPE : ushort DSF = 0x0161, TABID = 0x013D, FNGROUPCOUNT = 0x009C, + COLWIDTH = 0x0024, + LEFTMARGIN = 0x0026, + RIGHTMARGIN = 0x0027, + TOPMARGIN = 0x0028, + BOTTOMMARGIN = 0x0029, FILEPASS = 0x002F, WINDOWPROTECT = 0x0019, PROTECT = 0x0012, @@ -33,12 +49,15 @@ internal enum BIFFRECORDTYPE : ushort WINDOW1 = 0x003D, BACKUP = 0x0040, HIDEOBJ = 0x008D, - RECORD1904 = 0x0022, + PALETTE = 0x0092, + DATE1904 = 0x0022, REFRESHALL = 0x01B7, BOOKBOOL = 0x00DA, FONT = 0x0031, // Font record, BIFF2, 5 and later + FONT2 = 0x0032, // Font record, BIFF2, unknown usage. + FONT_V34 = 0x0231, // Font record, BIFF3, 4 FORMAT = 0x041E, // Format record, BIFF4 and later @@ -55,6 +74,8 @@ internal enum BIFFRECORDTYPE : ushort IXFE = 0x0044, // Index to XF, BIFF2 + BUILTINFMTCOUNT = 0x0056, // BIFF3+. Number of number of following FORMAT records that contain built-in number format. + STYLE = 0x0293, BOUNDSHEET = 0x0085, COUNTRY = 0x008C, @@ -71,7 +92,7 @@ internal enum BIFFRECORDTYPE : ushort BOF_V4 = 0x0409, // BOF Id for BIFF4 EOF = 0x000A, // End of block started with BOF - + INDEX_V2 = 0x000B, // Index record for BIFF2 CALCCOUNT = 0x000C, CALCMODE = 0x000D, PRECISION = 0x000E, @@ -91,15 +112,17 @@ internal enum BIFFRECORDTYPE : ushort HCENTER = 0x0083, VCENTER = 0x0084, PRINTSETUP = 0x00A1, - DFAULTCOLWIDTH = 0x0055, + DEFAULTCOLWIDTH = 0x0055, DIMENSIONS = 0x0200, // Size of area used for data DIMENSIONS_V2 = 0x0000, // BIFF2 ROW_V2 = 0x0008, // Row record ROW = 0x0208, // Row record - WINDOW2 = 0x023E, SELECTION = 0x001D, + OBNOMACROS = 0x1BD, + EXCEL9FILE = 0x01C0, + RECALCID = 0x01C1, INDEX = 0x020B, // Index record, unsure about signature DBCELL = 0x00D7, // DBCell record, unsure about signature @@ -120,7 +143,7 @@ internal enum BIFFRECORDTYPE : ushort LABEL = 0x0204, // String cell (up to 255 symbols) - LABEL_OLD = 0x0004, // String cell (up to 255 symbols), old format + LABEL_V2 = 0x0004, // String cell (up to 255 symbols), old format LABELSST = 0x00FD, // String cell with value from SST (for BIFF8) @@ -184,7 +207,7 @@ internal enum BIFFRECORDTYPE : ushort XL5MODIFY = 0x0162, OBJ = 0x005D, NOTE = 0x001C, - + // SXEXT = 0x00DC, VERTICALPAGEBREAKS = 0x001A, XCT = 0x0059, @@ -195,5 +218,22 @@ internal enum BIFFRECORDTYPE : ushort /// UNCALCED = 0x005E, QUICKTIP = 0x0800, - COLINFO = 0x007D + COLINFO = 0x007D, + DEFINEDNAME = 0x0218, // Defined name record. + WINDOW2 = 0x023E, // BIFF3+. Window 2 record. + BOOKEXT = 0x0863, + HFPICTURE = 0x0866, + XFCRC = 0x087C, + XFEXT = 0x087D, + COMPAT12 = 0x088C, + STYLEEXT = 0x892, + THEME = 0x0896, + GUIDTYPELIB = 0x0897, + DXF = 0x088D, + TABLESTYLES = 0x88E, + MTRSETTINGS = 0x089A, + COMPRESSPICTURES = 0x089B, + FORCEFULLCALCULATION = 0x08A3, + UNKNOWN2262 = 0x08D6, // Not documented. + CRTCLIENT = 0x105C } diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffBlankCell.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsBiffBlankCell.cs index 60361eb6..32aea75b 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffBlankCell.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsBiffBlankCell.cs @@ -38,7 +38,7 @@ internal XlsBiffBlankCell(byte[] bytes) /// public bool IsBiff2Cell => Id switch { - BIFFRECORDTYPE.NUMBER_OLD or BIFFRECORDTYPE.INTEGER_OLD or BIFFRECORDTYPE.LABEL_OLD or BIFFRECORDTYPE.BLANK_OLD or BIFFRECORDTYPE.BOOLERR_OLD => true, + BIFFRECORDTYPE.NUMBER_OLD or BIFFRECORDTYPE.INTEGER_OLD or BIFFRECORDTYPE.LABEL_V2 or BIFFRECORDTYPE.BLANK_OLD or BIFFRECORDTYPE.BOOLERR_OLD => true, _ => false, }; } \ No newline at end of file diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffLabelCell.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsBiffLabelCell.cs index 62e55c1f..aa313941 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffLabelCell.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsBiffLabelCell.cs @@ -13,7 +13,7 @@ internal sealed class XlsBiffLabelCell : XlsBiffBlankCell internal XlsBiffLabelCell(byte[] bytes, int biffVersion) : base(bytes) { - if (Id == BIFFRECORDTYPE.LABEL_OLD) + if (Id == BIFFRECORDTYPE.LABEL_V2) { // BIFF2 _xlsString = new XlsShortByteString(bytes, ContentOffset + 7); diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffStream.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsBiffStream.cs index f6b86b8f..59af92f5 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffStream.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsBiffStream.cs @@ -195,7 +195,7 @@ public XlsBiffRecord GetRecord(Stream stream) return new XlsBiffBlankCell(bytes); case BIFFRECORDTYPE.MULBLANK: return new XlsBiffMulBlankCell(bytes); - case BIFFRECORDTYPE.LABEL_OLD: + case BIFFRECORDTYPE.LABEL_V2: case BIFFRECORDTYPE.LABEL: case BIFFRECORDTYPE.RSTRING: return new XlsBiffLabelCell(bytes, biffVersion); @@ -234,7 +234,7 @@ public XlsBiffRecord GetRecord(Stream stream) return new XlsBiffSimpleValueRecord(bytes); case BIFFRECORDTYPE.FNGROUPCOUNT: return new XlsBiffSimpleValueRecord(bytes); - case BIFFRECORDTYPE.RECORD1904: + case BIFFRECORDTYPE.DATE1904: return new XlsBiffSimpleValueRecord(bytes); case BIFFRECORDTYPE.BOOKBOOL: return new XlsBiffSimpleValueRecord(bytes); diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsWorkbook.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsWorkbook.cs index 64218631..ec543579 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsWorkbook.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsWorkbook.cs @@ -23,18 +23,21 @@ internal XlsWorkbook(Stream stream, string password, Encoding fallbackEncoding) Encryption = biffStream.Encryption; Encoding = biffStream.BiffVersion == 8 ? Encoding.Unicode : fallbackEncoding; - if (biffStream.BiffType == BIFFTYPE.WorkbookGlobals) + switch (biffStream.BiffType) { - ReadWorkbookGlobals(biffStream); - } - else if (biffStream.BiffType == BIFFTYPE.Worksheet) - { - // set up 'virtual' bound sheet pointing at this - Sheets.Add(new XlsBiffBoundSheet(0, XlsBiffBoundSheet.SheetType.Worksheet, XlsBiffBoundSheet.SheetVisibility.Visible, "Sheet")); - } - else - { - throw new ExcelReaderException(Errors.ErrorWorkbookGlobalsInvalidData); + case BIFFTYPE.WorkbookGlobals: + ReadWorkbookGlobals(biffStream); + break; + case BIFFTYPE.Worksheet: + // set up 'virtual' bound sheet pointing at this + Sheets.Add(new XlsBiffBoundSheet(0, XlsBiffBoundSheet.SheetType.Worksheet, XlsBiffBoundSheet.SheetVisibility.Visible, "Sheet")); + break; + case BIFFTYPE.MacroSheet: + // set up 'virtual' bound sheet pointing at this + Sheets.Add(new XlsBiffBoundSheet(0, XlsBiffBoundSheet.SheetType.MacroSheet, XlsBiffBoundSheet.SheetVisibility.Visible, "Sheet")); + break; + default: + throw new ExcelReaderException(Errors.ErrorWorkbookGlobalsInvalidData); } } @@ -171,7 +174,7 @@ private void ReadWorkbookGlobals(XlsBiffStream biffStream) CodePage = codePage; Encoding = EncodingHelper.GetEncoding(CodePage.Value); break; - case XlsBiffSimpleValueRecord is1904 when rec.Id == BIFFRECORDTYPE.RECORD1904: + case XlsBiffSimpleValueRecord is1904 when rec.Id == BIFFRECORDTYPE.DATE1904: IsDate1904 = is1904.Value == 1; break; case XlsBiffFont font: diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsWorksheet.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsWorksheet.cs index 2c50fccb..6c4fe88c 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsWorksheet.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsWorksheet.cs @@ -287,7 +287,7 @@ private Cell ReadSingleCell(XlsBiffStream biffStream, XlsBiffBlankCell cell, int value = TryConvertOADateTime(((XlsBiffNumberCell)cell).Value, numberFormatIndex); break; case BIFFRECORDTYPE.LABEL: - case BIFFRECORDTYPE.LABEL_OLD: + case BIFFRECORDTYPE.LABEL_V2: case BIFFRECORDTYPE.RSTRING: value = GetLabelString((XlsBiffLabelCell)cell, effectiveStyle); break; @@ -434,7 +434,7 @@ private void ReadWorksheetGlobals() using var biffStream = new XlsBiffStream(Stream, (int)DataOffset, Workbook.BiffVersion, BIFFTYPE.Worksheet, secretKey: Workbook.SecretKey, encryption: Workbook.Encryption); // Check the expected BOF record was found in the BIFF stream - if (biffStream.BiffVersion == 0 || biffStream.BiffType != BIFFTYPE.Worksheet) + if (biffStream.BiffVersion == 0 || (biffStream.BiffType != BIFFTYPE.Worksheet && biffStream.BiffType != BIFFTYPE.MacroSheet)) return; XlsBiffHeaderFooterString header = null; @@ -463,7 +463,7 @@ private void ReadWorksheetGlobals() case XlsBiffDefaultRowHeight defaultRowHeightRecord: DefaultRowHeight = defaultRowHeightRecord.RowHeight; break; - case XlsBiffSimpleValueRecord is1904 when rec.Id == BIFFRECORDTYPE.RECORD1904: + case XlsBiffSimpleValueRecord is1904 when rec.Id == BIFFRECORDTYPE.DATE1904: IsDate1904 = is1904.Value == 1; break; case XlsBiffXF xf when rec.Id is BIFFRECORDTYPE.XF_V2 or BIFFRECORDTYPE.XF_V3 or BIFFRECORDTYPE.XF_V4: diff --git a/src/TestData/xls/MACRO1.XLM b/src/TestData/xls/MACRO1.XLM new file mode 100755 index 0000000000000000000000000000000000000000..a66f2885de876fb71fe2166fab09b194f324f718 GIT binary patch literal 1155 zcmZ8g>uwTJ5T3oT+*@d&^rA?>VlPm@HeN6lTD&sFohRf~fm%8K0)@2=QQ*uvKe1?dWY{lpr4d5DL&>zl%RmRz?o@BSe^}08n6s@=(h^Go* zi0;tFjB8fs`cZ0mQ}W=1fRG2Hm%E&vpA5UyD_vd+-z(a-UE~AOmVRJHBQ zQM1%)rqQz~AFHF$ghAEJ$P4jYY0OI44Uu7Lm0N9>0@QH&W6SBAG-Y0xWAoe`Szs3V zPLP;6do^@yP%)-L)N=Y4WJ>8XwW*oBcT)l*VhzG>FtBY{;6WC=$L}Bf`B(ZXnfGnqzw?ab)l3GU({fD zs=VX7Dt@@j8y`fdL%0q|?jt#}7jVDv2muY+L6*0@YpKLxyHn{lI>m!-)kFJTL@_8K y1tQ8cdF_}UFCiS!n%HHrhe+W>`=?Yh3o&|*IZzZ=0U_31ycmr&Zk>!m`2PoD#hSAK literal 0 HcmV?d00001 diff --git a/src/TestData/xls/SIMPLE.XLS b/src/TestData/xls/SIMPLE.XLS new file mode 100755 index 0000000000000000000000000000000000000000..324ddf87bead1e88a9fcfc18271b3708d7e1de55 GIT binary patch literal 907 zcmZ9K&u$V?6vn?9X!&oUO4X<%7K_xjLRn&zQvYs92r+JqAyc_33DcSZO}g|AdAao_!2w5I5Ka;+Rm!|U2B-z?yCKN zc5Kz2XJ0}LZsl!}#U{G`J5#EBs8HMSt92)$s|)?RwHwh#daRp_MR??iXXey|{>YOf z);+8tUcXnTSjSpn$mWZ4{#=Y*#gqCaG2dAutb`-zgEE7Izqmc*lQI5FYS$AUceC;o{)HY6LeCj}w> zr)Ob6loymnHyoZ$`t8t2;-;|djJ+66MrOQxUC+EZ3kN1-Tcx*hb6l#Nwpp$qML~hw zxf!VP!t+ZUM@)O^k3O1y`_M8c!j6?mVq{-K_Bni(?6|SXR|8iyUex%2E%@n7@%2`- XfQRxw97*EPaD%6hSs;;*lcx6%e}jQ7 literal 0 HcmV?d00001 From af9e9435a374aa75a8b9de4b6dd5f2fddda70f5d Mon Sep 17 00:00:00 2001 From: Kevin Frugier Date: Thu, 9 Apr 2026 09:07:29 +0200 Subject: [PATCH 08/19] Added CaseSentitive to ExcelDataTableConfiguration. (#732) This allows the creation of a primary key after case sensitive data is loaded from Excel. Co-authored-by: Kevin Frugier --- src/ExcelDataReader.DataSet/ExcelDataReaderExtensions.cs | 2 +- src/ExcelDataReader.DataSet/ExcelDataTableConfiguration.cs | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/ExcelDataReader.DataSet/ExcelDataReaderExtensions.cs b/src/ExcelDataReader.DataSet/ExcelDataReaderExtensions.cs index 1598420d..b2eaab3f 100644 --- a/src/ExcelDataReader.DataSet/ExcelDataReaderExtensions.cs +++ b/src/ExcelDataReader.DataSet/ExcelDataReaderExtensions.cs @@ -68,7 +68,7 @@ private static string GetUniqueColumnName(DataTable table, string name) private static DataTable AsDataTable(IExcelDataReader self, ExcelDataTableConfiguration configuration) { - var result = new DataTable { TableName = self.Name }; + var result = new DataTable { TableName = self.Name, CaseSensitive = configuration.CaseSentitive }; result.ExtendedProperties.Add("visiblestate", self.VisibleState); var first = true; var emptyRows = 0; diff --git a/src/ExcelDataReader.DataSet/ExcelDataTableConfiguration.cs b/src/ExcelDataReader.DataSet/ExcelDataTableConfiguration.cs index 04243d4f..f9880646 100644 --- a/src/ExcelDataReader.DataSet/ExcelDataTableConfiguration.cs +++ b/src/ExcelDataReader.DataSet/ExcelDataTableConfiguration.cs @@ -5,6 +5,11 @@ namespace ExcelDataReader; /// public class ExcelDataTableConfiguration { + /// + /// Gets or sets a value indicating whether the DataTable should be case sensitive. Useful when setting a primary key after loading from Excel. + /// + public bool CaseSentitive { get; set; } + /// /// Gets or sets a value indicating the prefix of generated column names. /// From d7bbf9539ac48956a3337c40137e4bcea2f65a3d Mon Sep 17 00:00:00 2001 From: Flithor <23412916+Flithor@users.noreply.github.com> Date: Thu, 9 Apr 2026 15:09:14 +0800 Subject: [PATCH 09/19] Add a DataTable Configuration for allows fill the empty cells in merged range to fill by left-top cell value (#730) * Add a DataTable Configuration for allows fill the empty cells in merged range to fill by left-top cell value * fix code format * Add test cases and fix empty row bug --- .../ExcelDataReaderExtensions.cs | 51 ++- .../ExcelDataTableConfiguration.cs | 5 + src/ExcelDataReader.Sample/Form1.Designer.cs | 328 +++++++++--------- src/ExcelDataReader.Sample/Form1.cs | 7 +- src/ExcelDataReader.Tests/ExcelTestBase.cs | 71 +++- 5 files changed, 291 insertions(+), 171 deletions(-) diff --git a/src/ExcelDataReader.DataSet/ExcelDataReaderExtensions.cs b/src/ExcelDataReader.DataSet/ExcelDataReaderExtensions.cs index b2eaab3f..8b3005db 100644 --- a/src/ExcelDataReader.DataSet/ExcelDataReaderExtensions.cs +++ b/src/ExcelDataReader.DataSet/ExcelDataReaderExtensions.cs @@ -72,9 +72,21 @@ private static DataTable AsDataTable(IExcelDataReader self, ExcelDataTableConfig result.ExtendedProperties.Add("visiblestate", self.VisibleState); var first = true; var emptyRows = 0; + List mergedCellsList = []; + Dictionary<(int Row, int Column), object> mergeCellValue = []; + + // If need to fill merged cells, check the next row have merged cells + var nextRowHaveMergedCell = false; + if (configuration.FillMergedCellsValue) + { + mergedCellsList = self.MergeCells.OrderBy(c => c.FromRow).ThenBy(c => c.FromColumn).ToList(); + } + + int rowIndex = -1; List columnIndices = []; while (self.Read()) { + rowIndex++; if (first) { if (configuration.UseHeaderRow && configuration.ReadHeaderRow != null) @@ -82,7 +94,7 @@ private static DataTable AsDataTable(IExcelDataReader self, ExcelDataTableConfig configuration.ReadHeaderRow(self); } - if (configuration.ReadHeader != null) + if (configuration.ReadHeader != null) { var dict = configuration.ReadHeader(self); foreach (var kvp in dict) @@ -96,8 +108,8 @@ private static DataTable AsDataTable(IExcelDataReader self, ExcelDataTableConfig result.Columns.Add(column); columnIndices.Add(columnIndex); } - } - else + } + else { for (var i = 0; i < self.FieldCount; i++) { @@ -137,7 +149,8 @@ private static DataTable AsDataTable(IExcelDataReader self, ExcelDataTableConfig continue; } - if (IsEmptyRow(self, configuration)) + // if next row is containing merged cells, skip the empty row check + if (!nextRowHaveMergedCell && IsEmptyRow(self, configuration)) { emptyRows++; continue; @@ -157,6 +170,36 @@ private static DataTable AsDataTable(IExcelDataReader self, ExcelDataTableConfig var columnIndex = columnIndices[i]; var value = self.GetValue(columnIndex); + if (configuration.FillMergedCellsValue) + { + var range = mergedCellsList.Find(range => range.FromRow <= rowIndex && + range.ToRow >= rowIndex && + range.FromColumn <= columnIndex && + range.ToColumn >= columnIndex); + if (range != null) + { + if (mergeCellValue.TryGetValue((range.FromRow, range.FromColumn), out var mergedValue)) + { + value = mergedValue; + } + else + { + mergeCellValue[(range.FromRow, range.FromColumn)] = value; + } + + // mark next row is in merged range, skip empty row check and to fill row + if (rowIndex < range.ToRow) + { + nextRowHaveMergedCell = true; + } + else if (rowIndex == range.ToRow && columnIndex == range.ToColumn) + { + mergedCellsList.Remove(range); + nextRowHaveMergedCell = false; + } + } + } + if (configuration.TransformValue != null) { var transformedValue = configuration.TransformValue(self, i, value); diff --git a/src/ExcelDataReader.DataSet/ExcelDataTableConfiguration.cs b/src/ExcelDataReader.DataSet/ExcelDataTableConfiguration.cs index f9880646..9f52f7e6 100644 --- a/src/ExcelDataReader.DataSet/ExcelDataTableConfiguration.cs +++ b/src/ExcelDataReader.DataSet/ExcelDataTableConfiguration.cs @@ -48,4 +48,9 @@ public class ExcelDataTableConfiguration /// Gets or sets a callback to determine whether to transform the cell value. /// public Func TransformValue { get; set; } + + /// + /// Gets or sets a value indicating whether merged cells should be filled with their top-left cell's value. + /// + public bool FillMergedCellsValue { get; set; } } diff --git a/src/ExcelDataReader.Sample/Form1.Designer.cs b/src/ExcelDataReader.Sample/Form1.Designer.cs index 7d717f16..e2647ad6 100644 --- a/src/ExcelDataReader.Sample/Form1.Designer.cs +++ b/src/ExcelDataReader.Sample/Form1.Designer.cs @@ -33,162 +33,177 @@ protected override void Dispose(bool disposing) /// private void InitializeComponent() { - this.openFileDialog1 = new System.Windows.Forms.OpenFileDialog(); - this.button1 = new System.Windows.Forms.Button(); - this.textBox1 = new System.Windows.Forms.TextBox(); - this.button2 = new System.Windows.Forms.Button(); - this.dataSet1 = new System.Data.DataSet(); - this.dataGridView1 = new System.Windows.Forms.DataGridView(); - this.sheetCombo = new System.Windows.Forms.ComboBox(); - this.Sheet = new System.Windows.Forms.Label(); - this.firstRowNamesCheckBox = new System.Windows.Forms.CheckBox(); - this.statusStrip1 = new System.Windows.Forms.StatusStrip(); - this.toolStripStatusLabel1 = new System.Windows.Forms.ToolStripStatusLabel(); - this.label1 = new System.Windows.Forms.Label(); - ((System.ComponentModel.ISupportInitialize)(this.dataSet1)).BeginInit(); - ((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).BeginInit(); - this.statusStrip1.SuspendLayout(); - this.SuspendLayout(); - // - // openFileDialog1 - // - this.openFileDialog1.FileName = "openFileDialog1"; - this.openFileDialog1.Filter = "Supported files|*.xls;*.xlsx;*.xlsb;*.csv|xls|*.xls|xlsx|*.xlsx|xlsb|*.xlsb|csv|*" + -".csv|All|*.*"; - // - // button1 - // - this.button1.Location = new System.Drawing.Point(345, 5); - this.button1.Margin = new System.Windows.Forms.Padding(2); - this.button1.Name = "button1"; - this.button1.Size = new System.Drawing.Size(70, 21); - this.button1.TabIndex = 0; - this.button1.Text = "Select file"; - this.button1.UseVisualStyleBackColor = true; - this.button1.Click += new System.EventHandler(this.Button1Click); - // - // textBox1 - // - this.textBox1.Location = new System.Drawing.Point(46, 6); - this.textBox1.Margin = new System.Windows.Forms.Padding(2); - this.textBox1.Name = "textBox1"; - this.textBox1.Size = new System.Drawing.Size(295, 20); - this.textBox1.TabIndex = 1; - // - // button2 - // - this.button2.Location = new System.Drawing.Point(11, 51); - this.button2.Margin = new System.Windows.Forms.Padding(2); - this.button2.Name = "button2"; - this.button2.Size = new System.Drawing.Size(73, 29); - this.button2.TabIndex = 2; - this.button2.Text = "Process"; - this.button2.UseVisualStyleBackColor = true; - this.button2.Click += new System.EventHandler(this.Button2Click); - // - // dataSet1 - // - this.dataSet1.DataSetName = "NewDataSet"; - // - // dataGridView1 - // - this.dataGridView1.AllowUserToAddRows = false; - this.dataGridView1.AllowUserToDeleteRows = false; - this.dataGridView1.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) - | System.Windows.Forms.AnchorStyles.Left) - | System.Windows.Forms.AnchorStyles.Right))); - this.dataGridView1.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; - this.dataGridView1.Location = new System.Drawing.Point(11, 114); - this.dataGridView1.Margin = new System.Windows.Forms.Padding(2); - this.dataGridView1.Name = "dataGridView1"; - this.dataGridView1.ReadOnly = true; - this.dataGridView1.RowTemplate.Height = 24; - this.dataGridView1.Size = new System.Drawing.Size(865, 426); - this.dataGridView1.TabIndex = 3; - // - // sheetCombo - // - this.sheetCombo.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; - this.sheetCombo.FormattingEnabled = true; - this.sheetCombo.Location = new System.Drawing.Point(88, 89); - this.sheetCombo.Margin = new System.Windows.Forms.Padding(2); - this.sheetCombo.Name = "sheetCombo"; - this.sheetCombo.Size = new System.Drawing.Size(253, 21); - this.sheetCombo.TabIndex = 4; - this.sheetCombo.SelectedIndexChanged += new System.EventHandler(this.SheetComboSelectedIndexChanged); - // - // Sheet - // - this.Sheet.AutoSize = true; - this.Sheet.Location = new System.Drawing.Point(12, 92); - this.Sheet.Margin = new System.Windows.Forms.Padding(2, 0, 2, 0); - this.Sheet.Name = "Sheet"; - this.Sheet.Size = new System.Drawing.Size(72, 13); - this.Sheet.TabIndex = 5; - this.Sheet.Text = "Choose sheet"; - // - // firstRowNamesCheckBox - // - this.firstRowNamesCheckBox.AutoSize = true; - this.firstRowNamesCheckBox.Checked = true; - this.firstRowNamesCheckBox.CheckState = System.Windows.Forms.CheckState.Checked; - this.firstRowNamesCheckBox.Location = new System.Drawing.Point(15, 30); - this.firstRowNamesCheckBox.Margin = new System.Windows.Forms.Padding(2); - this.firstRowNamesCheckBox.Name = "firstRowNamesCheckBox"; - this.firstRowNamesCheckBox.Size = new System.Drawing.Size(176, 17); - this.firstRowNamesCheckBox.TabIndex = 6; - this.firstRowNamesCheckBox.Text = "first row contains column names"; - this.firstRowNamesCheckBox.UseVisualStyleBackColor = true; - // - // statusStrip1 - // - this.statusStrip1.ImageScalingSize = new System.Drawing.Size(20, 20); - this.statusStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { - this.toolStripStatusLabel1}); - this.statusStrip1.Location = new System.Drawing.Point(0, 552); - this.statusStrip1.Name = "statusStrip1"; - this.statusStrip1.Padding = new System.Windows.Forms.Padding(1, 0, 10, 0); - this.statusStrip1.Size = new System.Drawing.Size(887, 22); - this.statusStrip1.TabIndex = 7; - this.statusStrip1.Text = "statusStrip1"; - // - // toolStripStatusLabel1 - // - this.toolStripStatusLabel1.Name = "toolStripStatusLabel1"; - this.toolStripStatusLabel1.Size = new System.Drawing.Size(0, 17); - // - // label1 - // - this.label1.AutoSize = true; - this.label1.Location = new System.Drawing.Point(12, 9); - this.label1.Name = "label1"; - this.label1.Size = new System.Drawing.Size(29, 13); - this.label1.TabIndex = 8; - this.label1.Text = "Path"; - // - // Form1 - // - this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); - this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; - this.ClientSize = new System.Drawing.Size(887, 574); - this.Controls.Add(this.label1); - this.Controls.Add(this.statusStrip1); - this.Controls.Add(this.firstRowNamesCheckBox); - this.Controls.Add(this.Sheet); - this.Controls.Add(this.sheetCombo); - this.Controls.Add(this.dataGridView1); - this.Controls.Add(this.button2); - this.Controls.Add(this.textBox1); - this.Controls.Add(this.button1); - this.Margin = new System.Windows.Forms.Padding(2); - this.Name = "Form1"; - this.Text = "Form1"; - ((System.ComponentModel.ISupportInitialize)(this.dataSet1)).EndInit(); - ((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).EndInit(); - this.statusStrip1.ResumeLayout(false); - this.statusStrip1.PerformLayout(); - this.ResumeLayout(false); - this.PerformLayout(); + this.openFileDialog1 = new System.Windows.Forms.OpenFileDialog(); + this.button1 = new System.Windows.Forms.Button(); + this.textBox1 = new System.Windows.Forms.TextBox(); + this.button2 = new System.Windows.Forms.Button(); + this.dataSet1 = new System.Data.DataSet(); + this.dataGridView1 = new System.Windows.Forms.DataGridView(); + this.sheetCombo = new System.Windows.Forms.ComboBox(); + this.Sheet = new System.Windows.Forms.Label(); + this.firstRowNamesCheckBox = new System.Windows.Forms.CheckBox(); + this.statusStrip1 = new System.Windows.Forms.StatusStrip(); + this.toolStripStatusLabel1 = new System.Windows.Forms.ToolStripStatusLabel(); + this.label1 = new System.Windows.Forms.Label(); + this.fillMergeCellCheckBox = new System.Windows.Forms.CheckBox(); + ((System.ComponentModel.ISupportInitialize)(this.dataSet1)).BeginInit(); + ((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).BeginInit(); + this.statusStrip1.SuspendLayout(); + this.SuspendLayout(); + // + // openFileDialog1 + // + this.openFileDialog1.FileName = "openFileDialog1"; + this.openFileDialog1.Filter = "Supported files|*.xls;*.xlsx;*.xlsb;*.csv|xls|*.xls|xlsx|*.xlsx|xlsb|*.xlsb|csv|*" + + ".csv|All|*.*"; + // + // button1 + // + this.button1.Location = new System.Drawing.Point(345, 5); + this.button1.Margin = new System.Windows.Forms.Padding(2); + this.button1.Name = "button1"; + this.button1.Size = new System.Drawing.Size(70, 19); + this.button1.TabIndex = 0; + this.button1.Text = "Select file"; + this.button1.UseVisualStyleBackColor = true; + this.button1.Click += new System.EventHandler(this.Button1Click); + // + // textBox1 + // + this.textBox1.Location = new System.Drawing.Point(46, 6); + this.textBox1.Margin = new System.Windows.Forms.Padding(2); + this.textBox1.Name = "textBox1"; + this.textBox1.Size = new System.Drawing.Size(295, 21); + this.textBox1.TabIndex = 1; + // + // button2 + // + this.button2.Location = new System.Drawing.Point(11, 47); + this.button2.Margin = new System.Windows.Forms.Padding(2); + this.button2.Name = "button2"; + this.button2.Size = new System.Drawing.Size(73, 27); + this.button2.TabIndex = 2; + this.button2.Text = "Process"; + this.button2.UseVisualStyleBackColor = true; + this.button2.Click += new System.EventHandler(this.Button2Click); + // + // dataSet1 + // + this.dataSet1.DataSetName = "NewDataSet"; + // + // dataGridView1 + // + this.dataGridView1.AllowUserToAddRows = false; + this.dataGridView1.AllowUserToDeleteRows = false; + this.dataGridView1.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom) + | System.Windows.Forms.AnchorStyles.Left) + | System.Windows.Forms.AnchorStyles.Right))); + this.dataGridView1.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; + this.dataGridView1.Location = new System.Drawing.Point(11, 105); + this.dataGridView1.Margin = new System.Windows.Forms.Padding(2); + this.dataGridView1.Name = "dataGridView1"; + this.dataGridView1.ReadOnly = true; + this.dataGridView1.RowTemplate.Height = 24; + this.dataGridView1.Size = new System.Drawing.Size(865, 393); + this.dataGridView1.TabIndex = 3; + // + // sheetCombo + // + this.sheetCombo.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; + this.sheetCombo.FormattingEnabled = true; + this.sheetCombo.Location = new System.Drawing.Point(88, 82); + this.sheetCombo.Margin = new System.Windows.Forms.Padding(2); + this.sheetCombo.Name = "sheetCombo"; + this.sheetCombo.Size = new System.Drawing.Size(253, 20); + this.sheetCombo.TabIndex = 4; + this.sheetCombo.SelectedIndexChanged += new System.EventHandler(this.SheetComboSelectedIndexChanged); + // + // Sheet + // + this.Sheet.AutoSize = true; + this.Sheet.Location = new System.Drawing.Point(12, 85); + this.Sheet.Margin = new System.Windows.Forms.Padding(2, 0, 2, 0); + this.Sheet.Name = "Sheet"; + this.Sheet.Size = new System.Drawing.Size(77, 12); + this.Sheet.TabIndex = 5; + this.Sheet.Text = "Choose sheet"; + // + // firstRowNamesCheckBox + // + this.firstRowNamesCheckBox.AutoSize = true; + this.firstRowNamesCheckBox.Checked = true; + this.firstRowNamesCheckBox.CheckState = System.Windows.Forms.CheckState.Checked; + this.firstRowNamesCheckBox.Location = new System.Drawing.Point(15, 28); + this.firstRowNamesCheckBox.Margin = new System.Windows.Forms.Padding(2); + this.firstRowNamesCheckBox.Name = "firstRowNamesCheckBox"; + this.firstRowNamesCheckBox.Size = new System.Drawing.Size(210, 16); + this.firstRowNamesCheckBox.TabIndex = 6; + this.firstRowNamesCheckBox.Text = "first row contains column names"; + this.firstRowNamesCheckBox.UseVisualStyleBackColor = true; + // + // statusStrip1 + // + this.statusStrip1.ImageScalingSize = new System.Drawing.Size(20, 20); + this.statusStrip1.Items.AddRange(new System.Windows.Forms.ToolStripItem[] { + this.toolStripStatusLabel1}); + this.statusStrip1.Location = new System.Drawing.Point(0, 508); + this.statusStrip1.Name = "statusStrip1"; + this.statusStrip1.Padding = new System.Windows.Forms.Padding(1, 0, 10, 0); + this.statusStrip1.Size = new System.Drawing.Size(887, 22); + this.statusStrip1.TabIndex = 7; + this.statusStrip1.Text = "statusStrip1"; + // + // toolStripStatusLabel1 + // + this.toolStripStatusLabel1.Name = "toolStripStatusLabel1"; + this.toolStripStatusLabel1.Size = new System.Drawing.Size(0, 17); + // + // label1 + // + this.label1.AutoSize = true; + this.label1.Location = new System.Drawing.Point(12, 8); + this.label1.Name = "label1"; + this.label1.Size = new System.Drawing.Size(29, 12); + this.label1.TabIndex = 8; + this.label1.Text = "Path"; + // + // fillMergeCellCheckBox + // + this.fillMergeCellCheckBox.AutoSize = true; + this.fillMergeCellCheckBox.Checked = true; + this.fillMergeCellCheckBox.CheckState = System.Windows.Forms.CheckState.Checked; + this.fillMergeCellCheckBox.Location = new System.Drawing.Point(229, 28); + this.fillMergeCellCheckBox.Margin = new System.Windows.Forms.Padding(2); + this.fillMergeCellCheckBox.Name = "fillMergeCellCheckBox"; + this.fillMergeCellCheckBox.Size = new System.Drawing.Size(228, 16); + this.fillMergeCellCheckBox.TabIndex = 9; + this.fillMergeCellCheckBox.Text = "fill merged cells by left-top cell"; + this.fillMergeCellCheckBox.UseVisualStyleBackColor = true; + // + // Form1 + // + this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 12F); + this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; + this.ClientSize = new System.Drawing.Size(887, 530); + this.Controls.Add(this.fillMergeCellCheckBox); + this.Controls.Add(this.label1); + this.Controls.Add(this.statusStrip1); + this.Controls.Add(this.firstRowNamesCheckBox); + this.Controls.Add(this.Sheet); + this.Controls.Add(this.sheetCombo); + this.Controls.Add(this.dataGridView1); + this.Controls.Add(this.button2); + this.Controls.Add(this.textBox1); + this.Controls.Add(this.button1); + this.Margin = new System.Windows.Forms.Padding(2); + this.Name = "Form1"; + this.Text = "Form1"; + ((System.ComponentModel.ISupportInitialize)(this.dataSet1)).EndInit(); + ((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).EndInit(); + this.statusStrip1.ResumeLayout(false); + this.statusStrip1.PerformLayout(); + this.ResumeLayout(false); + this.PerformLayout(); } @@ -207,4 +222,5 @@ private void InitializeComponent() private ToolStripStatusLabel toolStripStatusLabel1; private Label label1; private DataSet ds; + private CheckBox fillMergeCellCheckBox; } diff --git a/src/ExcelDataReader.Sample/Form1.cs b/src/ExcelDataReader.Sample/Form1.cs index a4205ad4..46a14ace 100644 --- a/src/ExcelDataReader.Sample/Form1.cs +++ b/src/ExcelDataReader.Sample/Form1.cs @@ -53,7 +53,7 @@ private void Button2Click(object sender, EventArgs e) var sw = new Stopwatch(); sw.Start(); - using IExcelDataReader reader = string.Equals(Path.GetExtension(textBox1.Text), ".csv", StringComparison.OrdinalIgnoreCase) ? + using IExcelDataReader reader = string.Equals(Path.GetExtension(textBox1.Text), ".csv", StringComparison.OrdinalIgnoreCase) ? ExcelReaderFactory.CreateCsvReader(stream) : ExcelReaderFactory.CreateReader(stream); var openTiming = sw.ElapsedMilliseconds; @@ -63,7 +63,8 @@ private void Button2Click(object sender, EventArgs e) UseColumnDataType = false, ConfigureDataTable = (tableReader) => new ExcelDataTableConfiguration() { - UseHeaderRow = firstRowNamesCheckBox.Checked + UseHeaderRow = firstRowNamesCheckBox.Checked, + FillMergedCellsValue = fillMergeCellCheckBox.Checked } }); @@ -75,7 +76,7 @@ private void Button2Click(object sender, EventArgs e) if (tablenames.Count > 0) sheetCombo.SelectedIndex = 0; } - catch (Exception ex) + catch (Exception ex) { MessageBox.Show(ex.ToString(), ex.Message, MessageBoxButtons.OK, MessageBoxIcon.Error); } diff --git a/src/ExcelDataReader.Tests/ExcelTestBase.cs b/src/ExcelDataReader.Tests/ExcelTestBase.cs index d198a889..386d804c 100644 --- a/src/ExcelDataReader.Tests/ExcelTestBase.cs +++ b/src/ExcelDataReader.Tests/ExcelTestBase.cs @@ -159,7 +159,7 @@ public void MergedCells() using var excelReader = OpenReader("Test_MergedCell"); excelReader.Read(); - Assert.That(excelReader.MergeCells, Is.EquivalentTo(new[] + Assert.That(excelReader.MergeCells, Is.EquivalentTo(new[] { new[] { 1, 2, 0, 1 }, new[] { 1, 5, 2, 2 }, @@ -192,7 +192,7 @@ public void OpenXmlLeaveOpen() public void RowHeight() { using var reader = OpenReader("CollapsedHide"); - + // Verify the row heights are set when expected, and converted to points from twips reader.Read(); Assert.That(reader.RowHeight, Is.EqualTo(15)); @@ -298,7 +298,7 @@ public void GitIssue82Date1900() // 15/06/2009 // 4/19/2013 (=TODAY() when file was saved) using var excelReader = OpenReader("roo_1900_base"); - + DataSet result = excelReader.AsDataSet(); Assert.That((DateTime)result.Tables[0].Rows[0][0], Is.EqualTo(new DateTime(2009, 6, 15))); Assert.That((DateTime)result.Tables[0].Rows[1][0], Is.EqualTo(GitIssue82TodayDate)); @@ -337,7 +337,7 @@ public void IssueDecimal1109Test() double val2 = (double)dataSet.Tables[0].Rows[0][1]; Assert.That(val2, Is.EqualTo(val1)); } - + [Test] public void IssueEncoding1520Test() { @@ -402,7 +402,7 @@ public void GitIssue270EmptyRowsAtTheEnd() Assert.That(rowCount, Is.EqualTo(65536)); } } - + [Test] public void GitIssue283IsoFormatTimeSpan() { @@ -467,7 +467,7 @@ public void GitIssue329Error() public void Issue4031NullColumn() { using IExcelDataReader excelReader = OpenReader("Test_Issue_4031_NullColumn"); - + // DataSet dataSet = excelReader.AsDataSet(true); excelReader.Read(); Assert.That(excelReader.GetValue(0), Is.Null); @@ -576,7 +576,7 @@ public void Issue11435Colors() public void Issue11479BlankSheet() { using IExcelDataReader excelReader = OpenReader("Test_Issue_11479_BlankSheet"); - + // DataSet result = excelReader.AsDataSet(true); excelReader.Read(); Assert.That(excelReader.FieldCount, Is.EqualTo(5)); @@ -994,7 +994,7 @@ public void GitIssue694ExcelTimeFormatTimeSpanFormulaInvalidResult() [Test] public void GitIssue574VerticalAlignment() - { + { using var reader = OpenReader("Test_git_issue_574"); reader.Read(); @@ -1003,6 +1003,61 @@ public void GitIssue574VerticalAlignment() Assert.That(reader.GetCellStyle(2).VerticalAlignment, Is.EqualTo(VerticalAlignment.Bottom)); } + [Test] + public void AsDataSetTestFillEmptyCellsInMergedRangeNotUseHeaderRow() + { + using IExcelDataReader excelReader = OpenReader("Test_MergedCell"); + DataSet result = excelReader.AsDataSet(new ExcelDataSetConfiguration + { + ConfigureDataTable = _ => new ExcelDataTableConfiguration + { + FillMergedCellsValue = true + } + }); + + Assert.That(result != null, Is.True); + Assert.That(result.Tables.Count, Is.EqualTo(1)); + Assert.That(result.Tables[0].Rows.Count, Is.EqualTo(7)); + Assert.That(result.Tables[0].Columns.Count, Is.EqualTo(3)); + + Assert.That(result.Tables[0].Rows[1][0], Is.EqualTo("Merge Cell 1")); + Assert.That(result.Tables[0].Rows[2][0], Is.EqualTo("Merge Cell 1")); + Assert.That(result.Tables[0].Rows[1][1], Is.EqualTo("Merge Cell 1")); + Assert.That(result.Tables[0].Rows[2][1], Is.EqualTo("Merge Cell 1")); + Assert.That(result.Tables[0].Rows[5][2], Is.EqualTo("Merge Cell 2")); + Assert.That(result.Tables[0].Rows[4][0], Is.EqualTo("Merge Cell 3")); + Assert.That(result.Tables[0].Rows[6][1], Is.EqualTo("Merge Cell 4")); + Assert.That(result.Tables[0].Rows[6][2], Is.EqualTo("Merge Cell 4")); + } + + [Test] + public void AsDataSetTestFillEmptyCellsInMergedRangeUseHeaderRow() + { + using IExcelDataReader excelReader = OpenReader("Test_MergedCell"); + DataSet result = excelReader.AsDataSet(new ExcelDataSetConfiguration + { + ConfigureDataTable = _ => new ExcelDataTableConfiguration + { + UseHeaderRow = true, + FillMergedCellsValue = true + } + }); + + Assert.That(result != null, Is.True); + Assert.That(result.Tables.Count, Is.EqualTo(1)); + Assert.That(result.Tables[0].Rows.Count, Is.EqualTo(6)); + Assert.That(result.Tables[0].Columns.Count, Is.EqualTo(3)); + + Assert.That(result.Tables[0].Rows[0][0], Is.EqualTo("Merge Cell 1")); + Assert.That(result.Tables[0].Rows[1][0], Is.EqualTo("Merge Cell 1")); + Assert.That(result.Tables[0].Rows[0][1], Is.EqualTo("Merge Cell 1")); + Assert.That(result.Tables[0].Rows[1][1], Is.EqualTo("Merge Cell 1")); + Assert.That(result.Tables[0].Rows[4][2], Is.EqualTo("Merge Cell 2")); + Assert.That(result.Tables[0].Rows[3][0], Is.EqualTo("Merge Cell 3")); + Assert.That(result.Tables[0].Rows[5][1], Is.EqualTo("Merge Cell 4")); + Assert.That(result.Tables[0].Rows[5][2], Is.EqualTo("Merge Cell 4")); + } + protected IExcelDataReader OpenReader(string name) { return OpenReader(OpenStream(name)); From dabc7d0fdf00a6fc0ae57de192eeee35c6b4ef64 Mon Sep 17 00:00:00 2001 From: Johan Appelgren Date: Thu, 9 Apr 2026 09:10:55 +0200 Subject: [PATCH 10/19] Add Culture configuration option to return locale-aware format strings for built-in number format indices 14-17 and 22 (#737) --- .../ExcelOpenXmlReaderTest.cs | 92 +++++++++++++ src/ExcelDataReader.Tests/FormatReaderTest.cs | 28 +++- .../Core/BuiltinNumberFormat.cs | 124 ++++++++++++++++++ src/ExcelDataReader/Core/CommonWorkbook.cs | 17 ++- src/ExcelDataReader/ExcelBinaryReader.cs | 4 +- src/ExcelDataReader/ExcelOpenXmlReader.cs | 4 +- .../ExcelReaderConfiguration.cs | 10 +- src/ExcelDataReader/ExcelReaderFactory.cs | 16 +-- src/TestData/Test_git_issue_461.xlsx | Bin 0 -> 2082 bytes 9 files changed, 280 insertions(+), 15 deletions(-) create mode 100644 src/TestData/Test_git_issue_461.xlsx 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 0000000000000000000000000000000000000000..58a7537a6620d0e9d936abdc7db110bce2e9f115 GIT binary patch literal 2082 zcmWIWW@Zs#U|`^2*tDrV#(mY7Z@i2Q3_F<^7=(eM(a!mKC8>EO@gbE3sl~B+6}dTU zgHHM#HW1kRU0Wx9^(|fYmVile5q$?1)*MLims)-ANul?r?-rWfEgA(cGEJ&hy9KYU zPQS@0Jo7>JJ|XXO7gjImy?^abW%2$Uk9v$Mc+D!-{A@UK{p^}sJc6B~8<)>2w>+M$ z=~?=s!KLE`_ocQc3e!zz?X<`YCAD1LI$NPAq26PwNTYe&f)+w6g2sPj09# zR6Up1cgJwuGl9jXTtx?;9(eCO(=x=h&+= zv37s|uXC#0;PHIl(Uf0;aVKAXoOM$=044Nd?<=cs0fw3`Fa)`QbbL{2PO-ioi0nPT zpRdV4fbGNF!mdRE+lwL9PR9jKYGZ0al?u851yp=w7(xAGW1ZogP1Cis|@NAZU6^Eh4$ z@A;Elh0SGmMz&e(nisuN?WXem^qY;l{h3d-Y~(%tDy(7kZJCJ8QGeQ(uag#?=V_Z} z^6>cd_1zlY*OLV{Wc*VyzuURYWYK|S_kEg?YWb+1Yd`c<<~Yz(#z4>U1L=w!{qp>x z?4{+`jd|-idb?YVh2S>|&aE*{&h$;zogP6OX0#`SHgbt)_EV zD!k*1-~Rtx_ivYrN3J+;UVTupS4MblRYiSuZ3WwM)c_uD;cp+BxqdHvwJ+kF{F(Ih zx=V7;g05YwmQ6q7SiVQ;W_r@eg)^p1z8-4eGFQ+$?oV>8{ZAPo$EW|&PG>!}&$RK) zJR&ZqFS+v0BbMm_JwXLH%FfwxlF#x8+n;CO z%B!Eh-{bt18`aMq6W-;X2Ksda(9cp3KSSaa+2`Q+T|41?*C7X<_V=}2isiGf9ai#Q z9_R8w>ledelfTVx&lsFscYAwzz!XuDZyT0>@IEoUKjiiN%#iycl^^_^XNoU45qY0y z-Q@zC9C7UconxkHU;0eW9NDmLbC&<&iCYf;6^KynNao$^@xvwM_T?0d?~O6Dw%GZ( z1@*|XCHpT^juAV`e(XNa3ge~oHe_AT=-9oCrE~`KU)J}x%QkHJ_$^yWU4Pw!o5zK} zyG&Z3R;D5)r+X}0^Ro+U&Fjs7{N<-r`sml5xbx}mf-*d$HqB!%hV>d*T?us&B3i} z46j&S-SV+_8~6FSu3OYPygH9HU6^PW#`9;Q70c2qRl@6^tlhW8@*2yxwA>F%Pi5|S zuw#M0WR*rm}_J!;a^4<4p7j(lXAy2iAl!_e*s`^%@@OwpH(rMV8CP}GduGq=^s`Hc0%Wv^3C zZWNa3&d)W^Iw_xPpY_@PR^pXQ@>0hG$}E`d*FE_0Pf5_0F~FOVNrVA+Z3Of_7&HLG z0&CrbZUB0L4AIWO(7VD?6+P7;v`%7$yB{MB1$eWvfmE^qAt;P{z&rrY%2%}j literal 0 HcmV?d00001 From 4209039f561a5dcfa44c724bd0e93bdcf3aea714 Mon Sep 17 00:00:00 2001 From: Johan Appelgren Date: Thu, 9 Apr 2026 10:19:20 +0200 Subject: [PATCH 11/19] Update README --- README.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 9374a76e..77eea849 100644 --- a/README.md +++ b/README.md @@ -138,6 +138,13 @@ var reader = ExcelReaderFactory.CreateReader(stream, new ExcelReaderConfiguratio // Default: 0 - analyzes the entire file (CSV only, has no effect on other // formats) AnalyzeInitialCsvRows = 0, + + // 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. + // (XLS and XLSX/XLSB only, has no effect on CSV) + Culture = CultureInfo.CurrentCulture, }); ``` @@ -184,7 +191,11 @@ var result = reader.AsDataSet(new ExcelDataSetConfiguration() // headers. FilterColumn = (rowReader, columnIndex) => { return true; - } + }, + + // Gets or sets a value indicating whether merged cells should be filled + // with their top-left cell's value. Default: false + FillMergedCellsValue = false, } }); ``` From b9785962fd6386aa368c47e1e12f5de47636e6c4 Mon Sep 17 00:00:00 2001 From: Johan Appelgren Date: Thu, 9 Apr 2026 10:19:48 +0200 Subject: [PATCH 12/19] =?UTF-8?q?Fix=20locale-specific=20built-in=20number?= =?UTF-8?q?=20format=20indices=20(27-81)=20not=20detect=E2=80=A6=20(#736)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix locale-specific built-in number format indices (27-81) not detected as date/time --- .../packages.lock.json | 6 +- src/ExcelDataReader.Tests/ExcelTestBase.cs | 7 ++ src/ExcelDataReader.Tests/FormatReaderTest.cs | 72 +++++++++++++++++- .../Core/BuiltinNumberFormat.cs | 47 ++++++++++++ .../Core/NumberFormat/Parser.cs | 14 +++- src/ExcelDataReader/packages.lock.json | 6 +- src/TestData/Test_git_issue_541.xls | Bin 0 -> 5632 bytes src/TestData/Test_git_issue_541.xlsb | Bin 0 -> 5669 bytes src/TestData/Test_git_issue_541.xlsx | Bin 0 -> 2323 bytes src/TestData/strict/Test_git_issue_541.xlsx | Bin 0 -> 2305 bytes 10 files changed, 144 insertions(+), 8 deletions(-) create mode 100644 src/TestData/Test_git_issue_541.xls create mode 100644 src/TestData/Test_git_issue_541.xlsb create mode 100644 src/TestData/Test_git_issue_541.xlsx create mode 100644 src/TestData/strict/Test_git_issue_541.xlsx diff --git a/src/ExcelDataReader.DataSet/packages.lock.json b/src/ExcelDataReader.DataSet/packages.lock.json index eb584325..a0024132 100644 --- a/src/ExcelDataReader.DataSet/packages.lock.json +++ b/src/ExcelDataReader.DataSet/packages.lock.json @@ -116,9 +116,9 @@ "net8.0": { "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[8.0.20, )", - "resolved": "8.0.20", - "contentHash": "Rhcto2AjGvTO62+/VTmBpumBOmqIGp7nYEbTbmEXkCq4yPGxV8whju3/HsIA/bKyo2+DggaYk5+/8sxb1AbPTw==" + "requested": "[8.0.25, )", + "resolved": "8.0.25", + "contentHash": "sqX4nmBft05ivqKvUT4nxaN8rT3apCLt9SWFkfRrQPwra1zPwFknQAw1lleuMCKOCLvVmOWwrC2iPSm9RiXZUg==" }, "PolySharp": { "type": "Direct", diff --git a/src/ExcelDataReader.Tests/ExcelTestBase.cs b/src/ExcelDataReader.Tests/ExcelTestBase.cs index 386d804c..834e0b40 100644 --- a/src/ExcelDataReader.Tests/ExcelTestBase.cs +++ b/src/ExcelDataReader.Tests/ExcelTestBase.cs @@ -1004,6 +1004,13 @@ public void GitIssue574VerticalAlignment() } [Test] + public void GitIssue541BuiltinFormat55IsDate() + { + using var reader = OpenReader("Test_git_issue_541"); + Assert.That(reader.Read(), Is.True); + Assert.That(reader.GetValue(0), Is.EqualTo(new DateTime(2021, 1, 15))); + } + public void AsDataSetTestFillEmptyCellsInMergedRangeNotUseHeaderRow() { using IExcelDataReader excelReader = OpenReader("Test_MergedCell"); diff --git a/src/ExcelDataReader.Tests/FormatReaderTest.cs b/src/ExcelDataReader.Tests/FormatReaderTest.cs index 6a333ab8..4643e2f9 100644 --- a/src/ExcelDataReader.Tests/FormatReaderTest.cs +++ b/src/ExcelDataReader.Tests/FormatReaderTest.cs @@ -488,6 +488,76 @@ static void TestValid(string format) } } + [Test] + public void GitIssue541LocaleFormatStringsDetectedAsDate() + { + // Locale-specific format strings containing unquoted CJK / Thai characters + // should be detected as date/time after the implicit-literal parser fix. + + // ja-jp formats (unquoted characters) + Assert.That(IsDate("[$-411]ge.m.d"), Is.True); + Assert.That(IsDate("[$-411]ggge年m月d日"), Is.True); + Assert.That(IsDate("[$-0411]YYYY 年 M 月"), Is.True); + + // zh-tw / zh-cn time formats (AM/PM equivalent followed by hour/minute) + Assert.That(IsDate("上午/下午hh\"時\"mm\"分\""), Is.True); + Assert.That(IsDate("上午/下午h\"时\"mm\"分\""), Is.True); + + static bool IsDate(string formatString) + { + var format = new NumberFormatString(formatString); + return format.IsDateTimeFormat; + } + } + + [Test] + public void GitIssue541BuiltinFormatIndicesDetectedAsDate() + { + // Verify that the representative format strings stored for locale-specific built-in + // indices are each detected as date/time (i.e. IsDateTimeFormat = true). + // These are the ja-jp format strings used as fallbacks for indices 27-36 and 50-58, + // and the ASCII equivalents used for Thai date indices 71-81. + string[] expectedDateFormatStrings = + [ + @"[$-411]ge.m.d", // 27, 36, 50, 57 + "[$-411]ggge\"年\"m\"月\"d\"日\"", // 28, 29, 51, 54, 58 + "m/d/yy", // 30 + "yyyy\"年\"m\"月\"d\"日\"", // 31 + "h\"時\"mm\"分\"", // 32 + "h\"時\"mm\"分\"ss\"秒\"", // 33 + "yyyy\"年\"m\"月\"", // 34, 52, 55 + "m\"月\"d\"日\"", // 35, 53, 56 + "d/m/yyyy", // 71 + "d-mmm-yy", // 72 + "d-mmm", // 73 + "mmm-yy", // 74 + "h:mm", // 75 + "h:mm:ss", // 76 + "d/m/yyyy h:mm", // 77 + "mm:ss", // 78 + "mm:ss.0", // 80 + "d/m/yyyy", // 81 + ]; + + foreach (var formatString in expectedDateFormatStrings) + { + var format = new NumberFormatString(formatString); + Assert.That(format.IsDateTimeFormat, Is.True, $"Format string '{formatString}' not detected as date/time"); + } + + // Thai format 79 uses [h]:mm:ss (elapsed time) → IsTimeSpanFormat, not IsDateTimeFormat + var format79 = new NumberFormatString("[h]:mm:ss"); + Assert.That(format79.IsTimeSpanFormat, Is.True, "Format '[h]:mm:ss' not detected as TimeSpan"); + + // Thai number formats (indices 59-62, 67-70) are NOT date formats. + string[] expectedNumberFormatStrings = ["0", "0.00", "#,##0", "#,##0.00", "0%", "0.00%", "# ?/?", "# ??/??"]; + foreach (var formatString in expectedNumberFormatStrings) + { + var format = new NumberFormatString(formatString); + Assert.That(format.IsDateTimeFormat, Is.False, $"Format string '{formatString}' incorrectly detected as date/time"); + } + } + /// /// 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 @@ -511,4 +581,4 @@ public void GitIssue461_CultureDerivedFormatStringsAreValidDateFormats() 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 788982b0..1502a879 100644 --- a/src/ExcelDataReader/Core/BuiltinNumberFormat.cs +++ b/src/ExcelDataReader/Core/BuiltinNumberFormat.cs @@ -48,6 +48,53 @@ internal static class BuiltinNumberFormat { 47, new NumberFormatString("mm:ss.0") }, { 48, new NumberFormatString("##0.0E+0") }, { 49, new NumberFormatString("@") }, + + // Locale-specific built-in formats. + // Indices 27–36 and 50–58 vary by locale (ja-jp, ko-kr, zh-tw, zh-cn) but are + // always date/time formats. The ja-jp strings are stored as a representative + // fallback; files from other locales may display differently via GetNumberFormatString() + // but will still be correctly identified as date/time values. + { 27, new NumberFormatString(@"[$-411]ge.m.d") }, + { 28, new NumberFormatString("[$-411]ggge\"年\"m\"月\"d\"日\"") }, + { 29, new NumberFormatString("[$-411]ggge\"年\"m\"月\"d\"日\"") }, + { 30, new NumberFormatString("m/d/yy") }, + { 31, new NumberFormatString("yyyy\"年\"m\"月\"d\"日\"") }, + { 32, new NumberFormatString("h\"時\"mm\"分\"") }, + { 33, new NumberFormatString("h\"時\"mm\"分\"ss\"秒\"") }, + { 34, new NumberFormatString("yyyy\"年\"m\"月\"") }, + { 35, new NumberFormatString("m\"月\"d\"日\"") }, + { 36, new NumberFormatString(@"[$-411]ge.m.d") }, + { 50, new NumberFormatString(@"[$-411]ge.m.d") }, + { 51, new NumberFormatString("[$-411]ggge\"年\"m\"月\"d\"日\"") }, + { 52, new NumberFormatString("yyyy\"年\"m\"月\"") }, + { 53, new NumberFormatString("m\"月\"d\"日\"") }, + { 54, new NumberFormatString("[$-411]ggge\"年\"m\"月\"d\"日\"") }, + { 55, new NumberFormatString("yyyy\"年\"m\"月\"") }, + { 56, new NumberFormatString("m\"月\"d\"日\"") }, + { 57, new NumberFormatString(@"[$-411]ge.m.d") }, + { 58, new NumberFormatString("[$-411]ggge\"年\"m\"月\"d\"日\"") }, + + // 59–62 and 67–70 are Thai-digit number formats; 71–81 are Thai date/time formats. + // ASCII equivalents are used so the format strings parse correctly. + { 59, new NumberFormatString("0") }, + { 60, new NumberFormatString("0.00") }, + { 61, new NumberFormatString("#,##0") }, + { 62, new NumberFormatString("#,##0.00") }, + { 67, new NumberFormatString("0%") }, + { 68, new NumberFormatString("0.00%") }, + { 69, new NumberFormatString("# ?/?") }, + { 70, new NumberFormatString("# ??/??") }, + { 71, new NumberFormatString("d/m/yyyy") }, + { 72, new NumberFormatString("d-mmm-yy") }, + { 73, new NumberFormatString("d-mmm") }, + { 74, new NumberFormatString("mmm-yy") }, + { 75, new NumberFormatString("h:mm") }, + { 76, new NumberFormatString("h:mm:ss") }, + { 77, new NumberFormatString("d/m/yyyy h:mm") }, + { 78, new NumberFormatString("mm:ss") }, + { 79, new NumberFormatString("[h]:mm:ss") }, + { 80, new NumberFormatString("mm:ss.0") }, + { 81, new NumberFormatString("d/m/yyyy") }, }; public static NumberFormatString? GetBuiltinNumberFormat(int numFmtId) diff --git a/src/ExcelDataReader/Core/NumberFormat/Parser.cs b/src/ExcelDataReader/Core/NumberFormat/Parser.cs index 85d40f76..f2910650 100644 --- a/src/ExcelDataReader/Core/NumberFormat/Parser.cs +++ b/src/ExcelDataReader/Core/NumberFormat/Parser.cs @@ -248,7 +248,19 @@ private static string ReadToken(Tokenizer reader, out bool syntaxError) return reader.Substring(offset, length); } - syntaxError = reader.Position < reader.Length; + // Treat any unrecognised character as an implicit literal (single-char token). + // This matches Excel behaviour: characters that are not format metacharacters + // are passed through as literals (e.g. unquoted CJK/Thai characters in + // locale-specific built-in format strings such as [$-411]yyyy"年"m"月"). + if (reader.Position < reader.Length) + { + reader.Advance(1); + syntaxError = false; + var length = reader.Position - offset; + return reader.Substring(offset, length); + } + + syntaxError = false; return null; } diff --git a/src/ExcelDataReader/packages.lock.json b/src/ExcelDataReader/packages.lock.json index 16c72787..24ce0e9e 100644 --- a/src/ExcelDataReader/packages.lock.json +++ b/src/ExcelDataReader/packages.lock.json @@ -104,9 +104,9 @@ "net8.0": { "Microsoft.NET.ILLink.Tasks": { "type": "Direct", - "requested": "[8.0.20, )", - "resolved": "8.0.20", - "contentHash": "Rhcto2AjGvTO62+/VTmBpumBOmqIGp7nYEbTbmEXkCq4yPGxV8whju3/HsIA/bKyo2+DggaYk5+/8sxb1AbPTw==" + "requested": "[8.0.25, )", + "resolved": "8.0.25", + "contentHash": "sqX4nmBft05ivqKvUT4nxaN8rT3apCLt9SWFkfRrQPwra1zPwFknQAw1lleuMCKOCLvVmOWwrC2iPSm9RiXZUg==" }, "PolySharp": { "type": "Direct", diff --git a/src/TestData/Test_git_issue_541.xls b/src/TestData/Test_git_issue_541.xls new file mode 100644 index 0000000000000000000000000000000000000000..04570dbe793fa111fc43a84e505eaa091a989c7c GIT binary patch literal 5632 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 H&tLxsz*>8D literal 0 HcmV?d00001 diff --git a/src/TestData/Test_git_issue_541.xlsx b/src/TestData/Test_git_issue_541.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..d0bb1682014fedd279418bb21f8fbc2be4650900 GIT binary patch literal 2323 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 literal 0 HcmV?d00001 From 92809310b477b383e61d43e0f478f3a4eeac1b16 Mon Sep 17 00:00:00 2001 From: Sebastian-roko <78861755+Sebastian-roko@users.noreply.github.com> Date: Thu, 9 Apr 2026 10:34:34 +0200 Subject: [PATCH 13/19] Make clipboard selection dimension properties available (#720) * Add properties for clipboard selection size * Fix indentation after merge * Undo formatting change * expose worksheet dimensions as property Dimension * seal SheetDimRecord * in biff format <= 8, LastColumn and LastRow are non inclusive * Tests for clipboard dimension * fix unnessary black line --- .../ExcelBinaryReaderTest.cs | 10 ++++++++ .../ExcelOpenXmlBinaryReaderTest.cs | 11 +++++++++ .../Core/BinaryFormat/XlsWorksheet.cs | 5 +++- .../Core/CsvFormat/CsvWorksheet.cs | 2 ++ src/ExcelDataReader/Core/IWorksheet.cs | 2 ++ .../BinaryFormat/BiffWorksheetReader.cs | 22 +++++++++++++----- .../OpenXmlFormat/Records/SheetDimRecord.cs | 12 ++++++++++ .../Core/OpenXmlFormat/XlsxWorksheet.cs | 7 +++++- src/ExcelDataReader/ExcelDataReader.cs | 2 ++ src/ExcelDataReader/IExcelDataReader.cs | 6 +++++ src/TestData/Test_Clipboard_Biff12.xlsb | Bin 0 -> 6475 bytes src/TestData/Test_Clipboard_Biff8.xls | Bin 0 -> 14848 bytes 12 files changed, 71 insertions(+), 8 deletions(-) create mode 100644 src/ExcelDataReader/Core/OpenXmlFormat/Records/SheetDimRecord.cs create mode 100644 src/TestData/Test_Clipboard_Biff12.xlsb create mode 100644 src/TestData/Test_Clipboard_Biff8.xls diff --git a/src/ExcelDataReader.Tests/ExcelBinaryReaderTest.cs b/src/ExcelDataReader.Tests/ExcelBinaryReaderTest.cs index 60d8c002..d15c7f01 100644 --- a/src/ExcelDataReader.Tests/ExcelBinaryReaderTest.cs +++ b/src/ExcelDataReader.Tests/ExcelBinaryReaderTest.cs @@ -1094,6 +1094,16 @@ public void GitIssue642_ActiveSheet_SingleWorksheet() } [Test] + public void ClipboardDimension() + { + using var excelReader = ExcelReaderFactory.CreateBinaryReader(Configuration.GetTestWorkbook("Test_Clipboard_Biff8.xls")); + + Assert.That(excelReader.Dimension.FromRow, Is.EqualTo(10)); + Assert.That(excelReader.Dimension.ToRow, Is.EqualTo(11)); + Assert.That(excelReader.Dimension.FromColumn, Is.EqualTo(6)); + Assert.That(excelReader.Dimension.ToColumn, Is.EqualTo(7)); + } + public void Read_XlsExcel20() { using var stream = Configuration.GetTestWorkbook(Path.Combine("xls", "SIMPLE.XLS")); diff --git a/src/ExcelDataReader.Tests/ExcelOpenXmlBinaryReaderTest.cs b/src/ExcelDataReader.Tests/ExcelOpenXmlBinaryReaderTest.cs index 06e15562..aa171816 100644 --- a/src/ExcelDataReader.Tests/ExcelOpenXmlBinaryReaderTest.cs +++ b/src/ExcelDataReader.Tests/ExcelOpenXmlBinaryReaderTest.cs @@ -38,6 +38,17 @@ public void GitIssue642_ActiveSheet_SingleWorksheet() Assert.That(dataSet.Tables[0].TableName, Is.EqualTo("List1")); } + [Test] + public void ClipboardDimension() + { + using var excelReader = OpenReader("Test_Clipboard_Biff12"); + + Assert.That(excelReader.Dimension.FromRow, Is.EqualTo(10)); + Assert.That(excelReader.Dimension.ToRow, Is.EqualTo(11)); + Assert.That(excelReader.Dimension.FromColumn, Is.EqualTo(6)); + Assert.That(excelReader.Dimension.ToColumn, Is.EqualTo(7)); + } + /// protected override Stream OpenStream(string name) { diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsWorksheet.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsWorksheet.cs index 6c4fe88c..533a70ab 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsWorksheet.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsWorksheet.cs @@ -80,6 +80,8 @@ public XlsWorksheet(XlsWorkbook workbook, XlsBiffBoundSheet refSheet, Stream str public int RowCount { get; private set; } + public CellRange Dimension { get; private set; } + public bool IsDate1904 { get; private set; } public XlsWorkbook Workbook { get; } @@ -459,6 +461,7 @@ private void ReadWorksheetGlobals() case XlsBiffDimensions dims: // FieldCount = dims.LastColumn; RowCount = (int)dims.LastRow; + Dimension = new CellRange(dims.FirstColumn, (int)dims.FirstRow, dims.LastColumn - 1, (int)dims.LastRow - 1); break; case XlsBiffDefaultRowHeight defaultRowHeightRecord: DefaultRowHeight = defaultRowHeightRecord.RowHeight; @@ -616,7 +619,7 @@ internal sealed class XlsRowOffset public int MaxOverlapRowIndex { get; set; } } - + private sealed class XlsRowBlock { public Dictionary Rows { get; } = []; diff --git a/src/ExcelDataReader/Core/CsvFormat/CsvWorksheet.cs b/src/ExcelDataReader/Core/CsvFormat/CsvWorksheet.cs index 0e8a27e9..944fc9bc 100644 --- a/src/ExcelDataReader/Core/CsvFormat/CsvWorksheet.cs +++ b/src/ExcelDataReader/Core/CsvFormat/CsvWorksheet.cs @@ -65,6 +65,8 @@ public int RowCount } } + public CellRange Dimension => null; + public Stream Stream { get; } public Encoding Encoding { get; } diff --git a/src/ExcelDataReader/Core/IWorksheet.cs b/src/ExcelDataReader/Core/IWorksheet.cs index 215a90e9..4e39163b 100644 --- a/src/ExcelDataReader/Core/IWorksheet.cs +++ b/src/ExcelDataReader/Core/IWorksheet.cs @@ -17,6 +17,8 @@ internal interface IWorksheet int RowCount { get; } + CellRange Dimension { get; } + CellRange[] MergeCells { get; } List ColumnWidths { get; } diff --git a/src/ExcelDataReader/Core/OpenXmlFormat/BinaryFormat/BiffWorksheetReader.cs b/src/ExcelDataReader/Core/OpenXmlFormat/BinaryFormat/BiffWorksheetReader.cs index ffcc8cd2..99346a5c 100644 --- a/src/ExcelDataReader/Core/OpenXmlFormat/BinaryFormat/BiffWorksheetReader.cs +++ b/src/ExcelDataReader/Core/OpenXmlFormat/BinaryFormat/BiffWorksheetReader.cs @@ -4,11 +4,11 @@ namespace ExcelDataReader.Core.OpenXmlFormat.BinaryFormat; internal sealed class BiffWorksheetReader(Stream stream, bool preparing) : BiffReader(stream) { - private const uint Row = 0x00; + private const uint Row = 0x00; private const uint Blank = 0x01; private const uint Number = 0x02; - private const uint BoolError = 0x03; - private const uint Bool = 0x04; + private const uint BoolError = 0x03; + private const uint Bool = 0x04; private const uint Float = 0x05; private const uint String = 0x06; private const uint SharedString = 0x07; @@ -24,6 +24,7 @@ internal sealed class BiffWorksheetReader(Stream stream, bool preparing) : BiffR private const uint SheetDataBegin = 0x91; private const uint SheetDataEnd = 0x92; private const uint SheetPr = 0x93; + private const uint SheetDim = 0x94; private const uint SheetFormatPr = 0x1E5; // private const uint ColumnsBegin = 0x186; @@ -38,7 +39,7 @@ internal sealed class BiffWorksheetReader(Stream stream, bool preparing) : BiffR protected override Record ReadOverride(byte[] buffer, uint recordId, uint recordLength) { - switch (recordId) + switch (recordId) { case SheetDataBegin: return new SheetDataBeginRecord(); @@ -56,6 +57,15 @@ protected override Record ReadOverride(byte[] buffer, uint recordId, uint record return new SheetPrRecord(codeName); } + case SheetDim: // BrtWsDim + { + int dimFromRow = GetInt32(buffer, 0); + int dimToRow = GetInt32(buffer, 4); + int dimFromColumn = GetInt32(buffer, 8); + int dimToColumn = GetInt32(buffer, 12); + return new SheetDimRecord(new CellRange(dimFromColumn, dimFromRow, dimToColumn, dimToRow)); + } + case SheetFormatPr: // BrtWsFmtInfo { // TODO Default column width @@ -92,7 +102,7 @@ protected override Record ReadOverride(byte[] buffer, uint recordId, uint record var footerEven = GetNullableString(buffer, ref offset); var headerFirst = GetNullableString(buffer, ref offset); var footerFirst = GetNullableString(buffer, ref offset); - return new HeaderFooterRecord(new HeaderFooter(differentFirst, differentOddEven) + return new HeaderFooterRecord(new HeaderFooter(differentFirst, differentOddEven) { FirstHeader = headerFirst, FirstFooter = footerFirst, @@ -170,7 +180,7 @@ protected override Record ReadOverride(byte[] buffer, uint recordId, uint record return Record.Default; } - CellRecord ReadCell(object value, CellError? errorValue = null) + CellRecord ReadCell(object value, CellError? errorValue = null) { int column = (int)GetDWord(buffer, 0); uint xfIndex = GetDWord(buffer, 4) & 0xffffff; diff --git a/src/ExcelDataReader/Core/OpenXmlFormat/Records/SheetDimRecord.cs b/src/ExcelDataReader/Core/OpenXmlFormat/Records/SheetDimRecord.cs new file mode 100644 index 00000000..96315e35 --- /dev/null +++ b/src/ExcelDataReader/Core/OpenXmlFormat/Records/SheetDimRecord.cs @@ -0,0 +1,12 @@ +namespace ExcelDataReader.Core.OpenXmlFormat.Records +{ + internal sealed class SheetDimRecord : Record + { + public SheetDimRecord(CellRange range) + { + Range = range; + } + + public CellRange Range { get; } + } +} diff --git a/src/ExcelDataReader/Core/OpenXmlFormat/XlsxWorksheet.cs b/src/ExcelDataReader/Core/OpenXmlFormat/XlsxWorksheet.cs index 575fe70c..8eb1e8ca 100644 --- a/src/ExcelDataReader/Core/OpenXmlFormat/XlsxWorksheet.cs +++ b/src/ExcelDataReader/Core/OpenXmlFormat/XlsxWorksheet.cs @@ -20,7 +20,7 @@ public XlsxWorksheet(ZipWorker document, XlsxWorkbook workbook, SheetRecord refS return; using var sheetStream = Document.GetWorksheetReader(Path, true); - + if (sheetStream == null) return; @@ -65,6 +65,9 @@ public XlsxWorksheet(ZipWorker document, XlsxWorkbook workbook, SheetRecord refS case HeaderFooterRecord headerFooter: HeaderFooter = headerFooter.HeaderFooter; break; + case SheetDimRecord dimRecord: + Dimension = dimRecord.Range; + break; } } @@ -82,6 +85,8 @@ public XlsxWorksheet(ZipWorker document, XlsxWorkbook workbook, SheetRecord refS public int RowCount { get; } + public CellRange Dimension { get; private set; } + public string Name { get; } public string CodeName { get; } diff --git a/src/ExcelDataReader/ExcelDataReader.cs b/src/ExcelDataReader/ExcelDataReader.cs index d6e07335..8767f42a 100644 --- a/src/ExcelDataReader/ExcelDataReader.cs +++ b/src/ExcelDataReader/ExcelDataReader.cs @@ -51,6 +51,8 @@ internal abstract class ExcelDataReader : IExcelDataReade public int RowCount => _worksheetIterator?.Current?.RowCount ?? 0; + public CellRange Dimension => _worksheetIterator?.Current?.Dimension; + public int RecordsAffected => throw new NotSupportedException(); public double RowHeight => _rowIterator?.Current?.Height ?? 0; diff --git a/src/ExcelDataReader/IExcelDataReader.cs b/src/ExcelDataReader/IExcelDataReader.cs index 13f48a3b..34ed5564 100644 --- a/src/ExcelDataReader/IExcelDataReader.cs +++ b/src/ExcelDataReader/IExcelDataReader.cs @@ -52,6 +52,12 @@ public interface IExcelDataReader : IDataReader /// int RowCount { get; } + /// + /// Gets the dimension of the current result. + /// + /// Main use case is analysis of clipboard data. Potentially unreliable in other use cases. + CellRange Dimension { get; } + /// /// Gets the height of the current row in points. /// diff --git a/src/TestData/Test_Clipboard_Biff12.xlsb b/src/TestData/Test_Clipboard_Biff12.xlsb new file mode 100644 index 0000000000000000000000000000000000000000..8d9b82106d50088d66e3f48bdb35f043fb1de525 GIT binary patch literal 6475 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 literal 0 HcmV?d00001 diff --git a/src/TestData/Test_Clipboard_Biff8.xls b/src/TestData/Test_Clipboard_Biff8.xls new file mode 100644 index 0000000000000000000000000000000000000000..30d8f0f449a85a81ca0e803d31b8134cca19f944 GIT binary patch literal 14848 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 literal 0 HcmV?d00001 From e2bad141f8d829082ecdf47f9a49684286563af4 Mon Sep 17 00:00:00 2001 From: Johan Appelgren Date: Thu, 9 Apr 2026 13:09:47 +0200 Subject: [PATCH 14/19] Add single-pass mode and reduce allocations (#738) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add single-pass mode and reduce allocations 28–56% SinglePassMode trades row Count and maximum field count availability for lower memory usage. It also won't work for .xls files with out of order data. --- .../SinglePassRead.cs | 78 +++++++ .../ExcelDataReaderExtensions.cs | 21 ++ src/ExcelDataReader.Tests/ExcelTestBase.cs | 33 +++ .../Core/BinaryFormat/XlsWorkbook.cs | 2 +- .../Core/BinaryFormat/XlsWorksheet.cs | 204 +++++++++++++++--- src/ExcelDataReader/Core/Cell.cs | 2 +- src/ExcelDataReader/Core/CommonWorkbook.cs | 2 + .../BinaryFormat/BiffWorksheetReader.cs | 58 ++++- .../OpenXmlFormat/Records/ScanResultRecord.cs | 14 ++ .../Core/OpenXmlFormat/XlsxWorkbook.cs | 2 +- .../Core/OpenXmlFormat/XlsxWorksheet.cs | 39 ++-- .../XmlFormat/XmlWorksheetReader.cs | 166 +++++++++----- src/ExcelDataReader/Core/Row.cs | 2 +- src/ExcelDataReader/ExcelBinaryReader.cs | 6 +- src/ExcelDataReader/ExcelDataReader.cs | 32 ++- src/ExcelDataReader/ExcelOpenXmlReader.cs | 4 +- .../ExcelReaderConfiguration.cs | 7 + src/ExcelDataReader/ExcelReaderFactory.cs | 16 +- 18 files changed, 556 insertions(+), 132 deletions(-) create mode 100644 src/ExcelDataReader.Benchmarks/SinglePassRead.cs create mode 100644 src/ExcelDataReader/Core/OpenXmlFormat/Records/ScanResultRecord.cs diff --git a/src/ExcelDataReader.Benchmarks/SinglePassRead.cs b/src/ExcelDataReader.Benchmarks/SinglePassRead.cs new file mode 100644 index 00000000..d13a1d5c --- /dev/null +++ b/src/ExcelDataReader.Benchmarks/SinglePassRead.cs @@ -0,0 +1,78 @@ +using System.Text; +using BenchmarkDotNet.Attributes; + +namespace ExcelDataReader.Benchmarks; + +[MemoryDiagnoser] +public class SinglePassRead +{ + private static readonly ExcelReaderConfiguration MultiPassConfig = new(); + private static readonly ExcelReaderConfiguration SinglePassConfig = new() { SinglePassMode = true }; + + [GlobalSetup] + public void Setup() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + } + + [Benchmark] + public void ReadMultiPassXlsx() + { + using var reader = ExcelReaderFactory.CreateReader( + typeof(OpenXmlFile).Assembly.GetManifestResourceStream("ExcelDataReader.Benchmarks.Test10x10000.xlsx"), + MultiPassConfig); + Read(reader); + } + + [Benchmark] + public void ReadSinglePassXlsx() + { + using var reader = ExcelReaderFactory.CreateReader( + typeof(OpenXmlFile).Assembly.GetManifestResourceStream("ExcelDataReader.Benchmarks.Test10x10000.xlsx"), + SinglePassConfig); + Read(reader); + } + + [Benchmark] + public void ReadMultiPassXlsb() + { + using var reader = ExcelReaderFactory.CreateReader( + typeof(OpenXmlFile).Assembly.GetManifestResourceStream("ExcelDataReader.Benchmarks.Test10x10000.xlsb"), + MultiPassConfig); + Read(reader); + } + + [Benchmark] + public void ReadSinglePassXlsb() + { + using var reader = ExcelReaderFactory.CreateReader( + typeof(OpenXmlFile).Assembly.GetManifestResourceStream("ExcelDataReader.Benchmarks.Test10x10000.xlsb"), + SinglePassConfig); + Read(reader); + } + + [Benchmark] + public void ReadMultiPassXls() + { + using var reader = ExcelReaderFactory.CreateReader( + typeof(OpenXmlFile).Assembly.GetManifestResourceStream("ExcelDataReader.Benchmarks.Test10x10000.xls"), + MultiPassConfig); + Read(reader); + } + + [Benchmark] + public void ReadSinglePassXls() + { + using var reader = ExcelReaderFactory.CreateReader( + typeof(OpenXmlFile).Assembly.GetManifestResourceStream("ExcelDataReader.Benchmarks.Test10x10000.xls"), + SinglePassConfig); + Read(reader); + } + + private static void Read(IExcelDataReader reader) + { + while (reader.Read()) + { + } + } +} diff --git a/src/ExcelDataReader.DataSet/ExcelDataReaderExtensions.cs b/src/ExcelDataReader.DataSet/ExcelDataReaderExtensions.cs index 8b3005db..b40916aa 100644 --- a/src/ExcelDataReader.DataSet/ExcelDataReaderExtensions.cs +++ b/src/ExcelDataReader.DataSet/ExcelDataReaderExtensions.cs @@ -83,6 +83,7 @@ private static DataTable AsDataTable(IExcelDataReader self, ExcelDataTableConfig } int rowIndex = -1; + int lastCheckedFieldCount = 0; List columnIndices = []; while (self.Read()) { @@ -136,6 +137,7 @@ private static DataTable AsDataTable(IExcelDataReader self, ExcelDataTableConfig } result.BeginLoadData(); + lastCheckedFieldCount = self.FieldCount; first = false; if (configuration.UseHeaderRow) @@ -143,6 +145,25 @@ private static DataTable AsDataTable(IExcelDataReader self, ExcelDataTableConfig continue; } } + else if (configuration.ReadHeader == null && self.FieldCount > lastCheckedFieldCount) + { + // Grow DataTable columns dynamically when FieldCount increases (e.g. single-pass mode) + for (var i = lastCheckedFieldCount; i < self.FieldCount; i++) + { + if (configuration.FilterColumn != null && !configuration.FilterColumn(self, i)) + { + continue; + } + + var name = configuration.EmptyColumnNamePrefix + i; + var columnName = GetUniqueColumnName(result, name); + var column = new DataColumn(columnName, typeof(object)) { Caption = name }; + result.Columns.Add(column); + columnIndices.Add(i); + } + + lastCheckedFieldCount = self.FieldCount; + } if (configuration.FilterRow != null && !configuration.FilterRow(self)) { diff --git a/src/ExcelDataReader.Tests/ExcelTestBase.cs b/src/ExcelDataReader.Tests/ExcelTestBase.cs index 834e0b40..18a9d6a5 100644 --- a/src/ExcelDataReader.Tests/ExcelTestBase.cs +++ b/src/ExcelDataReader.Tests/ExcelTestBase.cs @@ -1004,6 +1004,39 @@ public void GitIssue574VerticalAlignment() } [Test] + public void GitIssue618_SinglePassMode_RowCountThrows() + { + using var reader = OpenReader(OpenStream("Test10x10"), new ExcelReaderConfiguration { SinglePassMode = true }); + Assert.Throws(() => _ = reader.RowCount); + reader.Read(); + Assert.Throws(() => _ = reader.RowCount); + } + + [Test] + public void GitIssue618_SinglePassMode_AsDataSet() + { + using var reader = OpenReader(OpenStream("Test10x10"), new ExcelReaderConfiguration { SinglePassMode = true }); + var dataSet = reader.AsDataSet(); + Assert.That(dataSet.Tables[0].Rows.Count, Is.EqualTo(10)); + Assert.That(dataSet.Tables[0].Columns.Count, Is.EqualTo(10)); + Assert.That(dataSet.Tables[0].Rows[1][0], Is.EqualTo("10x10")); + Assert.That(dataSet.Tables[0].Rows[9][9], Is.EqualTo("10x27")); + } + + [Test] + public void GitIssue618_SinglePassMode_FieldCountGrows() + { + using var reader = OpenReader(OpenStream("Test10x10"), new ExcelReaderConfiguration { SinglePassMode = true }); + Assert.That(reader.FieldCount, Is.Zero); + reader.Read(); + Assert.That(reader.FieldCount, Is.EqualTo(9)); + while (reader.Read()) + { + } + + Assert.That(reader.FieldCount, Is.GreaterThanOrEqualTo(10)); + } + public void GitIssue541BuiltinFormat55IsDate() { using var reader = OpenReader("Test_git_issue_541"); diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsWorkbook.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsWorkbook.cs index ec543579..a63df23a 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsWorkbook.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsWorkbook.cs @@ -134,7 +134,7 @@ public IEnumerable ReadWorksheets() { for (var i = 0; i < Sheets.Count; ++i) { - yield return new XlsWorksheet(this, Sheets[i], Stream); + yield return new XlsWorksheet(this, Sheets[i], Stream, SinglePassMode); } } diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsWorksheet.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsWorksheet.cs index 533a70ab..f6fe6113 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsWorksheet.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsWorksheet.cs @@ -8,7 +8,7 @@ namespace ExcelDataReader.Core.BinaryFormat; /// internal sealed class XlsWorksheet : IWorksheet { - public XlsWorksheet(XlsWorkbook workbook, XlsBiffBoundSheet refSheet, Stream stream) + public XlsWorksheet(XlsWorkbook workbook, XlsBiffBoundSheet refSheet, Stream stream, bool singlePassMode = false) { Workbook = workbook; Stream = stream; @@ -27,6 +27,7 @@ public XlsWorksheet(XlsWorkbook workbook, XlsBiffBoundSheet refSheet, Stream str XlsBiffBoundSheet.SheetVisibility.VeryHidden => "veryhidden", _ => "visible", }; + SinglePassMode = singlePassMode; ReadWorksheetGlobals(); } @@ -84,21 +85,40 @@ public XlsWorksheet(XlsWorkbook workbook, XlsBiffBoundSheet refSheet, Stream str public bool IsDate1904 { get; private set; } + public bool SinglePassMode { get; } + public XlsWorkbook Workbook { get; } public IEnumerable ReadRows() { var rowIndex = 0; using var biffStream = new XlsBiffStream(Stream, (int)DataOffset, Workbook.BiffVersion, BIFFTYPE.Worksheet, secretKey: Workbook.SecretKey, encryption: Workbook.Encryption); - foreach (var rowBlock in ReadWorksheetRows(biffStream)) + + if (SinglePassMode) { - for (; rowIndex < rowBlock.RowIndex; ++rowIndex) + foreach (var row in ReadWorksheetRowsSinglePass(biffStream)) { - yield return new Row(rowIndex, DefaultRowHeight / 20.0, []); + for (; rowIndex < row.RowIndex; ++rowIndex) + { + yield return new Row(rowIndex, DefaultRowHeight / 20.0, []); + } + + rowIndex++; + yield return row; } + } + else + { + foreach (var rowBlock in ReadWorksheetRows(biffStream)) + { + for (; rowIndex < rowBlock.RowIndex; ++rowIndex) + { + yield return new Row(rowIndex, DefaultRowHeight / 20.0, []); + } - rowIndex++; - yield return rowBlock; + rowIndex++; + yield return rowBlock; + } } } @@ -136,41 +156,59 @@ private void GetBlockSize(int startRow, out int blockRowCount, out int minOffset private IEnumerable ReadWorksheetRows(XlsBiffStream biffStream) { var rowIndex = 0; + List> cellListPool = []; while (rowIndex < RowCount) { GetBlockSize(rowIndex, out var blockRowCount, out var minOffset, out var maxOffset); - var block = ReadNextBlock(biffStream, rowIndex, blockRowCount, minOffset, maxOffset); - - for (var i = 0; i < blockRowCount; ++i) +#if NETSTANDARD2_1_OR_GREATER || NET8_0_OR_GREATER + var block = System.Buffers.ArrayPool.Shared.Rent(blockRowCount); +#else + var block = new Row?[blockRowCount]; +#endif + try { - if (block.Rows.TryGetValue(rowIndex + i, out var row)) + ReadNextBlock(biffStream, rowIndex, blockRowCount, minOffset, maxOffset, block, cellListPool); + + for (var i = 0; i < blockRowCount; ++i) { - yield return row; + if (block[i].HasValue) + { + yield return block[i]!.Value; + + // Safe: ReadCurrentRow() already consumed cells before this MoveNext resumed. + var consumed = block[i]!.Value.Cells; + consumed.Clear(); + cellListPool.Add(consumed); + } } } + finally + { +#if NETSTANDARD2_1_OR_GREATER || NET8_0_OR_GREATER + System.Buffers.ArrayPool.Shared.Return(block, clearArray: true); +#endif + } rowIndex += blockRowCount; } } - private XlsRowBlock ReadNextBlock(XlsBiffStream biffStream, int startRow, int rows, int minOffset, int maxOffset) + private void ReadNextBlock(XlsBiffStream biffStream, int startRow, int rows, int minOffset, int maxOffset, Row?[] result, List> cellListPool) { - var result = new XlsRowBlock(); - // Ensure rows with physical records are initialized with height for (var i = 0; i < rows; i++) { if (RowOffsetMap.TryGetValue(startRow + i, out _)) { - EnsureRow(result, startRow + i); + EnsureRow(result, startRow, startRow + i, cellListPool); } } if (minOffset == int.MaxValue) { - return result; + return; } biffStream.Position = minOffset; @@ -186,18 +224,18 @@ private XlsRowBlock ReadNextBlock(XlsBiffStream biffStream, int startRow, int ro if (rec is XlsBiffBlankCell cell) { - var currentRow = EnsureRow(result, cell.RowIndex); + var cellList = EnsureRow(result, startRow, cell.RowIndex, cellListPool); if (cell.Id == BIFFRECORDTYPE.MULRK) { var cellValues = ReadMultiCell(cell); - currentRow.Cells.AddRange(cellValues); + cellList.AddRange(cellValues); } else { var xfIndex = GetXfIndexForCell(cell, ixfe); var cellValue = ReadSingleCell(biffStream, cell, xfIndex); - currentRow.Cells.Add(cellValue); + cellList.Add(cellValue); } ixfe = null; @@ -208,13 +246,12 @@ private XlsRowBlock ReadNextBlock(XlsBiffStream biffStream, int startRow, int ro #endif } - - return result; } - private Row EnsureRow(XlsRowBlock result, int rowIndex) + private List EnsureRow(Row?[] result, int startRow, int rowIndex, List> cellListPool) { - if (!result.Rows.TryGetValue(rowIndex, out var currentRow)) + int i = rowIndex - startRow; + if (!result[i].HasValue) { var height = DefaultRowHeight / 20.0; if (RowOffsetMap.TryGetValue(rowIndex, out var rowOffset) && rowOffset.RowHeight != null) @@ -222,12 +259,21 @@ private Row EnsureRow(XlsRowBlock result, int rowIndex) height = (rowOffset.UseDefaultRowHeight ? DefaultRowHeight : rowOffset.RowHeight.Value) / 20.0; } - currentRow = new Row(rowIndex, height, []); + List cells; + if (cellListPool.Count > 0) + { + cells = cellListPool[^1]; + cellListPool.RemoveAt(cellListPool.Count - 1); + } + else + { + cells = []; + } - result.Rows.Add(rowIndex, currentRow); + result[i] = new Row(rowIndex, height, cells); } - return currentRow; + return result[i]!.Value.Cells; } private IEnumerable ReadMultiCell(XlsBiffBlankCell cell) @@ -454,12 +500,12 @@ private void ReadWorksheetGlobals() var recordOffset = biffStream.Position; var rec = biffStream.Read(); - while (rec != null && rec is not XlsBiffEof) + var stopAtCells = false; + while (!stopAtCells && rec != null && rec is not XlsBiffEof) { switch (rec) { case XlsBiffDimensions dims: - // FieldCount = dims.LastColumn; RowCount = (int)dims.LastRow; Dimension = new CellRange(dims.FirstColumn, (int)dims.FirstRow, dims.LastColumn - 1, (int)dims.LastRow - 1); break; @@ -509,12 +555,26 @@ private void ReadWorksheetGlobals() CodeName = codeName.GetValue(Encoding); break; case XlsBiffRow row: + if (SinglePassMode) + { + // In single-pass mode, stop before processing cell data; + // ReadRows() will stream cells in one forward pass + stopAtCells = true; + break; + } + SetMinMaxRow(row.RowIndex, row); // Count rows by row records without affecting the overlap in OffsetMap maxRowCountFromRowRecord = Math.Max(maxRowCountFromRowRecord, row.RowIndex + 1); break; case XlsBiffBlankCell cell: + if (SinglePassMode) + { + stopAtCells = true; + break; + } + if (!cell.IsEmpty) { if (cell is XlsBiffMulRKCell mcell) @@ -562,7 +622,10 @@ private void ReadWorksheetGlobals() if (mergeCells.Count > 0) MergeCells = mergeCells.ToArray(); - FieldCount = maxCellColumn; + if (!SinglePassMode) + { + FieldCount = maxCellColumn; + } maxRowCount = Math.Max(maxRowCount, maxRowCountFromRowRecord); if (RowCount < maxRowCount) @@ -590,6 +653,84 @@ private void SetMinMaxRow(int rowIndex, XlsBiffRow row) rowOffset.RowHeight = row.RowHeight; } + private IEnumerable ReadWorksheetRowsSinglePass(XlsBiffStream biffStream) + { + int lastYieldedRowIndex = -1; + int currentRowIndex = -1; + List currentRowCells = []; + Dictionary rowHeights = []; + ushort? ixfe = null; + + var rec = biffStream.Read(); + while (rec != null && rec is not XlsBiffEof) + { + switch (rec) + { + case XlsBiffRow row: + rowHeights[row.RowIndex] = row.UseDefaultRowHeight + ? DefaultRowHeight / 20.0 + : row.RowHeight / 20.0; + break; + case XlsBiffBlankCell cell: + { + if (cell.RowIndex <= lastYieldedRowIndex) + { + throw new InvalidOperationException( + "File contains out-of-order cells. Use SinglePassMode = false to read this file."); + } + + if (cell.RowIndex != currentRowIndex) + { + if (currentRowIndex != -1) + { + var height = rowHeights.TryGetValue(currentRowIndex, out var h) ? h : DefaultRowHeight / 20.0; + yield return new Row(currentRowIndex, height, currentRowCells); + lastYieldedRowIndex = currentRowIndex; + rowHeights.Remove(lastYieldedRowIndex); + currentRowCells.Clear(); + } + + currentRowIndex = cell.RowIndex; + } + + if (!cell.IsEmpty) + { + if (cell.Id == BIFFRECORDTYPE.MULRK) + { + currentRowCells.AddRange(ReadMultiCell(cell)); + } + else + { + var xfIndex = GetXfIndexForCell(cell, ixfe); + currentRowCells.Add(ReadSingleCell(biffStream, cell, xfIndex)); + } + } + + ixfe = null; + } + + break; + case { Id: BIFFRECORDTYPE.IXFE }: + ixfe = rec.ReadUInt16(0); + break; + } + +#if NETSTANDARD2_1_OR_GREATER || NET8_0_OR_GREATER + rec.Return(); +#endif + rec = biffStream.Read(); + + if (rec is XlsBiffBOF) + break; + } + + if (currentRowIndex != -1 && currentRowCells.Count > 0) + { + var height = rowHeights.TryGetValue(currentRowIndex, out var h) ? h : DefaultRowHeight / 20.0; + yield return new Row(currentRowIndex, height, currentRowCells); + } + } + private void SetMinMaxRowOffset(int rowIndex, int recordOffset, int maxOverlapRow) { if (!RowOffsetMap.TryGetValue(rowIndex, out var rowOffset)) @@ -619,9 +760,4 @@ internal sealed class XlsRowOffset public int MaxOverlapRowIndex { get; set; } } - - private sealed class XlsRowBlock - { - public Dictionary Rows { get; } = []; - } } \ No newline at end of file diff --git a/src/ExcelDataReader/Core/Cell.cs b/src/ExcelDataReader/Core/Cell.cs index 09f01cef..ffacc1e0 100644 --- a/src/ExcelDataReader/Core/Cell.cs +++ b/src/ExcelDataReader/Core/Cell.cs @@ -12,4 +12,4 @@ namespace ExcelDataReader.Core; /// the Cell XF, with optional overrides from a Cell Style XF. /// /// Cell error -or- . -internal sealed record Cell(int ColumnIndex, object? Value, ExtendedFormat EffectiveStyle, CellError? Error); +internal readonly record struct Cell(int ColumnIndex, object? Value, ExtendedFormat EffectiveStyle, CellError? Error); diff --git a/src/ExcelDataReader/Core/CommonWorkbook.cs b/src/ExcelDataReader/Core/CommonWorkbook.cs index c750d9be..74a6290d 100644 --- a/src/ExcelDataReader/Core/CommonWorkbook.cs +++ b/src/ExcelDataReader/Core/CommonWorkbook.cs @@ -26,6 +26,8 @@ internal class CommonWorkbook /// public List CellStyleExtendedFormats { get; } = []; + public bool SinglePassMode { get; set; } + /// /// Gets or sets the culture to use for locale-dependent built-in number format indices. /// When null (the default), hardcoded format strings are used. diff --git a/src/ExcelDataReader/Core/OpenXmlFormat/BinaryFormat/BiffWorksheetReader.cs b/src/ExcelDataReader/Core/OpenXmlFormat/BinaryFormat/BiffWorksheetReader.cs index 99346a5c..9ec01d03 100644 --- a/src/ExcelDataReader/Core/OpenXmlFormat/BinaryFormat/BiffWorksheetReader.cs +++ b/src/ExcelDataReader/Core/OpenXmlFormat/BinaryFormat/BiffWorksheetReader.cs @@ -24,7 +24,7 @@ internal sealed class BiffWorksheetReader(Stream stream, bool preparing) : BiffR private const uint SheetDataBegin = 0x91; private const uint SheetDataEnd = 0x92; private const uint SheetPr = 0x93; - private const uint SheetDim = 0x94; + private const uint SheetDim = 0x94; // BrtWsDim - worksheet dimensions private const uint SheetFormatPr = 0x1E5; // private const uint ColumnsBegin = 0x186; @@ -37,14 +37,68 @@ internal sealed class BiffWorksheetReader(Stream stream, bool preparing) : BiffR // private const uint MergeCellsEnd = 178; private const uint MergeCell = 176; + // State for pre-scan mode: track max indices while suppressing per-cell record allocations + private int _scanMaxRowIndex = int.MinValue; + private int _scanMaxColumnIndex = int.MinValue; + private bool _inPreparingScan; + protected override Record ReadOverride(byte[] buffer, uint recordId, uint recordLength) { switch (recordId) { case SheetDataBegin: + if (preparing) + { + _inPreparingScan = true; + return Record.Default; // suppress SheetDataBeginRecord; ScanResultRecord comes at SheetDataEnd + } + return new SheetDataBeginRecord(); case SheetDataEnd: + if (_inPreparingScan) + { + _inPreparingScan = false; + return new ScanResultRecord(_scanMaxRowIndex, _scanMaxColumnIndex); + } + return new SheetDataEndRecord(); + case Row when _inPreparingScan: + { + int rowIndex = GetInt32(buffer, 0); + if (rowIndex > _scanMaxRowIndex) + { + _scanMaxRowIndex = rowIndex; + } + + return Record.Default; + } + + // Blank cells have null value and null error — not counted toward column maximum + case Blank when _inPreparingScan: + return Record.Default; + + // All other cell types have a non-null value or non-null error — counted toward column maximum + case Number when _inPreparingScan: + case Float when _inPreparingScan: + case String when _inPreparingScan: + case SharedString when _inPreparingScan: + case FormulaNumber when _inPreparingScan: + case FormulaString when _inPreparingScan: + case FormulaBool when _inPreparingScan: + case BrtCellRString when _inPreparingScan: + case Bool when _inPreparingScan: + case BoolError when _inPreparingScan: + case FormulaError when _inPreparingScan: + { + int column = (int)GetDWord(buffer, 0); + if (column > _scanMaxColumnIndex) + { + _scanMaxColumnIndex = column; + } + + return Record.Default; + } + case SheetPr: // BrtWsProp { // Must be between 0 and 31 characters @@ -57,7 +111,7 @@ protected override Record ReadOverride(byte[] buffer, uint recordId, uint record return new SheetPrRecord(codeName); } - case SheetDim: // BrtWsDim + case SheetDim: // BrtWsDim - worksheet dimensions (rwFirst, rwLast, colFirst, colLast) { int dimFromRow = GetInt32(buffer, 0); int dimToRow = GetInt32(buffer, 4); diff --git a/src/ExcelDataReader/Core/OpenXmlFormat/Records/ScanResultRecord.cs b/src/ExcelDataReader/Core/OpenXmlFormat/Records/ScanResultRecord.cs new file mode 100644 index 00000000..92864b28 --- /dev/null +++ b/src/ExcelDataReader/Core/OpenXmlFormat/Records/ScanResultRecord.cs @@ -0,0 +1,14 @@ +namespace ExcelDataReader.Core.OpenXmlFormat.Records; + +/// +/// Produced by worksheet readers in preparing (pre-scan) mode instead of individual +/// RowHeaderRecord and CellRecord instances. Contains the maximum row and column indices +/// found in the sheet data, allowing the worksheet constructor to determine FieldCount +/// and RowCount without allocating per-cell objects. +/// +internal sealed class ScanResultRecord(int maxRowIndex, int maxColumnIndex) : Record +{ + public int MaxRowIndex { get; } = maxRowIndex; + + public int MaxColumnIndex { get; } = maxColumnIndex; +} diff --git a/src/ExcelDataReader/Core/OpenXmlFormat/XlsxWorkbook.cs b/src/ExcelDataReader/Core/OpenXmlFormat/XlsxWorkbook.cs index 82983f5e..e0f9d9bb 100644 --- a/src/ExcelDataReader/Core/OpenXmlFormat/XlsxWorkbook.cs +++ b/src/ExcelDataReader/Core/OpenXmlFormat/XlsxWorkbook.cs @@ -24,7 +24,7 @@ public XlsxWorkbook(ZipWorker zipWorker) private List Sheets { get; } = []; - public IEnumerable ReadWorksheets() => Sheets.Select(sheet => new XlsxWorksheet(_zipWorker, this, sheet)); + public IEnumerable ReadWorksheets() => Sheets.Select(sheet => new XlsxWorksheet(_zipWorker, this, sheet, SinglePassMode)); private void ReadWorkbook() { diff --git a/src/ExcelDataReader/Core/OpenXmlFormat/XlsxWorksheet.cs b/src/ExcelDataReader/Core/OpenXmlFormat/XlsxWorksheet.cs index 8eb1e8ca..bbbc640f 100644 --- a/src/ExcelDataReader/Core/OpenXmlFormat/XlsxWorksheet.cs +++ b/src/ExcelDataReader/Core/OpenXmlFormat/XlsxWorksheet.cs @@ -6,7 +6,7 @@ namespace ExcelDataReader.Core.OpenXmlFormat; internal sealed class XlsxWorksheet : IWorksheet { - public XlsxWorksheet(ZipWorker document, XlsxWorkbook workbook, SheetRecord refSheet) + public XlsxWorksheet(ZipWorker document, XlsxWorkbook workbook, SheetRecord refSheet, bool singlePassMode = false) { Document = document; Workbook = workbook; @@ -19,8 +19,8 @@ public XlsxWorksheet(ZipWorker document, XlsxWorkbook workbook, SheetRecord refS if (string.IsNullOrEmpty(Path)) return; - using var sheetStream = Document.GetWorksheetReader(Path, true); - + using var sheetStream = Document.GetWorksheetReader(Path, !singlePassMode); + if (sheetStream == null) return; @@ -30,24 +30,23 @@ public XlsxWorksheet(ZipWorker document, XlsxWorkbook workbook, SheetRecord refS List columnWidths = []; List cellRanges = []; - bool inSheetData = false; + bool stop = false; - while (sheetStream.Read() is { } record) + while (!stop && sheetStream.Read() is { } record) { switch (record) { - case SheetDataBeginRecord _: - inSheetData = true; - break; - case SheetDataEndRecord _: - inSheetData = false; + case SheetDimRecord dimRecord: + Dimension = dimRecord.Range; break; - case RowHeaderRecord row when inSheetData: - rowIndexMaximum = Math.Max(rowIndexMaximum, row.RowIndex); + case SheetDataBeginRecord _ when singlePassMode: + // In single-pass mode stop before reading cells; ReadRows() will be the only pass + stop = true; break; - case CellRecord cell when inSheetData: - if (cell.Value != null || cell.Error != null) - columnIndexMaximum = Math.Max(columnIndexMaximum, cell.ColumnIndex); + case ScanResultRecord scan: + // Multi-pass: sheetData was scanned without allocating per-cell records + rowIndexMaximum = Math.Max(rowIndexMaximum, scan.MaxRowIndex); + columnIndexMaximum = Math.Max(columnIndexMaximum, scan.MaxColumnIndex); break; case ColumnRecord column: columnWidths.Add(column.Column); @@ -65,16 +64,18 @@ public XlsxWorksheet(ZipWorker document, XlsxWorkbook workbook, SheetRecord refS case HeaderFooterRecord headerFooter: HeaderFooter = headerFooter.HeaderFooter; break; - case SheetDimRecord dimRecord: - Dimension = dimRecord.Range; - break; } } ColumnWidths = columnWidths; MergeCells = [.. cellRanges]; - if (rowIndexMaximum != int.MinValue && columnIndexMaximum != int.MinValue) + if (singlePassMode) + { + // In single-pass mode FieldCount comes from the dimension hint only (may be 0 if absent) + FieldCount = columnIndexMaximum != int.MinValue ? columnIndexMaximum + 1 : 0; + } + else if (rowIndexMaximum != int.MinValue && columnIndexMaximum != int.MinValue) { FieldCount = columnIndexMaximum + 1; RowCount = rowIndexMaximum + 1; diff --git a/src/ExcelDataReader/Core/OpenXmlFormat/XmlFormat/XmlWorksheetReader.cs b/src/ExcelDataReader/Core/OpenXmlFormat/XmlFormat/XmlWorksheetReader.cs index 5bec55e0..a412ad4c 100644 --- a/src/ExcelDataReader/Core/OpenXmlFormat/XmlFormat/XmlWorksheetReader.cs +++ b/src/ExcelDataReader/Core/OpenXmlFormat/XmlFormat/XmlWorksheetReader.cs @@ -46,6 +46,8 @@ internal sealed class XmlWorksheetReader(XmlReader reader, bool preparing) : Xml private const string NMergeCell = "mergeCell"; + private const string NDimension = "dimension"; + private const string ACustomHeight = "customHeight"; private const string AHt = "ht"; @@ -65,59 +67,121 @@ protected override IEnumerable ReadOverride() { if (Reader.IsStartElement(NSheetData, ProperNamespaces.NsSpreadsheetMl)) { - yield return new SheetDataBeginRecord(); - if (!XmlReaderHelper.ReadFirstContent(Reader)) + if (preparing) { - yield return new SheetDataEndRecord(); - continue; - } + // Fast pre-scan: read row/column indices directly without allocating per-cell records. + int maxRowIndex = int.MinValue; + int maxColumnIndex = int.MinValue; - int rowIndex = -1; - while (!Reader.EOF) + if (XmlReaderHelper.ReadFirstContent(Reader)) + { + int seqRowIndex = -1; + while (!Reader.EOF) + { + if (Reader.IsStartElement(NRow, ProperNamespaces.NsSpreadsheetMl)) + { + // Mirror ReadOverride: use r attribute if valid, otherwise sequential + if (int.TryParse(Reader.GetAttribute(AR), out int arValue)) + seqRowIndex = arValue - 1; + else + seqRowIndex++; + + maxRowIndex = Math.Max(maxRowIndex, seqRowIndex); + + if (XmlReaderHelper.ReadFirstContent(Reader)) + { + int nextColumnIndex = 0; + while (!Reader.EOF) + { + if (Reader.IsStartElement(NC, ProperNamespaces.NsSpreadsheetMl)) + { + // Mirror ReadCell: use the r attribute if valid, otherwise sequential + int colIndex; + if (ReferenceHelper.ParseReference(Reader.GetAttribute(AR), out int referenceColumn, out _)) + colIndex = referenceColumn - 1; + else + colIndex = nextColumnIndex; + + nextColumnIndex = colIndex + 1; + + // Only non-empty cells count toward the column maximum + if (!Reader.IsEmptyElement) + maxColumnIndex = Math.Max(maxColumnIndex, colIndex); + + Reader.Skip(); + } + else if (!XmlReaderHelper.SkipContent(Reader)) + { + break; + } + } + } + } + else if (!XmlReaderHelper.SkipContent(Reader)) + { + break; + } + } + } + + yield return new ScanResultRecord(maxRowIndex, maxColumnIndex); + } + else { - if (Reader.IsStartElement(NRow, ProperNamespaces.NsSpreadsheetMl)) + yield return new SheetDataBeginRecord(); + if (!XmlReaderHelper.ReadFirstContent(Reader)) + { + yield return new SheetDataEndRecord(); + continue; + } + + int rowIndex = -1; + while (!Reader.EOF) { - if (int.TryParse(Reader.GetAttribute(AR), out int arValue)) - rowIndex = arValue - 1; // The row attribute is 1-based - else - rowIndex++; + if (Reader.IsStartElement(NRow, ProperNamespaces.NsSpreadsheetMl)) + { + if (int.TryParse(Reader.GetAttribute(AR), out int arValue)) + rowIndex = arValue - 1; // The row attribute is 1-based + else + rowIndex++; #pragma warning disable CA1806 // Do not ignore method results - int.TryParse(Reader.GetAttribute(AHidden), out int hidden); - int.TryParse(Reader.GetAttribute(ACustomHeight), out int _); + int.TryParse(Reader.GetAttribute(AHidden), out int hidden); + int.TryParse(Reader.GetAttribute(ACustomHeight), out int _); #pragma warning restore CA1806 // Do not ignore method results - double? height = double.TryParse(Reader.GetAttribute(AHt), NumberStyles.Any, CultureInfo.InvariantCulture, out var ahtValue) ? Math.Abs(ahtValue) : null; - - yield return new RowHeaderRecord(rowIndex, hidden != 0, height); + double? height = double.TryParse(Reader.GetAttribute(AHt), NumberStyles.Any, CultureInfo.InvariantCulture, out var ahtValue) ? Math.Abs(ahtValue) : null; - if (!XmlReaderHelper.ReadFirstContent(Reader)) - { - continue; - } + yield return new RowHeaderRecord(rowIndex, hidden != 0, height); - int nextColumnIndex = 0; - while (!Reader.EOF) - { - if (Reader.IsStartElement(NC, ProperNamespaces.NsSpreadsheetMl)) + if (!XmlReaderHelper.ReadFirstContent(Reader)) { - var cell = ReadCell(nextColumnIndex, ProperNamespaces.NsSpreadsheetMl); - nextColumnIndex = cell.ColumnIndex + 1; - yield return cell; + continue; } - else if (!XmlReaderHelper.SkipContent(Reader)) + + int nextColumnIndex = 0; + while (!Reader.EOF) { - break; + if (Reader.IsStartElement(NC, ProperNamespaces.NsSpreadsheetMl)) + { + var cell = ReadCell(nextColumnIndex, ProperNamespaces.NsSpreadsheetMl); + nextColumnIndex = cell.ColumnIndex + 1; + yield return cell; + } + else if (!XmlReaderHelper.SkipContent(Reader)) + { + break; + } } } + else if (!XmlReaderHelper.SkipContent(Reader)) + { + break; + } } - else if (!XmlReaderHelper.SkipContent(Reader)) - { - break; - } - } - yield return new SheetDataEndRecord(); + yield return new SheetDataEndRecord(); + } } else if (Reader.IsStartElement(NMergeCells, ProperNamespaces.NsSpreadsheetMl)) { @@ -193,6 +257,17 @@ protected override IEnumerable ReadOverride() Reader.Skip(); } + else if (Reader.IsStartElement(NDimension, ProperNamespaces.NsSpreadsheetMl)) + { + var refAttr = Reader.GetAttribute(ARef); + if (refAttr != null) + { + var range = CellRange.Parse(refAttr); + yield return new SheetDimRecord(range); + } + + Reader.Skip(); + } else if (!XmlReaderHelper.SkipContent(Reader)) { break; @@ -259,25 +334,6 @@ private CellRecord ReadCell(int nextColumnIndex, string nsSpreadsheetMl) else columnIndex = nextColumnIndex; - if (preparing) - { - // We only care about columnIndex and if there is any content or not when preparing. - if (!XmlReaderHelper.ReadFirstContent(Reader)) - { - return new CellRecord(columnIndex, 0, null, null); - } - - while (!Reader.EOF) - { - if (!XmlReaderHelper.SkipContent(Reader)) - { - break; - } - } - - return new CellRecord(columnIndex, 0, string.Empty, null); - } - var aS = Reader.GetAttribute(AS); if (aS != null) { diff --git a/src/ExcelDataReader/Core/Row.cs b/src/ExcelDataReader/Core/Row.cs index 2e837b04..5ea5069b 100644 --- a/src/ExcelDataReader/Core/Row.cs +++ b/src/ExcelDataReader/Core/Row.cs @@ -8,4 +8,4 @@ namespace ExcelDataReader.Core; /// The zero-based row index. /// The height of this row in points, zero if hidden or collapsed. /// The cells. -internal sealed record Row(int RowIndex, double Height, List Cells); \ No newline at end of file +internal readonly record struct Row(int RowIndex, double Height, List Cells); \ No newline at end of file diff --git a/src/ExcelDataReader/ExcelBinaryReader.cs b/src/ExcelDataReader/ExcelBinaryReader.cs index f19e50bf..0aa5076d 100644 --- a/src/ExcelDataReader/ExcelBinaryReader.cs +++ b/src/ExcelDataReader/ExcelBinaryReader.cs @@ -6,11 +6,13 @@ namespace ExcelDataReader; internal sealed class ExcelBinaryReader : ExcelDataReader { - public ExcelBinaryReader(Stream stream, string password, Encoding fallbackEncoding, CultureInfo culture = null) + public ExcelBinaryReader(Stream stream, string password, Encoding fallbackEncoding, CultureInfo culture = null, bool singlePassMode = false) { Workbook = new XlsWorkbook(stream, password, fallbackEncoding); Workbook.Culture = culture; - + Workbook.SinglePassMode = singlePassMode; + SinglePassMode = singlePassMode; + // By default, the data reader is positioned on the first result. Reset(); } diff --git a/src/ExcelDataReader/ExcelDataReader.cs b/src/ExcelDataReader/ExcelDataReader.cs index 8767f42a..009855b0 100644 --- a/src/ExcelDataReader/ExcelDataReader.cs +++ b/src/ExcelDataReader/ExcelDataReader.cs @@ -20,6 +20,7 @@ internal abstract class ExcelDataReader : IExcelDataReade private IEnumerator _cachedWorksheetIterator; private List _cachedWorksheets; private int _idx; + private bool _singlePassMode; ~ExcelDataReader() { @@ -47,19 +48,25 @@ internal abstract class ExcelDataReader : IExcelDataReade public bool IsClosed { get; private set; } - public int FieldCount => _worksheetIterator?.Current?.FieldCount ?? 0; + public int FieldCount => _singlePassMode + ? Math.Max(_worksheetIterator?.Current?.FieldCount ?? 0, RowCells?.Length ?? 0) + : (_worksheetIterator?.Current?.FieldCount ?? 0); - public int RowCount => _worksheetIterator?.Current?.RowCount ?? 0; + public int RowCount => _singlePassMode + ? throw new InvalidOperationException("RowCount is not available in SinglePassMode.") + : (_worksheetIterator?.Current?.RowCount ?? 0); public CellRange Dimension => _worksheetIterator?.Current?.Dimension; public int RecordsAffected => throw new NotSupportedException(); - public double RowHeight => _rowIterator?.Current?.Height ?? 0; + public double RowHeight => _rowIterator?.Current.Height ?? 0; + + protected bool SinglePassMode { set => _singlePassMode = value; } protected TWorkbook Workbook { get; set; } - private Cell[] RowCells { get; set; } + private Cell?[] RowCells { get; set; } public object this[int i] => GetValue(i); @@ -347,16 +354,27 @@ private void ResetSheetData() private void ReadCurrentRow() { - RowCells ??= new Cell[FieldCount]; + RowCells ??= new Cell?[FieldCount]; Array.Clear(RowCells, 0, RowCells.Length); foreach (var cell in _rowIterator.Current.Cells) { - if (cell.ColumnIndex < RowCells.Length) + if (cell.ColumnIndex >= RowCells.Length) { - RowCells[cell.ColumnIndex] = cell; + if (_singlePassMode) + { + var resized = RowCells; + Array.Resize(ref resized, cell.ColumnIndex + 1); + RowCells = resized; + } + else + { + continue; + } } + + RowCells[cell.ColumnIndex] = cell; } } } diff --git a/src/ExcelDataReader/ExcelOpenXmlReader.cs b/src/ExcelDataReader/ExcelOpenXmlReader.cs index fbe9c835..accd1be3 100644 --- a/src/ExcelDataReader/ExcelOpenXmlReader.cs +++ b/src/ExcelDataReader/ExcelOpenXmlReader.cs @@ -5,10 +5,12 @@ namespace ExcelDataReader; internal sealed class ExcelOpenXmlReader : ExcelDataReader { - public ExcelOpenXmlReader(Stream stream, CultureInfo culture = null) + public ExcelOpenXmlReader(Stream stream, CultureInfo culture = null, bool singlePassMode = false) { Document = new(stream); Workbook = new XlsxWorkbook(Document); + Workbook.SinglePassMode = singlePassMode; + SinglePassMode = singlePassMode; Workbook.Culture = culture; // By default, the data reader is positioned on the first result. diff --git a/src/ExcelDataReader/ExcelReaderConfiguration.cs b/src/ExcelDataReader/ExcelReaderConfiguration.cs index 256a5fdf..21ffec1a 100644 --- a/src/ExcelDataReader/ExcelReaderConfiguration.cs +++ b/src/ExcelDataReader/ExcelReaderConfiguration.cs @@ -46,6 +46,13 @@ public class ExcelReaderConfiguration /// public int AnalyzeInitialCsvRows { get; set; } + /// + /// Gets or sets a value indicating whether to skip the initial full-scan pass used to determine FieldCount and RowCount. + /// When true, RowCount throws InvalidOperationException and FieldCount reflects the maximum column index seen so far, + /// growing dynamically as rows are read. Default: false (XLS and XLSX/XLSB only, has no effect on CSV). + /// + public bool SinglePassMode { 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). diff --git a/src/ExcelDataReader/ExcelReaderFactory.cs b/src/ExcelDataReader/ExcelReaderFactory.cs index e1fe49ed..9bd5a104 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, configuration.Culture); + return new ExcelBinaryReader(stream, configuration.Password, configuration.FallbackEncoding, configuration.Culture, configuration.SinglePassMode); } if (TryGetEncryptedPackage(fileStream, document, configuration.Password, out stream)) { - return new ExcelOpenXmlReader(stream, configuration.Culture); + return new ExcelOpenXmlReader(stream, configuration.Culture, configuration.SinglePassMode); } 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, configuration.Culture); + return new ExcelBinaryReader(fileStream, configuration.Password, configuration.FallbackEncoding, configuration.Culture, configuration.SinglePassMode); } if (probe[0] == 0x50 && probe[1] == 0x4B) { // zip files start with 'PK' - return new ExcelOpenXmlReader(fileStream, configuration.Culture); + return new ExcelOpenXmlReader(fileStream, configuration.Culture, configuration.SinglePassMode); } 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, configuration.Culture); + return new ExcelBinaryReader(stream, configuration.Password, configuration.FallbackEncoding, configuration.Culture, configuration.SinglePassMode); } 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, configuration.Culture); + return new ExcelBinaryReader(fileStream, configuration.Password, configuration.FallbackEncoding, configuration.Culture, configuration.SinglePassMode); } 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, configuration.Culture); + return new ExcelOpenXmlReader(stream, configuration.Culture, configuration.SinglePassMode); } 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, configuration.Culture); + return new ExcelOpenXmlReader(fileStream, configuration.Culture, configuration.SinglePassMode); } throw new HeaderException(Errors.ErrorHeaderSignature); From 2d7e8e28a1441f5f618f981fded8089fc944dfd2 Mon Sep 17 00:00:00 2001 From: Johan Appelgren Date: Thu, 9 Apr 2026 13:19:33 +0200 Subject: [PATCH 15/19] Reduce allocations a little --- .../ReadAllStrings.cs | 56 ++++++++++++ .../ExcelBinaryReaderTest.cs | 19 ++++ .../ExcelOpenXmlReaderBase.cs | 15 ++++ .../Core/BinaryFormat/XlsBiffSST.cs | 27 ++++-- .../Core/BinaryFormat/XlsBiffStream.cs | 87 ++++++++++++------- .../Core/BinaryFormat/XlsByteString.cs | 11 +-- .../BinaryFormat/XlsShortUnicodeString.cs | 22 +++-- .../Core/BinaryFormat/XlsUnicodeString.cs | 20 +++-- .../Core/BinaryFormat/XlsWorksheet.cs | 17 +--- src/ExcelDataReader/Core/CommonWorkbook.cs | 1 + .../Core/CsvFormat/CsvParser.cs | 2 +- .../Core/OfficeCrypto/CryptoHelpers.cs | 7 ++ .../OpenXmlFormat/BinaryFormat/BiffReader.cs | 38 +++++--- .../OpenXmlFormat/Records/SstCountRecord.cs | 6 ++ .../Core/OpenXmlFormat/XlsxWorkbook.cs | 9 +- .../XmlFormat/XmlSharedStringsReader.cs | 7 ++ 16 files changed, 257 insertions(+), 87 deletions(-) create mode 100644 src/ExcelDataReader.Benchmarks/ReadAllStrings.cs create mode 100644 src/ExcelDataReader/Core/OpenXmlFormat/Records/SstCountRecord.cs diff --git a/src/ExcelDataReader.Benchmarks/ReadAllStrings.cs b/src/ExcelDataReader.Benchmarks/ReadAllStrings.cs new file mode 100644 index 00000000..17e385fa --- /dev/null +++ b/src/ExcelDataReader.Benchmarks/ReadAllStrings.cs @@ -0,0 +1,56 @@ +using System.Text; +using BenchmarkDotNet.Attributes; + +namespace ExcelDataReader.Benchmarks; + +/// +/// Benchmarks for reading all string cell values, exercising SST lookup performance. +/// For XLS, this validates the lazy string cache (issue #525): strings are decoded and cached +/// on first access in GetString(), releasing the raw byte[] backing after the first lookup. +/// Repeated access of the same SST index returns the cached string with zero allocation. +/// +[MemoryDiagnoser] +public class ReadAllStrings +{ + [GlobalSetup] + public void Setup() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + } + + [Benchmark] + public string ReadAllStringsXlsx() + { + return ReadStrings(ExcelReaderFactory.CreateReader( + typeof(OpenXmlFile).Assembly.GetManifestResourceStream("ExcelDataReader.Benchmarks.Test10x10000.xlsx"))); + } + + [Benchmark] + public string ReadAllStringsXlsb() + { + return ReadStrings(ExcelReaderFactory.CreateReader( + typeof(OpenXmlFile).Assembly.GetManifestResourceStream("ExcelDataReader.Benchmarks.Test10x10000.xlsb"))); + } + + [Benchmark] + public string ReadAllStringsXls() + { + return ReadStrings(ExcelReaderFactory.CreateReader( + typeof(OpenXmlFile).Assembly.GetManifestResourceStream("ExcelDataReader.Benchmarks.Test10x10000.xls"))); + } + + private static string ReadStrings(IExcelDataReader reader) + { + using (reader) + { + string last = null; + while (reader.Read()) + { + for (var i = 0; i < reader.FieldCount; i++) + last = reader.GetString(i); + } + + return last; + } + } +} diff --git a/src/ExcelDataReader.Tests/ExcelBinaryReaderTest.cs b/src/ExcelDataReader.Tests/ExcelBinaryReaderTest.cs index d15c7f01..b702b050 100644 --- a/src/ExcelDataReader.Tests/ExcelBinaryReaderTest.cs +++ b/src/ExcelDataReader.Tests/ExcelBinaryReaderTest.cs @@ -1093,6 +1093,25 @@ public void GitIssue642_ActiveSheet_SingleWorksheet() Assert.That(dataSet.Tables[0].TableName, Is.EqualTo("List1")); } + [Test] + public void GitIssue525_SstMaterializationReturnsCorrectStrings() + { + // Verify SST strings are correctly accessible after lazy caching is initialized. + // XlsWorkbook.ReadWorkbookGlobals() calls SST.Flush() which allocates the cache array. + using var reader = ExcelReaderFactory.CreateBinaryReader(Configuration.GetTestWorkbook("Test10x10.xls")); + reader.Read(); + Assert.That(reader.GetString(0), Is.EqualTo("col1")); + Assert.That(reader.GetString(4), Is.EqualTo("col5")); + Assert.That(reader.GetString(9), Is.Null); // column 9 is empty in first row + + // Repeated lookups of the same SST index must return the same value + Assert.That(reader.GetString(0), Is.EqualTo("col1")); + + // Verify subsequent rows also resolve correctly + reader.Read(); + Assert.That(reader.GetString(0), Is.EqualTo("10x10")); + } + [Test] public void ClipboardDimension() { diff --git a/src/ExcelDataReader.Tests/ExcelOpenXmlReaderBase.cs b/src/ExcelDataReader.Tests/ExcelOpenXmlReaderBase.cs index a1ba31db..d9fef677 100644 --- a/src/ExcelDataReader.Tests/ExcelOpenXmlReaderBase.cs +++ b/src/ExcelDataReader.Tests/ExcelOpenXmlReaderBase.cs @@ -4,6 +4,21 @@ namespace ExcelDataReader.Tests; public abstract class ExcelOpenXmlReaderBase : ExcelTestBase { + [Test] + public void GitIssue525_XlsxSstPreallocationReturnsCorrectStrings() + { + // Verify that pre-allocating XlsxSST based on uniqueCount doesn't break SST string resolution. + // XlsxWorkbook.ReadSharedStrings() sets SST.Capacity = uniqueCount from + using var reader = OpenReader("Test10x10"); + reader.Read(); + Assert.That(reader.GetString(0), Is.EqualTo("col1")); + Assert.That(reader.GetString(4), Is.EqualTo("col5")); + Assert.That(reader.GetString(9), Is.Null); // column 9 is empty in first row + + reader.Read(); + Assert.That(reader.GetString(0), Is.EqualTo("10x10")); + } + [Test] public void GitIssue14InvalidOADate() { diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffSST.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsBiffSST.cs index 98c6f0e0..1fad9bf9 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffSST.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsBiffSST.cs @@ -7,8 +7,9 @@ namespace ExcelDataReader.Core.BinaryFormat; /// internal sealed class XlsBiffSST : XlsBiffRecord { - private readonly List _strings = []; private readonly XlsSSTReader _reader = new(); + private string[] _materializedStrings; + private List _strings = []; internal XlsBiffSST(byte[] bytes) : base(bytes) @@ -54,20 +55,36 @@ public void Flush() { _strings.Add(str); } + + _materializedStrings = new string[_strings.Count]; } /// - /// Returns string at specified index. + /// Returns string at specified index, caching it on first access and releasing the raw byte buffer. /// /// Index of string to get. /// Workbook encoding. /// string value if it was found, empty string otherwise. public string GetString(uint sstIndex, Encoding encoding) { - if (sstIndex < _strings.Count) - return _strings[(int)sstIndex].GetValue(encoding); + if (_materializedStrings == null) + { + if (sstIndex < _strings.Count) + return _strings[(int)sstIndex].GetValue(encoding); + return null; + } + + if (sstIndex >= (uint)_materializedStrings.Length) + return null; + + var cached = _materializedStrings[sstIndex]; + if (cached != null) + return cached; - return null; // #VALUE error + var s = _strings[(int)sstIndex].GetValue(encoding); + _materializedStrings[sstIndex] = s; + _strings[(int)sstIndex] = null; + return s; } /// diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffStream.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsBiffStream.cs index 59af92f5..7d913911 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffStream.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsBiffStream.cs @@ -323,8 +323,20 @@ private void CreateBlockDecryptor(int blockNumber) /// private void AlignBlockDecryptor(int blockOffset) { +#if NETSTANDARD2_1_OR_GREATER || NET8_0_OR_GREATER + var bytes = System.Buffers.ArrayPool.Shared.Rent(blockOffset); + try + { + CryptoHelpers.DecryptBytes(CipherTransform, bytes, blockOffset); + } + finally + { + System.Buffers.ArrayPool.Shared.Return(bytes); + } +#else var bytes = new byte[blockOffset]; - CryptoHelpers.DecryptBytes(CipherTransform, bytes, bytes.Length); + CryptoHelpers.DecryptBytes(CipherTransform, bytes, blockOffset); +#endif } private void DecryptRecord(int startPosition, BIFFRECORDTYPE id, byte[] bytes, int recordSize) @@ -343,44 +355,57 @@ private void DecryptRecord(int startPosition, BIFFRECORDTYPE id, byte[] bytes, i break; } - var position = 0; - while (position < recordSize) + // Max chunk size per iteration is 1024 (one encryption block boundary). + // Rent both buffers once and reuse across iterations to avoid per-iteration allocations. +#if NETSTANDARD2_1_OR_GREATER || NET8_0_OR_GREATER + var inputBlock = System.Buffers.ArrayPool.Shared.Rent(1024); + var outputBlock = System.Buffers.ArrayPool.Shared.Rent(1024); +#else + var inputBlock = new byte[1024]; + var outputBlock = new byte[1024]; +#endif + try { - var offset = startPosition + position; - int blockNumber = offset / 1024; - var blockOffset = offset % 1024; - - if (blockNumber != CipherBlock) + var position = 0; + while (position < recordSize) { - CreateBlockDecryptor(blockNumber); - } + var offset = startPosition + position; + int blockNumber = offset / 1024; + var blockOffset = offset % 1024; - if (Encryption.IsXor) - { - // Bypass everything and hook into the XorTransform instance to set the XorArrayIndex pr record. - // This is a hack to use the XorTransform otherwise transparently to the other encryption methods. - var xorTransform = (XorManaged.XorTransform)CipherTransform; - xorTransform.XorArrayIndex = offset + recordSize - 4; - } + if (blockNumber != CipherBlock) + { + CreateBlockDecryptor(blockNumber); + } - // Decrypt at most up to the next 1024 byte boundary - var chunkSize = Math.Min(recordSize - position, 1024 - blockOffset); + if (Encryption.IsXor) + { + // Bypass everything and hook into the XorTransform instance to set the XorArrayIndex pr record. + // This is a hack to use the XorTransform otherwise transparently to the other encryption methods. + var xorTransform = (XorManaged.XorTransform)CipherTransform; + xorTransform.XorArrayIndex = offset + recordSize - 4; + } -#if NETSTANDARD2_1_OR_GREATER || NET8_0_OR_GREATER - var block = System.Buffers.ArrayPool.Shared.Rent(chunkSize); -#else - var block = new byte[chunkSize]; -#endif + // Decrypt at most up to the next 1024 byte boundary + var chunkSize = Math.Min(recordSize - position, 1024 - blockOffset); - Array.Copy(bytes, position, block, 0, chunkSize); + Array.Copy(bytes, position, inputBlock, 0, chunkSize); + CryptoHelpers.DecryptBytes(CipherTransform, inputBlock, chunkSize, outputBlock); - var decryptedblock = CryptoHelpers.DecryptBytes(CipherTransform, block, chunkSize); - for (var i = 0; i < decryptedblock.Length; i++) - { - if (position >= startDecrypt) - bytes[position] = decryptedblock[i]; - position++; + for (var i = 0; i < chunkSize; i++) + { + if (position >= startDecrypt) + bytes[position] = outputBlock[i]; + position++; + } } } + finally + { +#if NETSTANDARD2_1_OR_GREATER || NET8_0_OR_GREATER + System.Buffers.ArrayPool.Shared.Return(inputBlock); + System.Buffers.ArrayPool.Shared.Return(outputBlock); +#endif + } } } diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsByteString.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsByteString.cs index 6c7b3002..ec5eb603 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsByteString.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsByteString.cs @@ -20,14 +20,7 @@ internal sealed class XlsByteString(byte[] bytes, uint offset) : IXlsString /// public string GetValue(Encoding encoding) { - var stringBytes = ReadArray(0x2, CharacterCount * (Helpers.IsSingleByteEncoding(encoding) ? 1 : 2)); - return encoding.GetString(stringBytes, 0, stringBytes.Length); - } - - public byte[] ReadArray(int offset, int size) - { - byte[] tmp = new byte[size]; - Buffer.BlockCopy(_bytes, (int)(_offset + offset), tmp, 0, size); - return tmp; + int byteCount = CharacterCount * (Helpers.IsSingleByteEncoding(encoding) ? 1 : 2); + return encoding.GetString(_bytes, (int)_offset + 2, byteCount); } } \ No newline at end of file diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsShortUnicodeString.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsShortUnicodeString.cs index 78821e97..9fac52ed 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsShortUnicodeString.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsShortUnicodeString.cs @@ -1,4 +1,4 @@ -using System.Text; +using System.Text; namespace ExcelDataReader.Core.BinaryFormat; @@ -30,12 +30,20 @@ public string GetValue(Encoding encoding) return Encoding.Unicode.GetString(_bytes, (int)_offset + 2, CharacterCount * 2); } - byte[] bytes = new byte[CharacterCount * 2]; - for (int i = 0; i < CharacterCount; i++) + // In BIFF8 single-byte strings each byte value IS the Unicode code point, + // so a direct (char)byte cast is the exact and encoding-free conversion. +#if NETSTANDARD2_1_OR_GREATER || NET8_0_OR_GREATER + return string.Create(CharacterCount, (Bytes: _bytes, Start: (int)_offset + 2), static (span, state) => { - bytes[i * 2] = _bytes[_offset + 2 + i]; - } - - return Encoding.Unicode.GetString(bytes); + for (int i = 0; i < span.Length; i++) + span[i] = (char)state.Bytes[state.Start + i]; + }); +#else + var chars = new char[CharacterCount]; + int start = (int)_offset + 2; + for (int i = 0; i < CharacterCount; i++) + chars[i] = (char)_bytes[start + i]; + return new string(chars); +#endif } } diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsUnicodeString.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsUnicodeString.cs index 656fe54f..340dff83 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsUnicodeString.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsUnicodeString.cs @@ -30,12 +30,20 @@ public string GetValue(Encoding encoding) return Encoding.Unicode.GetString(_bytes, (int)_offset + 3, CharacterCount * 2); } - byte[] bytes = new byte[CharacterCount * 2]; - for (int i = 0; i < CharacterCount; i++) + // In BIFF8 single-byte strings each byte value IS the Unicode code point, + // so a direct (char)byte cast is the exact and encoding-free conversion. +#if NETSTANDARD2_1_OR_GREATER || NET8_0_OR_GREATER + return string.Create(CharacterCount, (Bytes: _bytes, Start: (int)_offset + 3), static (span, state) => { - bytes[i * 2] = _bytes[_offset + 3 + i]; - } - - return Encoding.Unicode.GetString(bytes); + for (int i = 0; i < span.Length; i++) + span[i] = (char)state.Bytes[state.Start + i]; + }); +#else + var chars = new char[CharacterCount]; + int start = (int)_offset + 3; + for (int i = 0; i < CharacterCount; i++) + chars[i] = (char)_bytes[start + i]; + return new string(chars); +#endif } } diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsWorksheet.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsWorksheet.cs index f6fe6113..2c669c2a 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsWorksheet.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsWorksheet.cs @@ -1,5 +1,4 @@ using System.Text; -using ExcelDataReader.Log; namespace ExcelDataReader.Core.BinaryFormat; @@ -228,8 +227,7 @@ private void ReadNextBlock(XlsBiffStream biffStream, int startRow, int rows, int if (cell.Id == BIFFRECORDTYPE.MULRK) { - var cellValues = ReadMultiCell(cell); - cellList.AddRange(cellValues); + ReadMultiCell(cell, cellList); } else { @@ -276,24 +274,19 @@ private List EnsureRow(Row?[] result, int startRow, int rowIndex, List ReadMultiCell(XlsBiffBlankCell cell) + private void ReadMultiCell(XlsBiffBlankCell cell, List cellList) { - LogManager.Log(this).Debug("ReadMultiCell {0}", cell.Id); - switch (cell.Id) { case BIFFRECORDTYPE.MULRK: - XlsBiffMulRKCell rkCell = (XlsBiffMulRKCell)cell; ushort lastColumnIndex = rkCell.LastColumnIndex; for (ushort j = cell.ColumnIndex; j <= lastColumnIndex; j++) { var xfIndex = rkCell.GetXF(j); var effectiveStyle = Workbook.GetEffectiveCellStyle(xfIndex, cell.Format); - var value = TryConvertOADateTime(rkCell.GetValue(j), effectiveStyle.NumberFormatIndex); - LogManager.Log(this).Debug("CELL[{0}] = {1}", j, value); - yield return new Cell(j, value, effectiveStyle, null); + cellList.Add(new Cell(j, value, effectiveStyle, null)); } break; @@ -305,8 +298,6 @@ private IEnumerable ReadMultiCell(XlsBiffBlankCell cell) /// private Cell ReadSingleCell(XlsBiffStream biffStream, XlsBiffBlankCell cell, int xfIndex) { - LogManager.Log(this).Debug("ReadSingleCell {0}", cell.Id); - var effectiveStyle = Workbook.GetEffectiveCellStyle(xfIndex, cell.Format); var numberFormatIndex = effectiveStyle.NumberFormatIndex; @@ -697,7 +688,7 @@ private IEnumerable ReadWorksheetRowsSinglePass(XlsBiffStream biffStream) { if (cell.Id == BIFFRECORDTYPE.MULRK) { - currentRowCells.AddRange(ReadMultiCell(cell)); + ReadMultiCell(cell, currentRowCells); } else { diff --git a/src/ExcelDataReader/Core/CommonWorkbook.cs b/src/ExcelDataReader/Core/CommonWorkbook.cs index 74a6290d..6a740cc2 100644 --- a/src/ExcelDataReader/Core/CommonWorkbook.cs +++ b/src/ExcelDataReader/Core/CommonWorkbook.cs @@ -72,6 +72,7 @@ public NumberFormatString GetNumberFormatString(int numberFormatIndex) #pragma warning restore CA1304 if (numberFormat != null) { + Formats[numberFormatIndex] = numberFormat; // cache for future lookups return numberFormat; } diff --git a/src/ExcelDataReader/Core/CsvFormat/CsvParser.cs b/src/ExcelDataReader/Core/CsvFormat/CsvParser.cs index dd3b5869..bf198ac5 100644 --- a/src/ExcelDataReader/Core/CsvFormat/CsvParser.cs +++ b/src/ExcelDataReader/Core/CsvFormat/CsvParser.cs @@ -236,7 +236,7 @@ private bool ReadLinebreak(char c) private void AddValueToRow() { RowResult.Add(ValueResult.ToString(0, ValueResult.Length - TrailingWhitespaceCount)); - ValueResult = new StringBuilder(); + ValueResult.Clear(); TrailingWhitespaceCount = 0; } diff --git a/src/ExcelDataReader/Core/OfficeCrypto/CryptoHelpers.cs b/src/ExcelDataReader/Core/OfficeCrypto/CryptoHelpers.cs index d67da26f..cba4d201 100644 --- a/src/ExcelDataReader/Core/OfficeCrypto/CryptoHelpers.cs +++ b/src/ExcelDataReader/Core/OfficeCrypto/CryptoHelpers.cs @@ -81,4 +81,11 @@ public static byte[] DecryptBytes(ICryptoTransform transform, byte[] bytes, int csDecrypt.ReadAtLeast(result, 0, length); return result; } + + public static void DecryptBytes(ICryptoTransform transform, byte[] bytes, int chunkSize, byte[] output) + { + using MemoryStream msDecrypt = new(bytes, 0, chunkSize); + using CryptoStream csDecrypt = new(msDecrypt, transform, CryptoStreamMode.Read); + csDecrypt.ReadAtLeast(output, 0, chunkSize); + } } diff --git a/src/ExcelDataReader/Core/OpenXmlFormat/BinaryFormat/BiffReader.cs b/src/ExcelDataReader/Core/OpenXmlFormat/BinaryFormat/BiffReader.cs index 18ef788f..56ebe3fb 100644 --- a/src/ExcelDataReader/Core/OpenXmlFormat/BinaryFormat/BiffReader.cs +++ b/src/ExcelDataReader/Core/OpenXmlFormat/BinaryFormat/BiffReader.cs @@ -18,11 +18,28 @@ internal abstract class BiffReader(Stream stream) : RecordReader !TryReadVariableValue(out var recordLength)) return null; +#if NETSTANDARD2_1_OR_GREATER || NET8_0_OR_GREATER + bool rented = recordLength >= _buffer.Length; + byte[] buffer = rented + ? System.Buffers.ArrayPool.Shared.Rent((int)recordLength) + : _buffer; +#else byte[] buffer = recordLength < _buffer.Length ? _buffer : new byte[recordLength]; - if (Stream.ReadAtLeast(buffer, 0, (int)recordLength) != recordLength) - return null; +#endif + try + { + if (Stream.ReadAtLeast(buffer, 0, (int)recordLength) != recordLength) + return null; - return ReadOverride(buffer, recordId, recordLength); + return ReadOverride(buffer, recordId, recordLength); + } + finally + { +#if NETSTANDARD2_1_OR_GREATER || NET8_0_OR_GREATER + if (rented) + System.Buffers.ArrayPool.Shared.Return(buffer); +#endif + } } protected static uint GetDWord(byte[] buffer, uint offset) @@ -51,12 +68,7 @@ protected static ushort GetWord(byte[] buffer, uint offset) } protected static string GetString(byte[] buffer, uint offset, uint length) - { - StringBuilder sb = new((int)length); - for (uint i = offset; i < offset + 2 * length; i += 2) - sb.Append((char)GetWord(buffer, i)); - return sb.ToString(); - } + => Encoding.Unicode.GetString(buffer, (int)offset, (int)(length * 2)); protected static string? GetNullableString(byte[] buffer, ref uint offset) { @@ -64,11 +76,9 @@ protected static string GetString(byte[] buffer, uint offset, uint length) offset += 4; if (length == uint.MaxValue) return null; - StringBuilder sb = new((int)length); - uint end = offset + length * 2; - for (; offset < end; offset += 2) - sb.Append((char)GetWord(buffer, offset)); - return sb.ToString(); + string result = Encoding.Unicode.GetString(buffer, (int)offset, (int)(length * 2)); + offset += length * 2; + return result; } protected static double GetRkNumber(byte[] buffer, uint offset) diff --git a/src/ExcelDataReader/Core/OpenXmlFormat/Records/SstCountRecord.cs b/src/ExcelDataReader/Core/OpenXmlFormat/Records/SstCountRecord.cs new file mode 100644 index 00000000..0b075233 --- /dev/null +++ b/src/ExcelDataReader/Core/OpenXmlFormat/Records/SstCountRecord.cs @@ -0,0 +1,6 @@ +namespace ExcelDataReader.Core.OpenXmlFormat.Records; + +internal sealed class SstCountRecord(int uniqueCount) : Record +{ + public int UniqueCount { get; } = uniqueCount; +} diff --git a/src/ExcelDataReader/Core/OpenXmlFormat/XlsxWorkbook.cs b/src/ExcelDataReader/Core/OpenXmlFormat/XlsxWorkbook.cs index e0f9d9bb..68f9a194 100644 --- a/src/ExcelDataReader/Core/OpenXmlFormat/XlsxWorkbook.cs +++ b/src/ExcelDataReader/Core/OpenXmlFormat/XlsxWorkbook.cs @@ -24,7 +24,11 @@ public XlsxWorkbook(ZipWorker zipWorker) private List Sheets { get; } = []; - public IEnumerable ReadWorksheets() => Sheets.Select(sheet => new XlsxWorksheet(_zipWorker, this, sheet, SinglePassMode)); + public IEnumerable ReadWorksheets() + { + foreach (var sheet in Sheets) + yield return new XlsxWorksheet(_zipWorker, this, sheet, SinglePassMode); + } private void ReadWorkbook() { @@ -57,6 +61,9 @@ private void ReadSharedStrings() { switch (record) { + case SstCountRecord countRecord: + SST.Capacity = countRecord.UniqueCount; + break; case SharedStringRecord pr: SST.Add(pr.Value); break; diff --git a/src/ExcelDataReader/Core/OpenXmlFormat/XmlFormat/XmlSharedStringsReader.cs b/src/ExcelDataReader/Core/OpenXmlFormat/XmlFormat/XmlSharedStringsReader.cs index 3165d058..f019fb71 100644 --- a/src/ExcelDataReader/Core/OpenXmlFormat/XmlFormat/XmlSharedStringsReader.cs +++ b/src/ExcelDataReader/Core/OpenXmlFormat/XmlFormat/XmlSharedStringsReader.cs @@ -7,6 +7,7 @@ internal sealed class XmlSharedStringsReader(XmlReader reader) : XmlRecordReader { private const string ElementSst = "sst"; private const string ElementStringItem = "si"; + private const string AttributeUniqueCount = "uniqueCount"; protected override IEnumerable ReadOverride() { @@ -15,6 +16,12 @@ protected override IEnumerable ReadOverride() yield break; } + var uniqueCountStr = Reader.GetAttribute(AttributeUniqueCount); + if (int.TryParse(uniqueCountStr, out var uniqueCount) && uniqueCount > 0) + { + yield return new SstCountRecord(uniqueCount); + } + if (!XmlReaderHelper.ReadFirstContent(Reader)) { yield break; From 107def84cdd08b9e98e83b141e55f87a95ddfe7e Mon Sep 17 00:00:00 2001 From: Johan Appelgren Date: Thu, 9 Apr 2026 23:59:15 +0200 Subject: [PATCH 16/19] Various performance improvements --- .../ExcelDataReader.Benchmarks.csproj | 9 +- .../Core/BinaryFormat/XlsBiffRecord.cs | 15 +++- .../Core/BinaryFormat/XlsBiffStream.cs | 82 ++++++++++++++++-- .../Core/BinaryFormat/XlsWorksheet.cs | 60 ++++++++++--- .../Core/CompoundFormat/CompoundStream.cs | 25 +++--- src/ExcelDataReader/Core/Helpers.cs | 5 ++ .../OpenXmlFormat/BinaryFormat/BiffReader.cs | 14 +++- .../XmlFormat/XmlSharedStringsReader.cs | 2 +- .../XmlFormat/XmlWorksheetReader.cs | 18 ++-- .../Core/OpenXmlFormat/ZipWorker.cs | 84 ++++++++++++++++--- 10 files changed, 256 insertions(+), 58 deletions(-) diff --git a/src/ExcelDataReader.Benchmarks/ExcelDataReader.Benchmarks.csproj b/src/ExcelDataReader.Benchmarks/ExcelDataReader.Benchmarks.csproj index 1b67a3d7..f5c1b95d 100644 --- a/src/ExcelDataReader.Benchmarks/ExcelDataReader.Benchmarks.csproj +++ b/src/ExcelDataReader.Benchmarks/ExcelDataReader.Benchmarks.csproj @@ -15,14 +15,15 @@ + - - - - + + + + \ No newline at end of file diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffRecord.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsBiffRecord.cs index ae99991a..5026113b 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffRecord.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsBiffRecord.cs @@ -6,8 +6,16 @@ namespace ExcelDataReader.Core.BinaryFormat; /// internal class XlsBiffRecord { + // Records with a total buffer size at or below this threshold are allocated with + // new byte[] rather than rented from ArrayPool. The pool's per-operation overhead + // (bucket lookup + thread-local push/pop) exceeds the benefit for tiny buffers. + // Rented buffers always have Length > SmallRecordPoolThreshold because ArrayPool + // rounds up to the next power-of-two bucket (≥128 when totalSize > 64), so the + // check in Return() reliably distinguishes heap-allocated from pool-rented arrays. + internal const int SmallRecordPoolThreshold = 64; + protected const int ContentOffset = 4; - + public XlsBiffRecord(byte[] bytes) { if (bytes.Length < 4) @@ -35,7 +43,10 @@ public XlsBiffRecord(byte[] bytes) #if NETSTANDARD2_1_OR_GREATER || NET8_0_OR_GREATER public virtual void Return() { - System.Buffers.ArrayPool.Shared.Return(Bytes); + // Only buffers rented from ArrayPool need to be returned. + // Heap-allocated small records (Bytes.Length <= SmallRecordPoolThreshold) are GC-managed. + if (Bytes.Length > SmallRecordPoolThreshold) + System.Buffers.ArrayPool.Shared.Return(Bytes); } #endif diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffStream.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsBiffStream.cs index 7d913911..a4715340 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffStream.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsBiffStream.cs @@ -10,7 +10,17 @@ namespace ExcelDataReader.Core.BinaryFormat; /// internal sealed class XlsBiffStream : IDisposable { - private byte[] _headerBuffer = new byte[4]; + // Read-ahead buffer: a single BaseStream.Read() fills 1 024 bytes that then serve + // ~70 sequential small records (avg 14 bytes each) without further CompoundStream calls. + // Any Seek() sets _readAheadStart == _readAheadEnd to invalidate it. + // Uses only byte[], Buffer.BlockCopy and Stream.Read — available on all target frameworks. + private const int ReadAheadSize = 1024; + + private readonly byte[] _headerBuffer = new byte[4]; + + private readonly byte[] _readAheadBuffer = new byte[ReadAheadSize]; + private int _readAheadStart; + private int _readAheadEnd; public XlsBiffStream(Stream baseStream, int offset = 0, int explicitVersion = 0, BIFFTYPE? defaultType = null, string password = null, byte[] secretKey = null, EncryptionInfo encryption = null) { @@ -78,7 +88,11 @@ record = Read(); /// /// Gets or sets the current position in BIFF stream. /// - public int Position { get => (int)BaseStream.Position; set => Seek(value, SeekOrigin.Begin); } + public int Position + { + get => (int)BaseStream.Position - (_readAheadEnd - _readAheadStart); + set => Seek(value, SeekOrigin.Begin); + } public Stream BaseStream { get; } @@ -105,6 +119,9 @@ record = Read(); /// Offset origin. public void Seek(int offset, SeekOrigin origin) { + // Discard buffered bytes: they belong to the old position. + _readAheadStart = 0; + _readAheadEnd = 0; BaseStream.Seek(offset, origin); if (Position < 0) @@ -146,20 +163,28 @@ record = null; /// The record -or- null. public XlsBiffRecord GetRecord(Stream stream) { - var recordOffset = (int)stream.Position; - stream.ReadAtLeast(_headerBuffer, 0, 4); + // Capture the logical record start before consuming header bytes from the read-ahead + // buffer; this is the value DecryptRecord() needs for its block-number calculation. + var recordOffset = Position; + ReadFromReadAhead(_headerBuffer, 0, 4); // Does this work on a big endian system? var id = (BIFFRECORDTYPE)BitConverter.ToUInt16(_headerBuffer, 0); ushort recordSize = BitConverter.ToUInt16(_headerBuffer, 2); #if NETSTANDARD2_1_OR_GREATER || NET8_0_OR_GREATER - var bytes = System.Buffers.ArrayPool.Shared.Rent(4 + recordSize); + // Records at or below SmallRecordPoolThreshold bytes use a plain heap allocation. + // ArrayPool.Rent+Return overhead exceeds the benefit for these tiny buffers. + // Rented buffers always have Length > SmallRecordPoolThreshold (pool rounds up to + // the next power-of-two bucket), so XlsBiffRecord.Return() can tell them apart. + var bytes = (4 + recordSize) <= XlsBiffRecord.SmallRecordPoolThreshold + ? new byte[4 + recordSize] + : System.Buffers.ArrayPool.Shared.Rent(4 + recordSize); #else var bytes = new byte[4 + recordSize]; #endif Array.Copy(_headerBuffer, bytes, 4); - stream.ReadAtLeast(bytes, 4, recordSize); + ReadFromReadAhead(bytes, 4, recordSize); if (SecretKey != null) DecryptRecord(recordOffset, id, bytes, 4 + recordSize); @@ -306,6 +331,51 @@ private static int GetBiffVersion(XlsBiffBOF bof) return 0; } + // Serves 'count' bytes into dest[destOffset..], draining the read-ahead buffer first. + // When the buffer is exhausted it is refilled with a single BaseStream.Read(1024). + // For large bodies that exceed the remaining buffer the tail is read directly from + // BaseStream in one call (avoids per-1024-chunk overhead for SST / other big records). + private void ReadFromReadAhead(byte[] dest, int destOffset, int count) + { + // Fast path: serve from the in-memory buffer when possible. + int available = _readAheadEnd - _readAheadStart; + if (available > 0) + { + int fromBuffer = Math.Min(available, count); + Buffer.BlockCopy(_readAheadBuffer, _readAheadStart, dest, destOffset, fromBuffer); + _readAheadStart += fromBuffer; + + if (fromBuffer == count) + return; + + destOffset += fromBuffer; + count -= fromBuffer; + } + + // Buffer was exhausted before satisfying the request. + // For small leftovers refill the buffer and serve from it; + // for large reads go directly to BaseStream to avoid per-chunk overhead. + if (count <= ReadAheadSize) + { + _readAheadStart = 0; + _readAheadEnd = BaseStream.Read(_readAheadBuffer, 0, ReadAheadSize); + if (_readAheadEnd < count) + throw new EndOfStreamException(); + + Buffer.BlockCopy(_readAheadBuffer, 0, dest, destOffset, count); + _readAheadStart = count; + } + else + { + // Large record body: read directly into the caller's buffer. + // The read-ahead buffer remains empty (_readAheadStart == _readAheadEnd == 0) + // and will be refilled on the next call. + _readAheadStart = 0; + _readAheadEnd = 0; + BaseStream.ReadAtLeast(dest, destOffset, count); + } + } + /// /// Create an ICryptoTransform instance to decrypt a 1024-byte block. /// diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsWorksheet.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsWorksheet.cs index 2c669c2a..14cb15e5 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsWorksheet.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsWorksheet.cs @@ -157,17 +157,39 @@ private IEnumerable ReadWorksheetRows(XlsBiffStream biffStream) var rowIndex = 0; List> cellListPool = []; - while (rowIndex < RowCount) - { - GetBlockSize(rowIndex, out var blockRowCount, out var minOffset, out var maxOffset); + // blockRowCount is declared here so the finally block can see its current value + // for the early-disposal safety clear (blockRowCount == 0 after normal completion). + var blockRowCount = 0; #if NETSTANDARD2_1_OR_GREATER || NET8_0_OR_GREATER - var block = System.Buffers.ArrayPool.Shared.Rent(blockRowCount); + // Rent once for the lifetime of the loop. For most files blockRowCount stays at + // Math.Min(32, RowCount) throughout, so a single Rent/Return replaces the previous + // 313 Rent + 313 clearArray:true Return cycles for a 10 000-row worksheet. + var block = System.Buffers.ArrayPool.Shared.Rent(Math.Max(1, Math.Min(32, RowCount))); + try + { #else - var block = new Row?[blockRowCount]; + // On older targets allocate once and grow only when overlapping rows force a larger block. + var block = new Row?[Math.Max(1, Math.Min(32, RowCount))]; + { #endif - try + while (rowIndex < RowCount) { + GetBlockSize(rowIndex, out blockRowCount, out var minOffset, out var maxOffset); + +#if NETSTANDARD2_1_OR_GREATER || NET8_0_OR_GREATER + if (blockRowCount > block.Length) + { + // Rare: overlapping rows require a larger block. The current array is already + // clean (all slots default), so return it without an extra clear pass. + System.Buffers.ArrayPool.Shared.Return(block, clearArray: false); + block = System.Buffers.ArrayPool.Shared.Rent(blockRowCount); + } +#else + if (blockRowCount > block.Length) + block = new Row?[blockRowCount]; +#endif + ReadNextBlock(biffStream, rowIndex, blockRowCount, minOffset, maxOffset, block, cellListPool); for (var i = 0; i < blockRowCount; ++i) @@ -176,22 +198,34 @@ private IEnumerable ReadWorksheetRows(XlsBiffStream biffStream) { yield return block[i]!.Value; - // Safe: ReadCurrentRow() already consumed cells before this MoveNext resumed. + // Safe: consumer has already processed the row's cells before MoveNext resumed. var consumed = block[i]!.Value.Cells; + + // release List reference immediately so the pool + // return below does not need a bulk Array.Clear pass + block[i] = default; consumed.Clear(); cellListPool.Add(consumed); } } + + rowIndex += blockRowCount; } - finally - { + + blockRowCount = 0; // signal to finally: normal completion, all slots already cleared + } #if NETSTANDARD2_1_OR_GREATER || NET8_0_OR_GREATER - System.Buffers.ArrayPool.Shared.Return(block, clearArray: true); -#endif - } + finally + { + // On early disposal blockRowCount > 0 and some slots in the current block may + // still hold live Row? values; clear them so the pool does not retain references. + // On normal completion blockRowCount == 0, so this loop is a no-op. + for (var j = 0; j < blockRowCount; ++j) + block[j] = default; - rowIndex += blockRowCount; + System.Buffers.ArrayPool.Shared.Return(block, clearArray: false); } +#endif } private void ReadNextBlock(XlsBiffStream biffStream, int startRow, int rows, int minOffset, int maxOffset, Row?[] result, List> cellListPool) diff --git a/src/ExcelDataReader/Core/CompoundFormat/CompoundStream.cs b/src/ExcelDataReader/Core/CompoundFormat/CompoundStream.cs index f4e49c7f..fa4bedd5 100644 --- a/src/ExcelDataReader/Core/CompoundFormat/CompoundStream.cs +++ b/src/ExcelDataReader/Core/CompoundFormat/CompoundStream.cs @@ -4,6 +4,10 @@ namespace ExcelDataReader.Core.CompoundFormat; internal sealed class CompoundStream : Stream { + private readonly byte[] _sectorBuffer; + + private int _sectorBufferValidLength; + public CompoundStream(CompoundDocument document, Stream baseStream, List sectorChain, int length, bool leaveOpen) { Document = document; @@ -12,6 +16,7 @@ public CompoundStream(CompoundDocument document, Stream baseStream, List s LeaveOpen = leaveOpen; Length = length; SectorChain = sectorChain; + _sectorBuffer = new byte[Document.Header.SectorSize]; ReadSector(); } @@ -27,10 +32,12 @@ public CompoundStream(CompoundDocument document, Stream baseStream, uint baseSec { SectorChain = CompoundDocument.GetSectorChain(baseSector, Document.MiniSectorTable); RootSectorChain = CompoundDocument.GetSectorChain(Document.RootEntry.StreamFirstSector, Document.SectorTable); + _sectorBuffer = new byte[Document.Header.MiniSectorSize]; } else { SectorChain = CompoundDocument.GetSectorChain(baseSector, Document.SectorTable); + _sectorBuffer = new byte[Document.Header.SectorSize]; } ReadSector(); @@ -48,7 +55,7 @@ public CompoundStream(CompoundDocument document, Stream baseStream, uint baseSec public override long Length { get; } - public override long Position { get => Offset - SectorBytes.Length + SectorOffset; set => Seek(value, SeekOrigin.Begin); } + public override long Position { get => Offset - _sectorBufferValidLength + SectorOffset; set => Seek(value, SeekOrigin.Begin); } private Stream BaseStream { get; set; } @@ -64,8 +71,6 @@ public CompoundStream(CompoundDocument document, Stream baseStream, uint baseSec private int SectorOffset { get; set; } - private byte[] SectorBytes { get; set; } - public override void Flush() { } @@ -75,14 +80,14 @@ public override int Read(byte[] buffer, int offset, int count) int index = 0; while (index < count && Position < Length) { - if (SectorOffset == SectorBytes.Length) + if (SectorOffset == _sectorBufferValidLength) { ReadSector(); SectorOffset = 0; } - var chunkSize = Math.Min(count - index, SectorBytes.Length - SectorOffset); - Array.Copy(SectorBytes, SectorOffset, buffer, offset + index, chunkSize); + var chunkSize = Math.Min(count - index, _sectorBufferValidLength - SectorOffset); + Array.Copy(_sectorBuffer, SectorOffset, buffer, offset + index, chunkSize); index += chunkSize; SectorOffset += chunkSize; } @@ -161,12 +166,12 @@ private void ReadMiniSector() BaseStream.Seek(Document.GetSectorOffset(rootSector) + rootOffset, SeekOrigin.Begin); var chunkSize = (int)Math.Min(Length - Offset, Document.Header.MiniSectorSize); - SectorBytes = new byte[chunkSize]; - if (BaseStream.ReadAtLeast(SectorBytes, 0, chunkSize) < chunkSize) + if (BaseStream.ReadAtLeast(_sectorBuffer, 0, chunkSize) < chunkSize) { throw new CompoundDocumentException(Errors.ErrorEndOfFile); } + _sectorBufferValidLength = chunkSize; Offset += chunkSize; SectorChainOffset++; } @@ -177,12 +182,12 @@ private void ReadRegularSector() BaseStream.Seek(Document.GetSectorOffset(sector), SeekOrigin.Begin); var chunkSize = (int)Math.Min(Length - Offset, Document.Header.SectorSize); - SectorBytes = new byte[chunkSize]; - if (BaseStream.ReadAtLeast(SectorBytes, 0, chunkSize) < chunkSize) + if (BaseStream.ReadAtLeast(_sectorBuffer, 0, chunkSize) < chunkSize) { throw new CompoundDocumentException(Errors.ErrorEndOfFile); } + _sectorBufferValidLength = chunkSize; Offset += chunkSize; SectorChainOffset++; } diff --git a/src/ExcelDataReader/Core/Helpers.cs b/src/ExcelDataReader/Core/Helpers.cs index d3598fb8..0f706f21 100644 --- a/src/ExcelDataReader/Core/Helpers.cs +++ b/src/ExcelDataReader/Core/Helpers.cs @@ -32,6 +32,11 @@ public static bool IsSingleByteEncoding(Encoding encoding) public static string ConvertEscapeChars(string input) { + // Fast rejection: the escape pattern always starts with "_x". For typical + // spreadsheet strings this short-circuits before the regex engine is entered. + if (input.IndexOf("_x", StringComparison.Ordinal) < 0) + return input; + return EscapeRegex().Replace(input, m => ((char)uint.Parse(m.Groups[1].Value, NumberStyles.HexNumber, CultureInfo.InvariantCulture)).ToString()); } diff --git a/src/ExcelDataReader/Core/OpenXmlFormat/BinaryFormat/BiffReader.cs b/src/ExcelDataReader/Core/OpenXmlFormat/BinaryFormat/BiffReader.cs index 56ebe3fb..66999354 100644 --- a/src/ExcelDataReader/Core/OpenXmlFormat/BinaryFormat/BiffReader.cs +++ b/src/ExcelDataReader/Core/OpenXmlFormat/BinaryFormat/BiffReader.cs @@ -8,7 +8,7 @@ namespace ExcelDataReader.Core.OpenXmlFormat.BinaryFormat; internal abstract class BiffReader(Stream stream) : RecordReader { - private readonly byte[] _buffer = new byte[128]; + private readonly byte[] _buffer = new byte[512]; private Stream Stream { get; } = stream ?? throw new ArgumentNullException(nameof(stream)); @@ -68,7 +68,13 @@ protected static ushort GetWord(byte[] buffer, uint offset) } protected static string GetString(byte[] buffer, uint offset, uint length) - => Encoding.Unicode.GetString(buffer, (int)offset, (int)(length * 2)); + { +#if NETSTANDARD2_1_OR_GREATER || NET8_0_OR_GREATER + return Encoding.Unicode.GetString(buffer.AsSpan((int)offset, (int)(length * 2))); +#else + return Encoding.Unicode.GetString(buffer, (int)offset, (int)(length * 2)); +#endif + } protected static string? GetNullableString(byte[] buffer, ref uint offset) { @@ -76,7 +82,11 @@ protected static string GetString(byte[] buffer, uint offset, uint length) offset += 4; if (length == uint.MaxValue) return null; +#if NETSTANDARD2_1_OR_GREATER || NET8_0_OR_GREATER + string result = Encoding.Unicode.GetString(buffer.AsSpan((int)offset, (int)(length * 2))); +#else string result = Encoding.Unicode.GetString(buffer, (int)offset, (int)(length * 2)); +#endif offset += length * 2; return result; } diff --git a/src/ExcelDataReader/Core/OpenXmlFormat/XmlFormat/XmlSharedStringsReader.cs b/src/ExcelDataReader/Core/OpenXmlFormat/XmlFormat/XmlSharedStringsReader.cs index f019fb71..841f48ea 100644 --- a/src/ExcelDataReader/Core/OpenXmlFormat/XmlFormat/XmlSharedStringsReader.cs +++ b/src/ExcelDataReader/Core/OpenXmlFormat/XmlFormat/XmlSharedStringsReader.cs @@ -29,7 +29,7 @@ protected override IEnumerable ReadOverride() while (!Reader.EOF) { - if (Reader.IsStartElement(ElementStringItem, ProperNamespaces.NsSpreadsheetMl)) + if (Reader.NodeType == XmlNodeType.Element && Reader.LocalName == ElementStringItem) { var value = StringHelper.ReadStringItem(Reader, ProperNamespaces.NsSpreadsheetMl); yield return new SharedStringRecord(value); diff --git a/src/ExcelDataReader/Core/OpenXmlFormat/XmlFormat/XmlWorksheetReader.cs b/src/ExcelDataReader/Core/OpenXmlFormat/XmlFormat/XmlWorksheetReader.cs index a412ad4c..74b3ecf2 100644 --- a/src/ExcelDataReader/Core/OpenXmlFormat/XmlFormat/XmlWorksheetReader.cs +++ b/src/ExcelDataReader/Core/OpenXmlFormat/XmlFormat/XmlWorksheetReader.cs @@ -78,7 +78,7 @@ protected override IEnumerable ReadOverride() int seqRowIndex = -1; while (!Reader.EOF) { - if (Reader.IsStartElement(NRow, ProperNamespaces.NsSpreadsheetMl)) + if (Reader.NodeType == XmlNodeType.Element && Reader.LocalName == NRow) { // Mirror ReadOverride: use r attribute if valid, otherwise sequential if (int.TryParse(Reader.GetAttribute(AR), out int arValue)) @@ -92,10 +92,10 @@ protected override IEnumerable ReadOverride() { int nextColumnIndex = 0; while (!Reader.EOF) - { - if (Reader.IsStartElement(NC, ProperNamespaces.NsSpreadsheetMl)) - { - // Mirror ReadCell: use the r attribute if valid, otherwise sequential + { + if (Reader.NodeType == XmlNodeType.Element && Reader.LocalName == NC) + { + // Mirror ReadCell: use the r attribute if valid, otherwise sequential int colIndex; if (ReferenceHelper.ParseReference(Reader.GetAttribute(AR), out int referenceColumn, out _)) colIndex = referenceColumn - 1; @@ -138,7 +138,7 @@ protected override IEnumerable ReadOverride() int rowIndex = -1; while (!Reader.EOF) { - if (Reader.IsStartElement(NRow, ProperNamespaces.NsSpreadsheetMl)) + if (Reader.NodeType == XmlNodeType.Element && Reader.LocalName == NRow) { if (int.TryParse(Reader.GetAttribute(AR), out int arValue)) rowIndex = arValue - 1; // The row attribute is 1-based @@ -162,7 +162,7 @@ protected override IEnumerable ReadOverride() int nextColumnIndex = 0; while (!Reader.EOF) { - if (Reader.IsStartElement(NC, ProperNamespaces.NsSpreadsheetMl)) + if (Reader.NodeType == XmlNodeType.Element && Reader.LocalName == NC) { var cell = ReadCell(nextColumnIndex, ProperNamespaces.NsSpreadsheetMl); nextColumnIndex = cell.ColumnIndex + 1; @@ -354,13 +354,13 @@ private CellRecord ReadCell(int nextColumnIndex, string nsSpreadsheetMl) CellError? error = null; while (!Reader.EOF) { - if (Reader.IsStartElement(NV, nsSpreadsheetMl)) + if (Reader.NodeType == XmlNodeType.Element && Reader.LocalName == NV) { string rawValue = Reader.ReadElementContentAsString(); if (!string.IsNullOrEmpty(rawValue)) ConvertCellValue(rawValue, aT, out value, out error); } - else if (Reader.IsStartElement(NIs, nsSpreadsheetMl)) + else if (Reader.NodeType == XmlNodeType.Element && Reader.LocalName == NIs) { string rawValue = StringHelper.ReadStringItem(Reader, nsSpreadsheetMl); if (!string.IsNullOrEmpty(rawValue)) diff --git a/src/ExcelDataReader/Core/OpenXmlFormat/ZipWorker.cs b/src/ExcelDataReader/Core/OpenXmlFormat/ZipWorker.cs index 91130d0e..28050027 100644 --- a/src/ExcelDataReader/Core/OpenXmlFormat/ZipWorker.cs +++ b/src/ExcelDataReader/Core/OpenXmlFormat/ZipWorker.cs @@ -14,10 +14,18 @@ internal sealed partial class ZipWorker : IDisposable private const string Format = "xml"; private const string BinFormat = "bin"; - private static readonly XmlReaderSettings XmlSettings = new() + // Pre-populate the shared NameTable with every namespace URI and element/attribute name + // used by the xlsx/xlsb readers. When the XmlReader parses a namespace declaration it + // calls NameTable.Add() which returns the pre-existing atom, turning every subsequent + // Reader.NamespaceURI == someConstantString comparison into a cheap reference-equality + // check instead of a full 65-character string comparison. + private static readonly XmlNameTable XmlNameTable = BuildNameTable(); + + private static readonly XmlReaderSettings XmlSettings = new() { - IgnoreComments = true, + IgnoreComments = true, IgnoreWhitespace = true, + NameTable = XmlNameTable, }; private readonly Dictionary _entries = new(StringComparer.OrdinalIgnoreCase); @@ -145,7 +153,7 @@ static string ResolvePath(string? basePath, string path) return new XmlSharedStringsReader(XmlReader.Create(entry.Open(), XmlSettings)); if (entry.FullName.EndsWith(".bin", StringComparison.Ordinal)) - return new BiffSharedStringsReader(entry.Open()); + return new BiffSharedStringsReader(OpenZipEntry(entry)); } return null; @@ -162,7 +170,7 @@ static string ResolvePath(string? basePath, string path) return new XmlStylesReader(XmlReader.Create(entry.Open(), XmlSettings)); if (entry.FullName.EndsWith(".bin", StringComparison.Ordinal)) - return new BiffStylesReader(entry.Open()); + return new BiffStylesReader(OpenZipEntry(entry)); } return null; @@ -178,7 +186,7 @@ static string ResolvePath(string? basePath, string path) if (entry.FullName.EndsWith(".xml", StringComparison.Ordinal)) return new XmlWorkbookReader(XmlReader.Create(entry.Open(), XmlSettings), _worksheetRels); else if (entry.FullName.EndsWith(".bin", StringComparison.Ordinal)) - return new BiffWorkbookReader(entry.Open(), _worksheetRels); + return new BiffWorkbookReader(OpenZipEntry(entry), _worksheetRels); } throw new Exceptions.HeaderException(Errors.ErrorZipNoOpenXml); @@ -205,13 +213,67 @@ static string ResolvePath(string? basePath, string path) return null; } - // for some reason, reading of zip entry is slow on NET Core. - // fix that with usage of BufferedStream -#if NETSTANDARD2_0_OR_GREATER || NET8_0_OR_GREATER + private static NameTable BuildNameTable() + { + var nt = new NameTable(); + + // Namespace URIs + nt.Add(XmlNamespaces.NsSpreadsheetMl); + nt.Add(XmlNamespaces.StrictNsSpreadsheetMl); + nt.Add(XmlNamespaces.NsDocumentRelationship); + nt.Add(XmlNamespaces.StrictNsDocumentRelationship); + + // High-frequency element local names (worksheet inner loops) + nt.Add("worksheet"); + nt.Add("sheetData"); + nt.Add("row"); + nt.Add("c"); + nt.Add("v"); + nt.Add("f"); + nt.Add("is"); + nt.Add("t"); + + // SST + nt.Add("sst"); + nt.Add("si"); + nt.Add("r"); + nt.Add("rPh"); + nt.Add("phoneticPr"); + + // Workbook + nt.Add("workbook"); + nt.Add("sheets"); + nt.Add("sheet"); + nt.Add("bookViews"); + nt.Add("workbookView"); + + // Styles + nt.Add("styleSheet"); + nt.Add("cellXfs"); + nt.Add("xf"); + nt.Add("numFmts"); + nt.Add("numFmt"); + + // Common structural elements + nt.Add("mergeCell"); + nt.Add("mergeCells"); + nt.Add("dimension"); + nt.Add("col"); + nt.Add("cols"); + nt.Add("headerFooter"); + nt.Add("sheetPr"); + nt.Add("sheetFormatPr"); + + return nt; + } + + // BufferedStream wrapping is essential for all targets: BiffReader.TryReadVariableValue reads + // the variable-length record header one byte at a time. Without buffering each of those reads + // hits the DeflateStream (and the zlib Inflater) directly. For a 10x10000 xlsb file the SST + // alone produces ~400 000 single-byte DeflateStream.Read calls; a 4 096-byte BufferedStream + // reduces that to ~100 Inflater interactions. The guard previously excluded net462; removing it + // gives the same benefit to net462 consumers of xlsb/xlsx files. private static BufferedStream OpenZipEntry(ZipArchiveEntry zipEntry) => new(zipEntry.Open()); -#else - private static Stream OpenZipEntry(ZipArchiveEntry zipEntry) => zipEntry.Open(); -#endif private ZipArchiveEntry? FindEntry(string? name) { From 3fe746a4a71426748eaac596b5629efead8e8f23 Mon Sep 17 00:00:00 2001 From: Johan Appelgren Date: Fri, 10 Apr 2026 14:45:36 +0200 Subject: [PATCH 17/19] Code cleanup --- .../Core/BinaryFormat/XlsBiffMSODrawing.cs | 12 ------------ .../Core/BinaryFormat/XlsBiffQuickTip.cs | 12 ------------ .../Core/BinaryFormat/XlsBiffRecord.cs | 7 ------- .../Core/BinaryFormat/XlsBiffStream.cs | 12 ------------ .../Core/BinaryFormat/XlsBiffUncalced.cs | 13 ------------- .../Core/BinaryFormat/XlsInternalString.cs | 7 +------ .../Core/BinaryFormat/XlsShortByteString.cs | 12 ++---------- 7 files changed, 3 insertions(+), 72 deletions(-) delete mode 100644 src/ExcelDataReader/Core/BinaryFormat/XlsBiffMSODrawing.cs delete mode 100644 src/ExcelDataReader/Core/BinaryFormat/XlsBiffQuickTip.cs delete mode 100644 src/ExcelDataReader/Core/BinaryFormat/XlsBiffUncalced.cs diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffMSODrawing.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsBiffMSODrawing.cs deleted file mode 100644 index 34c60aba..00000000 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffMSODrawing.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace ExcelDataReader.Core.BinaryFormat; - -/// -/// Represents MSO Drawing record. -/// -internal sealed class XlsBiffMSODrawing : XlsBiffRecord -{ - internal XlsBiffMSODrawing(byte[] bytes) - : base(bytes) - { - } -} diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffQuickTip.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsBiffQuickTip.cs deleted file mode 100644 index a087a0f0..00000000 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffQuickTip.cs +++ /dev/null @@ -1,12 +0,0 @@ -namespace ExcelDataReader.Core.BinaryFormat; - -/// -/// For now QuickTip will do nothing, it seems to have a different. -/// -internal sealed class XlsBiffQuickTip : XlsBiffRecord -{ - internal XlsBiffQuickTip(byte[] bytes) - : base(bytes) - { - } -} \ No newline at end of file diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffRecord.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsBiffRecord.cs index 5026113b..22249759 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffRecord.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsBiffRecord.cs @@ -85,13 +85,6 @@ public long ReadInt64(int offset) return BitConverter.ToInt64(Bytes, ContentOffset + offset); } - public byte[] ReadArray(int offset, int size) - { - byte[] tmp = new byte[size]; - Buffer.BlockCopy(Bytes, ContentOffset + offset, tmp, 0, size); - return tmp; - } - public float ReadFloat(int offset) { return BitConverter.ToSingle(Bytes, ContentOffset + offset); diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffStream.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsBiffStream.cs index a4715340..af4b2d8e 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffStream.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsBiffStream.cs @@ -256,25 +256,13 @@ public XlsBiffRecord GetRecord(Stream stream) case BIFFRECORDTYPE.WINDOW1: return new XlsBiffWindow1(bytes); case BIFFRECORDTYPE.CODEPAGE: - return new XlsBiffSimpleValueRecord(bytes); case BIFFRECORDTYPE.FNGROUPCOUNT: - return new XlsBiffSimpleValueRecord(bytes); case BIFFRECORDTYPE.DATE1904: - return new XlsBiffSimpleValueRecord(bytes); case BIFFRECORDTYPE.BOOKBOOL: - return new XlsBiffSimpleValueRecord(bytes); case BIFFRECORDTYPE.BACKUP: - return new XlsBiffSimpleValueRecord(bytes); case BIFFRECORDTYPE.HIDEOBJ: - return new XlsBiffSimpleValueRecord(bytes); case BIFFRECORDTYPE.USESELFS: return new XlsBiffSimpleValueRecord(bytes); - case BIFFRECORDTYPE.UNCALCED: - return new XlsBiffUncalced(bytes); - case BIFFRECORDTYPE.QUICKTIP: - return new XlsBiffQuickTip(bytes); - case BIFFRECORDTYPE.MSODRAWING: - return new XlsBiffMSODrawing(bytes); case BIFFRECORDTYPE.FILEPASS: return new XlsBiffFilePass(bytes, biffVersion); case BIFFRECORDTYPE.HEADER: diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffUncalced.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsBiffUncalced.cs deleted file mode 100644 index 8caa1ac7..00000000 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffUncalced.cs +++ /dev/null @@ -1,13 +0,0 @@ -namespace ExcelDataReader.Core.BinaryFormat; - -/// -/// If present the Calculate Message was in the status bar when Excel saved the file. -/// This occurs if the sheet changed, the Manual calculation option was on, and the Recalculate Before Save option was off. -/// -internal sealed class XlsBiffUncalced : XlsBiffRecord -{ - internal XlsBiffUncalced(byte[] bytes) - : base(bytes) - { - } -} \ No newline at end of file diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsInternalString.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsInternalString.cs index e8a0aa69..e73d89ab 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsInternalString.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsInternalString.cs @@ -7,10 +7,5 @@ namespace ExcelDataReader.Core.BinaryFormat; /// internal sealed class XlsInternalString(string value) : IXlsString { - private readonly string stringValue = value; - - public string GetValue(Encoding encoding) - { - return stringValue; - } + public string GetValue(Encoding encoding) => value; } diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsShortByteString.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsShortByteString.cs index fd6119e0..be7df796 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsShortByteString.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsShortByteString.cs @@ -12,14 +12,6 @@ internal sealed class XlsShortByteString(byte[] bytes, uint offset) : IXlsString public ushort CharacterCount => _bytes[_offset]; - public string GetValue(Encoding encoding) - { - // Supposedly this is never multibyte, but technically could be - if (!Helpers.IsSingleByteEncoding(encoding)) - { - return encoding.GetString(_bytes, (int)_offset + 1, CharacterCount * 2); - } - - return encoding.GetString(_bytes, (int)_offset + 1, CharacterCount); - } + public string GetValue(Encoding encoding) => + encoding.GetString(_bytes, (int)_offset + 1, CharacterCount * (Helpers.IsSingleByteEncoding(encoding) ? 1 : 2)); } From eabdfc655417840c843ef52f9948352a4a7e5679 Mon Sep 17 00:00:00 2001 From: Johan Appelgren Date: Sat, 11 Apr 2026 14:11:56 +0200 Subject: [PATCH 18/19] Remove unused code --- .../Core/BinaryFormat/XlsBiffBOF.cs | 56 --------------- .../Core/BinaryFormat/XlsBiffMulBlankCell.cs | 32 --------- .../Core/BinaryFormat/XlsBiffStream.cs | 4 +- .../Core/BinaryFormat/XlsBiffWindow1.cs | 71 ------------------- 4 files changed, 2 insertions(+), 161 deletions(-) delete mode 100644 src/ExcelDataReader/Core/BinaryFormat/XlsBiffMulBlankCell.cs delete mode 100644 src/ExcelDataReader/Core/BinaryFormat/XlsBiffWindow1.cs diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffBOF.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsBiffBOF.cs index a407d7ec..33d949a6 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffBOF.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsBiffBOF.cs @@ -19,60 +19,4 @@ internal XlsBiffBOF(byte[] bytes) /// Gets the type of the BIFF block. /// public BIFFTYPE Type => (BIFFTYPE)ReadUInt16(0x2); - - /// - /// Gets the creation Id. - /// - /// Not used before BIFF5. - public ushort CreationId - { - get - { - if (RecordSize < 6) - return 0; - return ReadUInt16(0x4); - } - } - - /// - /// Gets the creation year. - /// - /// Not used before BIFF5. - public ushort CreationYear - { - get - { - if (RecordSize < 8) - return 0; - return ReadUInt16(0x6); - } - } - - /// - /// Gets the file history flag. - /// - /// Not used before BIFF8. - public uint HistoryFlag - { - get - { - if (RecordSize < 12) - return 0; - return ReadUInt32(0x8); - } - } - - /// - /// Gets the minimum Excel version to open this file. - /// - /// Not used before BIFF8. - public uint MinVersionToOpen - { - get - { - if (RecordSize < 16) - return 0; - return ReadUInt32(0xC); - } - } } \ No newline at end of file diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffMulBlankCell.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsBiffMulBlankCell.cs deleted file mode 100644 index ae69bb38..00000000 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffMulBlankCell.cs +++ /dev/null @@ -1,32 +0,0 @@ -namespace ExcelDataReader.Core.BinaryFormat; - -/// -/// Represents multiple Blank cell. -/// -internal sealed class XlsBiffMulBlankCell : XlsBiffBlankCell -{ - internal XlsBiffMulBlankCell(byte[] bytes) - : base(bytes) - { - } - - public override bool IsEmpty => true; - - /// - /// Gets the zero-based index of last described column. - /// - public ushort LastColumnIndex => ReadUInt16(RecordSize - 2); - - /// - /// Returns format forspecified column, column must be between ColumnIndex and LastColumnIndex. - /// - /// Index of column. - /// Format. - public ushort GetXF(ushort columnIdx) - { - int ofs = 4 + 6 * (columnIdx - ColumnIndex); - if (ofs > RecordSize - 2) - return 0; - return ReadUInt16(ofs); - } -} \ No newline at end of file diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffStream.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsBiffStream.cs index af4b2d8e..87bcdf66 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffStream.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsBiffStream.cs @@ -219,7 +219,7 @@ public XlsBiffRecord GetRecord(Stream stream) case BIFFRECORDTYPE.BLANK_OLD: return new XlsBiffBlankCell(bytes); case BIFFRECORDTYPE.MULBLANK: - return new XlsBiffMulBlankCell(bytes); + return new XlsBiffBlankCell(bytes); case BIFFRECORDTYPE.LABEL_V2: case BIFFRECORDTYPE.LABEL: case BIFFRECORDTYPE.RSTRING: @@ -254,7 +254,7 @@ public XlsBiffRecord GetRecord(Stream stream) case BIFFRECORDTYPE.BOUNDSHEET: return new XlsBiffBoundSheet(bytes, biffVersion); case BIFFRECORDTYPE.WINDOW1: - return new XlsBiffWindow1(bytes); + return new XlsBiffRecord(bytes); case BIFFRECORDTYPE.CODEPAGE: case BIFFRECORDTYPE.FNGROUPCOUNT: case BIFFRECORDTYPE.DATE1904: diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffWindow1.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsBiffWindow1.cs deleted file mode 100644 index 257bd525..00000000 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsBiffWindow1.cs +++ /dev/null @@ -1,71 +0,0 @@ -namespace ExcelDataReader.Core.BinaryFormat; - -/// -/// Represents Workbook's global window description. -/// -internal sealed class XlsBiffWindow1 : XlsBiffRecord -{ - internal XlsBiffWindow1(byte[] bytes) - : base(bytes) - { - } - - [Flags] - public enum Window1Flags : ushort - { - Hidden = 0x1, - Minimized = 0x2, - - // (Reserved) = 0x4, - HScrollVisible = 0x8, - VScrollVisible = 0x10, - WorkbookTabs = 0x20 - - // (Other bits are reserved) - } - - /// - /// Gets the X position of a window. - /// - public ushort Left => ReadUInt16(0x0); - - /// - /// Gets the Y position of a window. - /// - public ushort Top => ReadUInt16(0x2); - - /// - /// Gets the width of the window. - /// - public ushort Width => ReadUInt16(0x4); - - /// - /// Gets the height of the window. - /// - public ushort Height => ReadUInt16(0x6); - - /// - /// Gets the window flags. - /// - public Window1Flags Flags => (Window1Flags)ReadUInt16(0x8); - - /// - /// Gets the active workbook tab (zero-based). - /// - public ushort ActiveTab => ReadUInt16(0xA); - - /// - /// Gets the first visible workbook tab (zero-based). - /// - public ushort FirstVisibleTab => ReadUInt16(0xC); - - /// - /// Gets the number of selected workbook tabs. - /// - public ushort SelectedTabCount => ReadUInt16(0xE); - - /// - /// Gets the workbook tab width to horizontal scrollbar width. - /// - public ushort TabRatio => ReadUInt16(0x10); -} \ No newline at end of file From 09108f7282dd163f58f0f6feb4adde0f7b6bf506 Mon Sep 17 00:00:00 2001 From: Johan Appelgren Date: Sat, 11 Apr 2026 14:30:14 +0200 Subject: [PATCH 19/19] Throw more explicit errors for corrupt files (#739) Fixes #735 --- .../Core/BinaryFormat/XlsShortUnicodeString.cs | 8 ++++++++ src/ExcelDataReader/Core/BinaryFormat/XlsUnicodeString.cs | 8 ++++++++ src/ExcelDataReader/Core/BinaryFormat/XlsWorksheet.cs | 6 ++++++ src/ExcelDataReader/Errors.cs | 1 + 4 files changed, 23 insertions(+) diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsShortUnicodeString.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsShortUnicodeString.cs index 9fac52ed..46a3e677 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsShortUnicodeString.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsShortUnicodeString.cs @@ -1,4 +1,5 @@ using System.Text; +using ExcelDataReader.Exceptions; namespace ExcelDataReader.Core.BinaryFormat; @@ -25,6 +26,13 @@ public string GetValue(Encoding encoding) return string.Empty; } + int available = _bytes.Length - (int)_offset - 2; + int needed = IsMultiByte ? CharacterCount * 2 : CharacterCount; + if (available < needed) + { + throw new ExcelReaderException(Errors.ErrorBiffStringSize); + } + if (IsMultiByte) { return Encoding.Unicode.GetString(_bytes, (int)_offset + 2, CharacterCount * 2); diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsUnicodeString.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsUnicodeString.cs index 340dff83..ebce7307 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsUnicodeString.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsUnicodeString.cs @@ -1,4 +1,5 @@ using System.Text; +using ExcelDataReader.Exceptions; namespace ExcelDataReader.Core.BinaryFormat; @@ -25,6 +26,13 @@ public string GetValue(Encoding encoding) return string.Empty; } + int available = _bytes.Length - (int)_offset - 3; + int needed = IsMultiByte ? CharacterCount * 2 : CharacterCount; + if (available < needed) + { + throw new ExcelReaderException(Errors.ErrorBiffStringSize); + } + if (IsMultiByte) { return Encoding.Unicode.GetString(_bytes, (int)_offset + 3, CharacterCount * 2); diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsWorksheet.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsWorksheet.cs index 14cb15e5..3ba6838b 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsWorksheet.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsWorksheet.cs @@ -1,4 +1,5 @@ using System.Text; +using ExcelDataReader.Exceptions; namespace ExcelDataReader.Core.BinaryFormat; @@ -365,6 +366,11 @@ private Cell ReadSingleCell(XlsBiffStream biffStream, XlsBiffBlankCell cell, int value = GetLabelString((XlsBiffLabelCell)cell, effectiveStyle); break; case BIFFRECORDTYPE.LABELSST: + if (Workbook.SST == null) + { + throw new ExcelReaderException(Errors.ErrorWorkbookGlobalsInvalidData); + } + value = Workbook.SST.GetString(((XlsBiffLabelSSTCell)cell).SSTIndex, Encoding); break; case BIFFRECORDTYPE.RK: diff --git a/src/ExcelDataReader/Errors.cs b/src/ExcelDataReader/Errors.cs index 5b8bef9b..97b8ce93 100644 --- a/src/ExcelDataReader/Errors.cs +++ b/src/ExcelDataReader/Errors.cs @@ -12,6 +12,7 @@ internal static class Errors public const string ErrorHeaderSignature = "Invalid file signature."; public const string ErrorHeaderOrder = "Invalid byte order specified in header."; public const string ErrorBiffRecordSize = "Buffer size is less than minimum BIFF record size."; + public const string ErrorBiffStringSize = "Error reading BIFF string - record size is too small."; public const string ErrorBiffIlegalBefore = "BIFF Stream error: Moving before stream start."; public const string ErrorBiffIlegalAfter = "BIFF Stream error: Moving after stream end.";