diff --git a/Directory.build.props b/Directory.build.props index d79eee79..089d42c6 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 @@ -24,7 +24,9 @@ $(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb MIT README.md - true + true + true + true @@ -49,7 +51,6 @@ - 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, } }); ``` 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.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.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 1598420d..b40916aa 100644 --- a/src/ExcelDataReader.DataSet/ExcelDataReaderExtensions.cs +++ b/src/ExcelDataReader.DataSet/ExcelDataReaderExtensions.cs @@ -68,13 +68,26 @@ 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; + 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; + int lastCheckedFieldCount = 0; List columnIndices = []; while (self.Read()) { + rowIndex++; if (first) { if (configuration.UseHeaderRow && configuration.ReadHeaderRow != null) @@ -82,7 +95,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 +109,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++) { @@ -124,6 +137,7 @@ private static DataTable AsDataTable(IExcelDataReader self, ExcelDataTableConfig } result.BeginLoadData(); + lastCheckedFieldCount = self.FieldCount; first = false; if (configuration.UseHeaderRow) @@ -131,13 +145,33 @@ 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)) { 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 +191,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 04243d4f..9f52f7e6 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. /// @@ -43,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.DataSet/packages.lock.json b/src/ExcelDataReader.DataSet/packages.lock.json index 684cac8a..a0024132 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", @@ -174,15 +114,11 @@ } }, "net8.0": { - "Microsoft.SourceLink.GitHub": { + "Microsoft.NET.ILLink.Tasks": { "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" - } + "requested": "[8.0.25, )", + "resolved": "8.0.25", + "contentHash": "sqX4nmBft05ivqKvUT4nxaN8rT3apCLt9SWFkfRrQPwra1zPwFknQAw1lleuMCKOCLvVmOWwrC2iPSm9RiXZUg==" }, "PolySharp": { "type": "Direct", @@ -199,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/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.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/ExcelBinaryReaderTest.cs b/src/ExcelDataReader.Tests/ExcelBinaryReaderTest.cs index 1531b60d..b702b050 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,71 @@ 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() + { + 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")); + 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.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/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.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.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/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 diff --git a/src/ExcelDataReader.Tests/ExcelTestBase.cs b/src/ExcelDataReader.Tests/ExcelTestBase.cs index d198a889..18a9d6a5 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,101 @@ public void GitIssue574VerticalAlignment() Assert.That(reader.GetCellStyle(2).VerticalAlignment, Is.EqualTo(VerticalAlignment.Bottom)); } + [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"); + 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"); + 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)); diff --git a/src/ExcelDataReader.Tests/FormatReaderTest.cs b/src/ExcelDataReader.Tests/FormatReaderTest.cs index e5463f5c..4643e2f9 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,98 @@ static void TestValid(string format) Assert.That(to.IsValid, Is.True, $"Invalid format: {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 + /// 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; + } } 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/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/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/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/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/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/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 ae99991a..22249759 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 @@ -74,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/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 f6b86b8f..87bcdf66 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); @@ -194,8 +219,8 @@ public XlsBiffRecord GetRecord(Stream stream) case BIFFRECORDTYPE.BLANK_OLD: return new XlsBiffBlankCell(bytes); case BIFFRECORDTYPE.MULBLANK: - return new XlsBiffMulBlankCell(bytes); - case BIFFRECORDTYPE.LABEL_OLD: + return new XlsBiffBlankCell(bytes); + case BIFFRECORDTYPE.LABEL_V2: case BIFFRECORDTYPE.LABEL: case BIFFRECORDTYPE.RSTRING: return new XlsBiffLabelCell(bytes, biffVersion); @@ -229,27 +254,15 @@ 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: - return new XlsBiffSimpleValueRecord(bytes); case BIFFRECORDTYPE.FNGROUPCOUNT: - return new XlsBiffSimpleValueRecord(bytes); - case BIFFRECORDTYPE.RECORD1904: - return new XlsBiffSimpleValueRecord(bytes); + case BIFFRECORDTYPE.DATE1904: 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: @@ -306,6 +319,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. /// @@ -323,8 +381,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 +413,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/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/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 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/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)); } diff --git a/src/ExcelDataReader/Core/BinaryFormat/XlsShortUnicodeString.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsShortUnicodeString.cs index 78821e97..46a3e677 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsShortUnicodeString.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsShortUnicodeString.cs @@ -1,4 +1,5 @@ -using System.Text; +using System.Text; +using ExcelDataReader.Exceptions; namespace ExcelDataReader.Core.BinaryFormat; @@ -25,17 +26,32 @@ public string GetValue(Encoding encoding) return string.Empty; } - if (IsMultiByte) + int available = _bytes.Length - (int)_offset - 2; + int needed = IsMultiByte ? CharacterCount * 2 : CharacterCount; + if (available < needed) { - return Encoding.Unicode.GetString(_bytes, (int)_offset + 2, CharacterCount * 2); + throw new ExcelReaderException(Errors.ErrorBiffStringSize); } - byte[] bytes = new byte[CharacterCount * 2]; - for (int i = 0; i < CharacterCount; i++) + if (IsMultiByte) { - bytes[i * 2] = _bytes[_offset + 2 + i]; + return Encoding.Unicode.GetString(_bytes, (int)_offset + 2, CharacterCount * 2); } - return Encoding.Unicode.GetString(bytes); + // 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) => + { + 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..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,17 +26,32 @@ public string GetValue(Encoding encoding) return string.Empty; } - if (IsMultiByte) + int available = _bytes.Length - (int)_offset - 3; + int needed = IsMultiByte ? CharacterCount * 2 : CharacterCount; + if (available < needed) { - return Encoding.Unicode.GetString(_bytes, (int)_offset + 3, CharacterCount * 2); + throw new ExcelReaderException(Errors.ErrorBiffStringSize); } - byte[] bytes = new byte[CharacterCount * 2]; - for (int i = 0; i < CharacterCount; i++) + if (IsMultiByte) { - bytes[i * 2] = _bytes[_offset + 3 + i]; + return Encoding.Unicode.GetString(_bytes, (int)_offset + 3, CharacterCount * 2); } - return Encoding.Unicode.GetString(bytes); + // 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) => + { + 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/XlsWorkbook.cs b/src/ExcelDataReader/Core/BinaryFormat/XlsWorkbook.cs index 64218631..a63df23a 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); } } @@ -131,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); } } @@ -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..3ba6838b 100644 --- a/src/ExcelDataReader/Core/BinaryFormat/XlsWorksheet.cs +++ b/src/ExcelDataReader/Core/BinaryFormat/XlsWorksheet.cs @@ -1,5 +1,5 @@ using System.Text; -using ExcelDataReader.Log; +using ExcelDataReader.Exceptions; namespace ExcelDataReader.Core.BinaryFormat; @@ -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(); } @@ -80,23 +81,44 @@ 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 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; + } } } @@ -134,41 +156,93 @@ private void GetBlockSize(int startRow, out int blockRowCount, out int minOffset private IEnumerable ReadWorksheetRows(XlsBiffStream biffStream) { var rowIndex = 0; + List> cellListPool = []; - while (rowIndex < RowCount) + // 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 + // 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 + // 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))]; { - GetBlockSize(rowIndex, out var blockRowCount, out var minOffset, out var maxOffset); +#endif + while (rowIndex < RowCount) + { + GetBlockSize(rowIndex, out blockRowCount, out var minOffset, out var maxOffset); - var block = ReadNextBlock(biffStream, rowIndex, blockRowCount, minOffset, 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 - for (var i = 0; i < blockRowCount; ++i) - { - 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: 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; } - rowIndex += blockRowCount; + blockRowCount = 0; // signal to finally: normal completion, all slots already cleared + } +#if NETSTANDARD2_1_OR_GREATER || NET8_0_OR_GREATER + 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; + + System.Buffers.ArrayPool.Shared.Return(block, clearArray: false); } +#endif } - 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; @@ -184,18 +258,17 @@ 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); + ReadMultiCell(cell, cellList); } else { var xfIndex = GetXfIndexForCell(cell, ixfe); var cellValue = ReadSingleCell(biffStream, cell, xfIndex); - currentRow.Cells.Add(cellValue); + cellList.Add(cellValue); } ixfe = null; @@ -206,13 +279,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) @@ -220,32 +292,36 @@ 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) + 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; @@ -257,8 +333,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; @@ -287,11 +361,16 @@ 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; case BIFFRECORDTYPE.LABELSST: + if (Workbook.SST == null) + { + throw new ExcelReaderException(Errors.ErrorWorkbookGlobalsInvalidData); + } + value = Workbook.SST.GetString(((XlsBiffLabelSSTCell)cell).SSTIndex, Encoding); break; case BIFFRECORDTYPE.RK: @@ -434,7 +513,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; @@ -452,18 +531,19 @@ 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; 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: @@ -506,12 +586,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) @@ -559,7 +653,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) @@ -587,6 +684,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) + { + ReadMultiCell(cell, currentRowCells); + } + 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)) @@ -616,9 +791,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/BuiltinNumberFormat.cs b/src/ExcelDataReader/Core/BuiltinNumberFormat.cs index fc5ff3c1..1502a879 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; @@ -46,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) @@ -55,4 +104,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/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 619a1dcb..6a740cc2 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,14 @@ 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. + /// + public CultureInfo? Culture { get; set; } + private NumberFormatString GeneralNumberFormat { get; } = new("General"); public ExtendedFormat GetEffectiveCellStyle(int xfIndex, int numberFormatFromCell) @@ -54,9 +65,14 @@ 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) { + Formats[numberFormatIndex] = numberFormat; // cache for future lookups return numberFormat; } 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/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/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/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/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/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/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..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)); @@ -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) @@ -52,10 +69,11 @@ 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(); +#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) @@ -64,11 +82,13 @@ 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(); +#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; } protected static double GetRkNumber(byte[] buffer, uint offset) diff --git a/src/ExcelDataReader/Core/OpenXmlFormat/BinaryFormat/BiffWorksheetReader.cs b/src/ExcelDataReader/Core/OpenXmlFormat/BinaryFormat/BiffWorksheetReader.cs index ffcc8cd2..9ec01d03 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; // BrtWsDim - worksheet dimensions private const uint SheetFormatPr = 0x1E5; // private const uint ColumnsBegin = 0x186; @@ -36,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) + 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 @@ -56,6 +111,15 @@ protected override Record ReadOverride(byte[] buffer, uint recordId, uint record return new SheetPrRecord(codeName); } + case SheetDim: // BrtWsDim - worksheet dimensions (rwFirst, rwLast, colFirst, colLast) + { + 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 +156,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 +234,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/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/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/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 82983f5e..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)); + 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/XlsxWorksheet.cs b/src/ExcelDataReader/Core/OpenXmlFormat/XlsxWorksheet.cs index 575fe70c..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,7 +19,7 @@ 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); @@ -71,7 +70,12 @@ public XlsxWorksheet(ZipWorker document, XlsxWorkbook workbook, SheetRecord refS 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; @@ -82,6 +86,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/Core/OpenXmlFormat/XmlFormat/XmlSharedStringsReader.cs b/src/ExcelDataReader/Core/OpenXmlFormat/XmlFormat/XmlSharedStringsReader.cs index 3165d058..841f48ea 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; @@ -22,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 5bec55e0..74b3ecf2 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.NodeType == XmlNodeType.Element && Reader.LocalName == NRow) + { + // 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.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; + 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.NodeType == XmlNodeType.Element && Reader.LocalName == NRow) + { + 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.NodeType == XmlNodeType.Element && Reader.LocalName == NC) + { + 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) { @@ -298,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) { 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/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."; diff --git a/src/ExcelDataReader/ExcelBinaryReader.cs b/src/ExcelDataReader/ExcelBinaryReader.cs index 58fe1258..0aa5076d 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,10 +6,13 @@ 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, 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 121e83f5..009855b0 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; @@ -17,6 +20,7 @@ internal abstract class ExcelDataReader : IExcelDataReade private IEnumerator _cachedWorksheetIterator; private List _cachedWorksheets; private int _idx; + private bool _singlePassMode; ~ExcelDataReader() { @@ -44,17 +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); @@ -82,6 +94,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); @@ -339,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 944aad56..accd1be3 100644 --- a/src/ExcelDataReader/ExcelOpenXmlReader.cs +++ b/src/ExcelDataReader/ExcelOpenXmlReader.cs @@ -1,13 +1,17 @@ +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, 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. Reset(); diff --git a/src/ExcelDataReader/ExcelReaderConfiguration.cs b/src/ExcelDataReader/ExcelReaderConfiguration.cs index 78b4a560..21ffec1a 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,18 @@ 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 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). + /// 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..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); + return new ExcelBinaryReader(stream, configuration.Password, configuration.FallbackEncoding, configuration.Culture, configuration.SinglePassMode); } if (TryGetEncryptedPackage(fileStream, document, configuration.Password, out stream)) { - return new ExcelOpenXmlReader(stream); + 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); + 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); + 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); + 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); + 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); + 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); + return new ExcelOpenXmlReader(fileStream, configuration.Culture, configuration.SinglePassMode); } throw new HeaderException(Errors.ErrorHeaderSignature); 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/ExcelDataReader/packages.lock.json b/src/ExcelDataReader/packages.lock.json index 36e27863..24ce0e9e 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", @@ -162,15 +102,11 @@ } }, "net8.0": { - "Microsoft.SourceLink.GitHub": { + "Microsoft.NET.ILLink.Tasks": { "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" - } + "requested": "[8.0.25, )", + "resolved": "8.0.25", + "contentHash": "sqX4nmBft05ivqKvUT4nxaN8rT3apCLt9SWFkfRrQPwra1zPwFknQAw1lleuMCKOCLvVmOWwrC2iPSm9RiXZUg==" }, "PolySharp": { "type": "Direct", @@ -187,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", diff --git a/src/TestData/Test_Clipboard_Biff12.xlsb b/src/TestData/Test_Clipboard_Biff12.xlsb new file mode 100644 index 00000000..8d9b8210 Binary files /dev/null and b/src/TestData/Test_Clipboard_Biff12.xlsb differ diff --git a/src/TestData/Test_Clipboard_Biff8.xls b/src/TestData/Test_Clipboard_Biff8.xls new file mode 100644 index 00000000..30d8f0f4 Binary files /dev/null and b/src/TestData/Test_Clipboard_Biff8.xls differ diff --git a/src/TestData/Test_git_issue_461.xlsx b/src/TestData/Test_git_issue_461.xlsx new file mode 100644 index 00000000..58a7537a Binary files /dev/null and b/src/TestData/Test_git_issue_461.xlsx differ diff --git a/src/TestData/Test_git_issue_541.xls b/src/TestData/Test_git_issue_541.xls new file mode 100644 index 00000000..04570dbe Binary files /dev/null and b/src/TestData/Test_git_issue_541.xls differ diff --git a/src/TestData/Test_git_issue_541.xlsb b/src/TestData/Test_git_issue_541.xlsb new file mode 100644 index 00000000..48918514 Binary files /dev/null and b/src/TestData/Test_git_issue_541.xlsb differ diff --git a/src/TestData/Test_git_issue_541.xlsx b/src/TestData/Test_git_issue_541.xlsx new file mode 100644 index 00000000..d0bb1682 Binary files /dev/null and b/src/TestData/Test_git_issue_541.xlsx differ diff --git a/src/TestData/strict/Test_git_issue_541.xlsx b/src/TestData/strict/Test_git_issue_541.xlsx new file mode 100644 index 00000000..c5647424 Binary files /dev/null and b/src/TestData/strict/Test_git_issue_541.xlsx differ diff --git a/src/TestData/xls/MACRO1.XLM b/src/TestData/xls/MACRO1.XLM new file mode 100755 index 00000000..a66f2885 Binary files /dev/null and b/src/TestData/xls/MACRO1.XLM differ diff --git a/src/TestData/xls/SIMPLE.XLS b/src/TestData/xls/SIMPLE.XLS new file mode 100755 index 00000000..324ddf87 Binary files /dev/null and b/src/TestData/xls/SIMPLE.XLS differ