From 7182657633d303d439661cb1f1bc3a42152034da Mon Sep 17 00:00:00 2001 From: Victor Irzak Date: Fri, 9 Oct 2026 23:35:36 -0400 Subject: [PATCH 1/8] Add QueryCells to read cell formulas and font colors Row queries drop whether a value came from a formula and how the cell is styled, which callers need when importing hand-edited sheets. --- src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs | 39 +++++ src/MiniExcel.OpenXml/Models/ExcelCell.cs | 94 +++++++++++ .../Reader/OpenXmlReader.Cells.cs | 158 ++++++++++++++++++ src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs | 2 +- src/MiniExcel.OpenXml/Styles/OpenXmlStyles.cs | 59 ++++++- .../Cells/QueryCellsAsyncTests.cs | 118 +++++++++++++ .../Cells/QueryCellsTests.cs | 30 ++++ 7 files changed, 498 insertions(+), 2 deletions(-) create mode 100644 src/MiniExcel.OpenXml/Models/ExcelCell.cs create mode 100644 src/MiniExcel.OpenXml/Reader/OpenXmlReader.Cells.cs create mode 100644 tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsAsyncTests.cs create mode 100644 tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsTests.cs diff --git a/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs b/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs index e5e44342..9d65bf78 100644 --- a/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs +++ b/src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs @@ -512,6 +512,45 @@ public async Task RetrieveCommentsAsync(Stream stream, string? return await reader.ReadCommentsAsync(sheetName, cancellationToken).ConfigureAwait(false); } + /// + /// Queries every cell stored in a worksheet together with its formula and font color. + /// + /// The path to the Excel document. + /// The name of the worksheet to query. If not specified, the first sheet is used. + /// A token to cancel the asynchronous operation. + /// + /// Cells are returned in document order, one per cell element in the file, so cells that were never written are skipped. + /// + [CreateSyncVersion] + public async IAsyncEnumerable QueryCellsAsync(string path, string? sheetName = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var stream = FileHelper.OpenSharedRead(path); + await using var disposableStream = stream.ConfigureAwait(false); + + await foreach (var cell in QueryCellsAsync(stream, sheetName, false, cancellationToken).ConfigureAwait(false)) + yield return cell; + } + + /// + /// Queries every cell stored in a worksheet together with its formula and font color. + /// + /// The stream containing the Excel file data. The stream position is not reset after reading. + /// The name of the worksheet to query. If not specified, the first sheet is used. + /// True to leave the stream open after the query is completed, otherwise false. + /// A token to cancel the asynchronous operation. + /// + /// Cells are returned in document order, one per cell element in the file, so cells that were never written are skipped. + /// + [CreateSyncVersion] + public async IAsyncEnumerable QueryCellsAsync(Stream stream, string? sheetName = null, bool leaveOpen = false, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + var reader = await OpenXmlReader.CreateAsync(stream, null, leaveOpen, cancellationToken).ConfigureAwait(false); + await using var disposableReader = reader.ConfigureAwait(false); + + await foreach (var cell in reader.QueryCellsAsync(sheetName, cancellationToken).ConfigureAwait(false)) + yield return cell; + } + /// /// Queries a named table in an Excel worksheet and returns dynamic objects representing each row. /// diff --git a/src/MiniExcel.OpenXml/Models/ExcelCell.cs b/src/MiniExcel.OpenXml/Models/ExcelCell.cs new file mode 100644 index 00000000..661f641e --- /dev/null +++ b/src/MiniExcel.OpenXml/Models/ExcelCell.cs @@ -0,0 +1,94 @@ +namespace MiniExcelLib.OpenXml.Models; + +/// +/// A worksheet cell together with the metadata that row queries leave out. +/// +public class ExcelCell +{ + internal ExcelCell(string reference, int rowIndex, int columnIndex, object? value, bool hasFormula, string? formula, ExcelColor? fontColor) + { + Reference = reference; + RowIndex = rowIndex; + ColumnIndex = columnIndex; + Value = value; + HasFormula = hasFormula; + Formula = formula; + FontColor = fontColor; + } + + /// + /// The A1-style reference of the cell, e.g. "B3". + /// + public string Reference { get; } + + /// + /// The 1-based row number of the cell. + /// + public int RowIndex { get; } + + /// + /// The 1-based column number of the cell. + /// + public int ColumnIndex { get; } + + /// + /// The cell value, converted the same way as in row queries. For formula cells this is the result cached by the application that last saved the file. + /// + public object? Value { get; } + + /// + /// True when the value of the cell is computed by a formula. + /// + public bool HasFormula { get; } + + /// + /// The formula text as stored in the file, without the leading '='. + /// Cells that only share the formula of another cell report as true and this property as . + /// + public string? Formula { get; } + + /// + /// The font color applied to the cell, or when its font declares no color. + /// + public ExcelColor? FontColor { get; } +} + +/// +/// A color as declared in the workbook styles. Exactly one of , , or is normally set. +/// +public class ExcelColor +{ + internal ExcelColor(bool auto, string? rgb, int? theme, int? indexed, double tint) + { + Auto = auto; + Rgb = rgb; + Theme = theme; + Indexed = indexed; + Tint = tint; + } + + /// + /// True when the color is the automatic system color. + /// + public bool Auto { get; } + + /// + /// The ARGB hex value, e.g. "FF000000". + /// + public string? Rgb { get; } + + /// + /// The zero-based index into the workbook theme colors (0 = Light 1, 1 = Dark 1, 2 = Light 2, 3 = Dark 2, 4-9 = Accent 1-6). + /// + public int? Theme { get; } + + /// + /// The index into the legacy indexed color palette. + /// + public int? Indexed { get; } + + /// + /// The lightening (positive) or darkening (negative) applied to the color, from -1 to 1. + /// + public double Tint { get; } +} diff --git a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Cells.cs b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Cells.cs new file mode 100644 index 00000000..4145b4a8 --- /dev/null +++ b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Cells.cs @@ -0,0 +1,158 @@ +using MiniExcelLib.OpenXml.Styles; +using XmlReaderHelper = MiniExcelLib.OpenXml.Utils.XmlReaderHelper; + +namespace MiniExcelLib.OpenXml.Reader; + +internal partial class OpenXmlReader +{ + [CreateSyncVersion] + internal async IAsyncEnumerable QueryCellsAsync(string? sheetName, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var sheetEntry = GetSheetEntry(sheetName); + var sheetStream = await sheetEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableSheetStream = sheetStream.ConfigureAwait(false); + + using var reader = XmlReader.Create(sheetStream, XmlReaderHelper.GetXmlReaderSettings()); + + if (!reader.IsStartElement("worksheet", Ns)) + yield break; + if (!await reader.ReadFirstContentAsync(cancellationToken).ConfigureAwait(false)) + yield break; + + while (!reader.EOF) + { + if (!reader.IsStartElement("sheetData", Ns)) + { + if (!await reader.SkipContentAsync(cancellationToken).ConfigureAwait(false)) + break; + + continue; + } + + if (!await reader.ReadFirstContentAsync(cancellationToken).ConfigureAwait(false)) + yield break; + + var rowNumber = 0; + while (!reader.EOF) + { + if (reader.IsStartElement("row", Ns)) + { + if (reader.GetAttribute("r") is { } rowAttribute) + { + if (!int.TryParse(rowAttribute, NumberStyles.None, CultureInfo.InvariantCulture, out rowNumber) || + rowNumber is < 1 or > CellReferenceConverter.MaxRowNumber) + { + throw new InvalidDataException($"Row index '{rowAttribute}' is outside Excel's valid range."); + } + } + else if (++rowNumber > CellReferenceConverter.MaxRowNumber) + { + throw new InvalidDataException("The worksheet contains more rows than Excel supports."); + } + + if (!await reader.ReadFirstContentAsync(cancellationToken).ConfigureAwait(false)) + continue; + + var columnNumber = 0; + while (!reader.EOF) + { + if (reader.IsStartElement("c", Ns)) + { + var cell = await ReadCellAsync(reader, rowNumber, columnNumber + 1, cancellationToken).ConfigureAwait(false); + columnNumber = cell.ColumnIndex; + yield return cell; + } + else if (!await reader.SkipContentAsync(cancellationToken).ConfigureAwait(false)) + { + break; + } + } + } + else if (!await reader.SkipContentAsync(cancellationToken).ConfigureAwait(false)) + { + break; + } + } + + yield break; + } + } + + [CreateSyncVersion] + private async Task ReadCellAsync(XmlReader reader, int rowNumber, int nextColumnNumber, CancellationToken cancellationToken) + { + var aR = reader.GetAttribute("r"); + var aT = reader.GetAttribute("t"); + var aS = reader.GetAttribute("s"); + + var columnNumber = nextColumnNumber; + if (CellReferenceConverter.TryParseCellReference(aR, out var referenceColumn, out _)) + { + columnNumber = referenceColumn; + } + else if (columnNumber > CellReferenceConverter.MaxColumnNumber) + { + throw new InvalidDataException("The worksheet contains more columns than Excel supports."); + } + + object? value = null; + var hasFormula = false; + string? formula = null; + + if (await reader.ReadFirstContentAsync(cancellationToken).ConfigureAwait(false)) + { + while (!reader.EOF) + { + if (reader.IsStartElement("f", Ns)) + { + hasFormula = true; + var formulaText = await reader.ReadElementContentAsStringAsync() +#if NET + .WaitAsync(cancellationToken) +#endif + .ConfigureAwait(false); + + formula = string.IsNullOrEmpty(formulaText) ? null : formulaText; + } + else if (reader.IsStartElement("v", Ns)) + { + var rawValue = await reader.ReadElementContentAsStringAsync() +#if NET + .WaitAsync(cancellationToken) +#endif + .ConfigureAwait(false); + + if (!string.IsNullOrEmpty(rawValue)) + ConvertCellValue(rawValue, aT, -1, out value); + } + else if (reader.IsStartElement("is", Ns)) + { + var rawValue = await reader.ReadStringItemAsync(cancellationToken).ConfigureAwait(false); + if (!string.IsNullOrEmpty(rawValue)) + ConvertCellValue(rawValue, aT, -1, out value); + } + else if (!await reader.SkipContentAsync(cancellationToken).ConfigureAwait(false)) + { + break; + } + } + } + + // A cell without an s attribute uses the first cell format, so its font is the workbook default font + var hasStyleIndex = int.TryParse(aS, NumberStyles.Any, CultureInfo.InvariantCulture, out var xfIndex); + ExcelColor? fontColor = null; + if (hasStyleIndex || Archive.GetEntry(ExcelFileNames.Styles) is not null) + { + _style ??= await OpenXmlStyles.CreateAsync(Archive, cancellationToken).ConfigureAwait(false); + if (hasStyleIndex) + value = _style.ConvertValueByStyleFormat(xfIndex, value); + + fontColor = _style.GetFontColor(xfIndex); + } + + var reference = CellReferenceConverter.GetCellFromCoordinates(columnNumber, rowNumber); + return new ExcelCell(reference, rowNumber, columnNumber, value, hasFormula, formula, fontColor); + } +} diff --git a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs index 87a33008..6fbc44ec 100644 --- a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs +++ b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs @@ -705,7 +705,7 @@ protected async Task ReadCellAndSetColumnIndexAsync(XmlReader rea return new CellAndColumn(value, columnIndex); } - private void ConvertCellValue(string rawValue, string aT, int xfIndex, out object? value) + private void ConvertCellValue(string rawValue, string? aT, int xfIndex, out object? value) { const NumberStyles style = NumberStyles.Any; var invariantCulture = CultureInfo.InvariantCulture; diff --git a/src/MiniExcel.OpenXml/Styles/OpenXmlStyles.cs b/src/MiniExcel.OpenXml/Styles/OpenXmlStyles.cs index 110d7885..6092680c 100644 --- a/src/MiniExcel.OpenXml/Styles/OpenXmlStyles.cs +++ b/src/MiniExcel.OpenXml/Styles/OpenXmlStyles.cs @@ -9,6 +9,7 @@ internal partial class OpenXmlStyles private readonly Dictionary _cellXfs = new(); private readonly Dictionary _cellStyleXfs = new(); private readonly Dictionary _customFormats = new(); + private readonly List _fontColors = []; private OpenXmlStyles() { } @@ -43,7 +44,8 @@ internal static async Task CreateAsync(OpenXmlZip zip, Cancellati { int.TryParse(reader.GetAttribute("xfId"), out var xfId); int.TryParse(reader.GetAttribute("numFmtId"), out var numFmtId); - openXmlStyles._cellXfs.Add(index, new StyleRecord { XfId = xfId, NumFmtId = numFmtId }); + int.TryParse(reader.GetAttribute("fontId"), out var fontId); + openXmlStyles._cellXfs.Add(index, new StyleRecord { XfId = xfId, NumFmtId = numFmtId, FontId = fontId }); await reader.SkipAsync().ConfigureAwait(false); index++; } @@ -74,6 +76,23 @@ internal static async Task CreateAsync(OpenXmlZip zip, Cancellati } } } + else if (reader.IsStartElement("fonts", Ns)) + { + if (!await reader.ReadFirstContentAsync(cancellationToken).ConfigureAwait(false)) + continue; + + while (!reader.EOF) + { + if (reader.IsStartElement("font", Ns)) + { + openXmlStyles._fontColors.Add(await ReadFontColorAsync(reader, cancellationToken).ConfigureAwait(false)); + } + else if (!await reader.SkipContentAsync(cancellationToken).ConfigureAwait(false)) + { + break; + } + } + } else if (reader.IsStartElement("numFmts", Ns)) { if (!await reader.ReadFirstContentAsync(cancellationToken).ConfigureAwait(false)) @@ -111,6 +130,43 @@ internal static async Task CreateAsync(OpenXmlZip zip, Cancellati return openXmlStyles; } + [CreateSyncVersion] + private static async Task ReadFontColorAsync(XmlReader reader, CancellationToken cancellationToken) + { + ExcelColor? color = null; + if (!await reader.ReadFirstContentAsync(cancellationToken).ConfigureAwait(false)) + return color; + + while (!reader.EOF) + { + if (reader.IsStartElement("color", Ns)) + { + color = new ExcelColor( + auto: reader.GetAttribute("auto") is "1" or "true", + rgb: reader.GetAttribute("rgb"), + theme: int.TryParse(reader.GetAttribute("theme"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var theme) ? theme : null, + indexed: int.TryParse(reader.GetAttribute("indexed"), NumberStyles.Integer, CultureInfo.InvariantCulture, out var indexed) ? indexed : null, + tint: double.TryParse(reader.GetAttribute("tint"), NumberStyles.Float, CultureInfo.InvariantCulture, out var tint) ? tint : 0); + await reader.SkipAsync().ConfigureAwait(false); + } + else if (!await reader.SkipContentAsync(cancellationToken).ConfigureAwait(false)) + { + break; + } + } + + return color; + } + + internal ExcelColor? GetFontColor(int index) + { + if (!_cellXfs.TryGetValue(index, out var styleRecord)) + return null; + + var fontId = styleRecord.FontId; + return fontId >= 0 && fontId < _fontColors.Count ? _fontColors[fontId] : null; + } + internal NumberFormatString? GetStyleFormat(int index) { if (!_cellXfs.TryGetValue(index, out var styleRecord)) @@ -196,4 +252,5 @@ internal class StyleRecord { public int XfId { get; set; } public int NumFmtId { get; set; } + public int FontId { get; set; } } \ No newline at end of file diff --git a/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsAsyncTests.cs b/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsAsyncTests.cs new file mode 100644 index 00000000..3b331889 --- /dev/null +++ b/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsAsyncTests.cs @@ -0,0 +1,118 @@ +using System.Drawing; +using OfficeOpenXml.Drawing; +using LicenseContext = OfficeOpenXml.LicenseContext; + +namespace MiniExcelLib.OpenXml.Tests.Cells; + +public class QueryCellsAsyncTests +{ + private readonly OpenXmlImporter _excelImporter = MiniExcelV2.Importers.GetOpenXmlImporter(); + private readonly OpenXmlExporter _excelExporter = MiniExcelV2.Exporters.GetOpenXmlExporter(); + + public QueryCellsAsyncTests() + { + ExcelPackage.LicenseContext = LicenseContext.NonCommercial; + } + + internal static MemoryStream CreateWorkbook() + { + using var package = new ExcelPackage(); + var sheet = package.Workbook.Worksheets.Add("Sheet1"); + + sheet.Cells["A1"].Value = 1; + sheet.Cells["A2"].Value = 2; + sheet.Cells["A2"].Style.Font.Color.SetColor(Color.Red); + sheet.Cells["A3"].Value = 3; + sheet.Cells["A3"].Style.Font.Color.SetColor(eThemeSchemeColor.Text1); + sheet.Cells["A4"].Value = "text"; + sheet.Cells["A4"].Style.Font.Color.SetColor(eThemeSchemeColor.Accent2); + sheet.Cells["A4"].Style.Font.Color.Tint = 0.4m; + + // Assigning one formula to a range makes EPPlus store it as a shared formula + sheet.Cells["B1:B3"].Formula = "A1*2"; + sheet.Cells["C1"].Formula = "\"x\"&A1"; + + package.Workbook.Calculate(); + + var stream = new MemoryStream(); + package.SaveAs(stream); + stream.Position = 0; + return stream; + } + + [Fact] + public async Task QueryCells_ReturnsValuesAndReferencesAsync() + { + using var stream = CreateWorkbook(); + var cells = await _excelImporter.QueryCellsAsync(stream).ToDictionaryAsync(c => c.Reference); + + Assert.Equal(1d, cells["A1"].Value); + Assert.Equal(1, cells["A1"].RowIndex); + Assert.Equal(1, cells["A1"].ColumnIndex); + Assert.Equal("text", cells["A4"].Value); + Assert.Equal(4, cells["A4"].RowIndex); + Assert.Equal(3, cells["C1"].ColumnIndex); + } + + [Fact] + public async Task QueryCells_ReportsFormulasAsync() + { + using var stream = CreateWorkbook(); + var cells = await _excelImporter.QueryCellsAsync(stream).ToDictionaryAsync(c => c.Reference); + + Assert.False(cells["A1"].HasFormula); + Assert.Null(cells["A1"].Formula); + + Assert.True(cells["B1"].HasFormula); + Assert.Equal("A1*2", cells["B1"].Formula); + Assert.Equal(2d, cells["B1"].Value); + + // Cells sharing the formula of B1 carry no formula text of their own + Assert.True(cells["B3"].HasFormula); + Assert.Null(cells["B3"].Formula); + Assert.Equal(6d, cells["B3"].Value); + + Assert.True(cells["C1"].HasFormula); + Assert.Equal("\"x\"&A1", cells["C1"].Formula); + Assert.Equal("x1", cells["C1"].Value); + } + + [Fact] + public async Task QueryCells_ReportsFontColorsAsync() + { + using var stream = CreateWorkbook(); + var cells = await _excelImporter.QueryCellsAsync(stream).ToDictionaryAsync(c => c.Reference); + + Assert.Equal("FFFF0000", cells["A2"].FontColor?.Rgb); + Assert.Null(cells["A2"].FontColor?.Theme); + + Assert.Equal(1, cells["A3"].FontColor?.Theme); + Assert.Null(cells["A3"].FontColor?.Rgb); + Assert.Equal(0, cells["A3"].FontColor?.Tint); + + Assert.Equal(5, cells["A4"].FontColor?.Theme); + Assert.Equal(0.4, cells["A4"].FontColor!.Tint, 6); + + Assert.Null(cells["A1"].FontColor?.Rgb); + Assert.Null(cells["A1"].FontColor?.Theme); + } + + [Fact] + public async Task QueryCells_SelectsSheetByNameAsync() + { + using var stream = new MemoryStream(); + var sheets = new Dictionary + { + ["First"] = new[] { new { Value = "first" } }, + ["Second"] = new[] { new { Value = "second" } } + }; + await _excelExporter.ExportAsync(stream, sheets); + stream.Position = 0; + + var cells = await _excelImporter.QueryCellsAsync(stream, "Second").ToListAsync(); + + Assert.Equal(["A1", "A2"], cells.Select(c => c.Reference)); + Assert.Equal(["Value", "second"], cells.Select(c => c.Value)); + Assert.All(cells, c => Assert.False(c.HasFormula)); + } +} diff --git a/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsTests.cs b/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsTests.cs new file mode 100644 index 00000000..1c14d7de --- /dev/null +++ b/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsTests.cs @@ -0,0 +1,30 @@ +using LicenseContext = OfficeOpenXml.LicenseContext; + +namespace MiniExcelLib.OpenXml.Tests.Cells; + +public class QueryCellsTests +{ + private readonly OpenXmlImporter _excelImporter = MiniExcelV2.Importers.GetOpenXmlImporter(); + + public QueryCellsTests() + { + ExcelPackage.LicenseContext = LicenseContext.NonCommercial; + } + + [Fact] + public void QueryCells_ReportsFormulasAndFontColors() + { + using var stream = QueryCellsAsyncTests.CreateWorkbook(); + var cells = _excelImporter.QueryCells(stream).ToDictionary(c => c.Reference); + + Assert.False(cells["A1"].HasFormula); + Assert.True(cells["B1"].HasFormula); + Assert.Equal("A1*2", cells["B1"].Formula); + Assert.Equal(2d, cells["B1"].Value); + Assert.True(cells["B2"].HasFormula); + Assert.Null(cells["B2"].Formula); + + Assert.Equal("FFFF0000", cells["A2"].FontColor?.Rgb); + Assert.Equal(1, cells["A3"].FontColor?.Theme); + } +} From 88a3636001edb1c0dc552d7eca24d51e7307b001 Mon Sep 17 00:00:00 2001 From: Victor Irzak Date: Sat, 10 Oct 2026 15:24:44 -0400 Subject: [PATCH 2/8] Report cell number formats from QueryCells --- src/MiniExcel.OpenXml/Models/ExcelCell.cs | 8 +++++++- .../Reader/OpenXmlReader.Cells.cs | 5 ++++- .../Cells/QueryCellsAsyncTests.cs | 17 +++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/MiniExcel.OpenXml/Models/ExcelCell.cs b/src/MiniExcel.OpenXml/Models/ExcelCell.cs index 661f641e..46951e43 100644 --- a/src/MiniExcel.OpenXml/Models/ExcelCell.cs +++ b/src/MiniExcel.OpenXml/Models/ExcelCell.cs @@ -5,7 +5,7 @@ namespace MiniExcelLib.OpenXml.Models; /// public class ExcelCell { - internal ExcelCell(string reference, int rowIndex, int columnIndex, object? value, bool hasFormula, string? formula, ExcelColor? fontColor) + internal ExcelCell(string reference, int rowIndex, int columnIndex, object? value, bool hasFormula, string? formula, ExcelColor? fontColor, string? numberFormat) { Reference = reference; RowIndex = rowIndex; @@ -14,6 +14,7 @@ internal ExcelCell(string reference, int rowIndex, int columnIndex, object? valu HasFormula = hasFormula; Formula = formula; FontColor = fontColor; + NumberFormat = numberFormat; } /// @@ -51,6 +52,11 @@ internal ExcelCell(string reference, int rowIndex, int columnIndex, object? valu /// The font color applied to the cell, or when its font declares no color. /// public ExcelColor? FontColor { get; } + + /// + /// The number format code of the cell, e.g. "0.0%", or for the General format. + /// + public string? NumberFormat { get; } } /// diff --git a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Cells.cs b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Cells.cs index 4145b4a8..afdcd93c 100644 --- a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Cells.cs +++ b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Cells.cs @@ -143,6 +143,7 @@ private async Task ReadCellAsync(XmlReader reader, int rowNumber, int // A cell without an s attribute uses the first cell format, so its font is the workbook default font var hasStyleIndex = int.TryParse(aS, NumberStyles.Any, CultureInfo.InvariantCulture, out var xfIndex); ExcelColor? fontColor = null; + string? numberFormat = null; if (hasStyleIndex || Archive.GetEntry(ExcelFileNames.Styles) is not null) { _style ??= await OpenXmlStyles.CreateAsync(Archive, cancellationToken).ConfigureAwait(false); @@ -150,9 +151,11 @@ private async Task ReadCellAsync(XmlReader reader, int rowNumber, int value = _style.ConvertValueByStyleFormat(xfIndex, value); fontColor = _style.GetFontColor(xfIndex); + if (_style.GetStyleFormat(xfIndex)?.FormatCode is { } formatCode and not "General") + numberFormat = formatCode; } var reference = CellReferenceConverter.GetCellFromCoordinates(columnNumber, rowNumber); - return new ExcelCell(reference, rowNumber, columnNumber, value, hasFormula, formula, fontColor); + return new ExcelCell(reference, rowNumber, columnNumber, value, hasFormula, formula, fontColor, numberFormat); } } diff --git a/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsAsyncTests.cs b/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsAsyncTests.cs index 3b331889..2ef5089c 100644 --- a/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsAsyncTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsAsyncTests.cs @@ -97,6 +97,23 @@ public async Task QueryCells_ReportsFontColorsAsync() Assert.Null(cells["A1"].FontColor?.Theme); } + [Fact] + public async Task QueryCells_ReportsNumberFormatsAsync() + { + using var package = new ExcelPackage(); + var sheet = package.Workbook.Worksheets.Add("Sheet1"); + sheet.Cells["A1"].Value = 0.233; + sheet.Cells["A1"].Style.Numberformat.Format = "0.0%"; + sheet.Cells["A2"].Value = 5; + using var stream = new MemoryStream(package.GetAsByteArray()); + + var cells = await _excelImporter.QueryCellsAsync(stream).ToDictionaryAsync(c => c.Reference); + + Assert.Equal("0.0%", cells["A1"].NumberFormat); + Assert.Equal(0.233, cells["A1"].Value); + Assert.Null(cells["A2"].NumberFormat); + } + [Fact] public async Task QueryCells_SelectsSheetByNameAsync() { From 643215ac3e134e41bb302f5212b0da1be18eb3f1 Mon Sep 17 00:00:00 2001 From: Victor Irzak Date: Sun, 11 Oct 2026 08:02:36 -0400 Subject: [PATCH 3/8] Document QueryCells in README_V2 --- README_V2.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/README_V2.md b/README_V2.md index 8402a915..91458f27 100644 --- a/README_V2.md +++ b/README_V2.md @@ -447,6 +447,32 @@ var importer = MiniExcelV2.Importers.GetOpenXmlImporter(); importer.Query(path, configuration: config) ``` +#### 13. Read cell formulas, font colors and number formats + +`Query` returns only the cached values. To find out which cells hold formulas, or how they are formatted, use `QueryCells`, which yields every stored cell of a worksheet: + +```csharp +var importer = MiniExcelV2.Importers.GetOpenXmlImporter(); + +foreach (var cell in importer.QueryCells(path, sheetName: "Sheet1")) +{ + Console.WriteLine($"{cell.Reference}: {cell.Value}"); + + if (cell.HasFormula) + Console.WriteLine($" formula: {cell.Formula ?? "(shared)"}"); + + if (cell.FontColor?.Rgb is { } rgb) + Console.WriteLine($" font color: {rgb}"); + + if (cell.NumberFormat is not null) + Console.WriteLine($" number format: {cell.NumberFormat}"); +} +``` + +Cells covered by a shared formula report `HasFormula` as `true` but leave `Formula` null, since only the first cell of the range stores the formula text. +Font colors can also be theme colors (`Theme` and `Tint`), indexed colors (`Indexed`) or automatic (`Auto`). +`NumberFormat` is null for cells in the General format. + ### Create/Export Excel From 08a6fd653a30a9f7abefa390d202c20eb21f4286 Mon Sep 17 00:00:00 2001 From: Victor Irzak Date: Sun, 11 Oct 2026 08:24:51 -0400 Subject: [PATCH 4/8] Reject out-of-range cell references in QueryCells --- .../Reader/OpenXmlReader.Cells.cs | 4 ++++ .../Cells/QueryCellsAsyncTests.cs | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Cells.cs b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Cells.cs index afdcd93c..6ee40e60 100644 --- a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Cells.cs +++ b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Cells.cs @@ -92,6 +92,10 @@ private async Task ReadCellAsync(XmlReader reader, int rowNumber, int { columnNumber = referenceColumn; } + else if (!string.IsNullOrEmpty(aR) && referenceColumn > CellReferenceConverter.MaxColumnNumber) + { + throw new InvalidDataException($"Cell reference '{aR}' is invalid."); + } else if (columnNumber > CellReferenceConverter.MaxColumnNumber) { throw new InvalidDataException("The worksheet contains more columns than Excel supports."); diff --git a/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsAsyncTests.cs b/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsAsyncTests.cs index 2ef5089c..3db77329 100644 --- a/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsAsyncTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsAsyncTests.cs @@ -132,4 +132,28 @@ public async Task QueryCells_SelectsSheetByNameAsync() Assert.Equal(["Value", "second"], cells.Select(c => c.Value)); Assert.All(cells, c => Assert.False(c.HasFormula)); } + + [Fact] + public async Task QueryCells_RejectsOutOfRangeCellReferenceAsync() + { + using var stream = new MemoryStream(); + await _excelExporter.ExportAsync(stream, new[] { new { Value = "value" } }); + + using (var archive = new ZipArchive(stream, ZipArchiveMode.Update, leaveOpen: true)) + { + var entry = archive.GetEntry("xl/worksheets/sheet1.xml")!; + string xml; + using (var reader = new StreamReader(entry.Open())) + xml = await reader.ReadToEndAsync(); + + Assert.Contains("r=\"A2\"", xml); + entry.Delete(); + using var writer = new StreamWriter(archive.CreateEntry("xl/worksheets/sheet1.xml").Open()); + await writer.WriteAsync(xml.Replace("r=\"A2\"", "r=\"XFE2\"")); + } + stream.Position = 0; + + var exception = await Assert.ThrowsAsync(() => _excelImporter.QueryCellsAsync(stream).ToListAsync().AsTask()); + Assert.Contains("XFE2", exception.Message); + } } From 6507859db98775b4d0f9e214e7083049329fbc64 Mon Sep 17 00:00:00 2001 From: Victor Irzak Date: Wed, 14 Oct 2026 09:51:20 -0400 Subject: [PATCH 5/8] Build ExcelCell.Reference on demand Building the string for every cell made QueryCells several times slower on large sheets. --- src/MiniExcel.OpenXml/Models/ExcelCell.cs | 6 +++--- src/MiniExcel.OpenXml/Reader/OpenXmlReader.Cells.cs | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/MiniExcel.OpenXml/Models/ExcelCell.cs b/src/MiniExcel.OpenXml/Models/ExcelCell.cs index 46951e43..56191eaf 100644 --- a/src/MiniExcel.OpenXml/Models/ExcelCell.cs +++ b/src/MiniExcel.OpenXml/Models/ExcelCell.cs @@ -5,9 +5,8 @@ namespace MiniExcelLib.OpenXml.Models; /// public class ExcelCell { - internal ExcelCell(string reference, int rowIndex, int columnIndex, object? value, bool hasFormula, string? formula, ExcelColor? fontColor, string? numberFormat) + internal ExcelCell(int rowIndex, int columnIndex, object? value, bool hasFormula, string? formula, ExcelColor? fontColor, string? numberFormat) { - Reference = reference; RowIndex = rowIndex; ColumnIndex = columnIndex; Value = value; @@ -20,7 +19,8 @@ internal ExcelCell(string reference, int rowIndex, int columnIndex, object? valu /// /// The A1-style reference of the cell, e.g. "B3". /// - public string Reference { get; } + // Built on demand: most callers only need the indices, and a string per cell adds up on large sheets + public string Reference => CellReferenceConverter.GetCellFromCoordinates(ColumnIndex, RowIndex); /// /// The 1-based row number of the cell. diff --git a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Cells.cs b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Cells.cs index 6ee40e60..253d9346 100644 --- a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Cells.cs +++ b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Cells.cs @@ -159,7 +159,6 @@ private async Task ReadCellAsync(XmlReader reader, int rowNumber, int numberFormat = formatCode; } - var reference = CellReferenceConverter.GetCellFromCoordinates(columnNumber, rowNumber); - return new ExcelCell(reference, rowNumber, columnNumber, value, hasFormula, formula, fontColor, numberFormat); + return new ExcelCell(rowNumber, columnNumber, value, hasFormula, formula, fontColor, numberFormat); } } From 272a57c660dc0d4bbd69f8e2e8f3a2f12309e380 Mon Sep 17 00:00:00 2001 From: Victor Irzak Date: Wed, 14 Oct 2026 11:52:50 -0400 Subject: [PATCH 6/8] Benchmark QueryCells --- .../BenchmarkSections/QueryExcelBenchmark.cs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/QueryExcelBenchmark.cs b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/QueryExcelBenchmark.cs index ef8dd9c1..b5ca1da1 100644 --- a/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/QueryExcelBenchmark.cs +++ b/benchmarks/MiniExcel.Benchmarks/BenchmarkSections/QueryExcelBenchmark.cs @@ -57,6 +57,15 @@ public void MiniExcel_Query() } } + [Benchmark(Description = "MiniExcel QueryCells")] + public void MiniExcel_QueryCells() + { + foreach (var cell in _importer.QueryCells(FilePath)) + { + _ = cell; + } + } + [Benchmark(Description = "MiniExcel QueryFirst with Mapping")] public void MiniExcel_QueryFirst_Mapping_Test() { From ea3a166560fbdd68e5e3887acdb75d935f5293b5 Mon Sep 17 00:00:00 2001 From: Victor Irzak Date: Wed, 14 Oct 2026 12:45:05 -0400 Subject: [PATCH 7/8] Make ExcelCell a readonly struct QueryCells returns one per cell, so a class cost an allocation per cell. --- src/MiniExcel.OpenXml/Models/ExcelCell.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/MiniExcel.OpenXml/Models/ExcelCell.cs b/src/MiniExcel.OpenXml/Models/ExcelCell.cs index 56191eaf..109a25bd 100644 --- a/src/MiniExcel.OpenXml/Models/ExcelCell.cs +++ b/src/MiniExcel.OpenXml/Models/ExcelCell.cs @@ -3,7 +3,7 @@ namespace MiniExcelLib.OpenXml.Models; /// /// A worksheet cell together with the metadata that row queries leave out. /// -public class ExcelCell +public readonly struct ExcelCell { internal ExcelCell(int rowIndex, int columnIndex, object? value, bool hasFormula, string? formula, ExcelColor? fontColor, string? numberFormat) { From 3544f395a8f4afaf0b225cf0fea4dc50127bf2ed Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Thu, 15 Oct 2026 15:58:15 +0200 Subject: [PATCH 8/8] Minor changes Fixed a typo and a newline, and slightly adjusted the new tests --- src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs | 4 ++-- src/MiniExcel.OpenXml/Styles/OpenXmlStyles.cs | 2 +- .../Cells/QueryCellsAsyncTests.cs | 17 +++++++++++++---- .../Cells/QueryCellsTests.cs | 2 -- 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs index 6fbc44ec..2b381298 100644 --- a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs +++ b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs @@ -7,7 +7,7 @@ namespace MiniExcelLib.OpenXml.Reader; internal partial class OpenXmlReader : IMiniExcelReader { - private static readonly string[] RelationshiopNs = [Schemas.SpreadsheetmlXmlRelationships, Schemas.SpreadsheetmlXmlStrictRelationships]; + private static readonly string[] RelationshipNs = [Schemas.SpreadsheetmlXmlRelationships, Schemas.SpreadsheetmlXmlStrictRelationships]; protected static readonly string[] Ns = [Schemas.SpreadsheetmlXmlMain, Schemas.SpreadsheetmlXmlStrictNs]; protected readonly OpenXmlConfiguration _config; @@ -555,7 +555,7 @@ await reader.SkipAsync() sheetName, reader.GetAttribute("state"), uint.TryParse(reader.GetAttribute("sheetId"), out var sheetId) ? sheetId : 0, - reader.GetAttribute("id", RelationshiopNs), + reader.GetAttribute("id", RelationshipNs), sheetCount == activeSheetIndex ); sheetCount++; diff --git a/src/MiniExcel.OpenXml/Styles/OpenXmlStyles.cs b/src/MiniExcel.OpenXml/Styles/OpenXmlStyles.cs index 6092680c..40a268fb 100644 --- a/src/MiniExcel.OpenXml/Styles/OpenXmlStyles.cs +++ b/src/MiniExcel.OpenXml/Styles/OpenXmlStyles.cs @@ -253,4 +253,4 @@ internal class StyleRecord public int XfId { get; set; } public int NumFmtId { get; set; } public int FontId { get; set; } -} \ No newline at end of file +} diff --git a/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsAsyncTests.cs b/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsAsyncTests.cs index 3db77329..4b575ce1 100644 --- a/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsAsyncTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsAsyncTests.cs @@ -1,6 +1,6 @@ using System.Drawing; +using MiniExcelLib.Core.Helpers; using OfficeOpenXml.Drawing; -using LicenseContext = OfficeOpenXml.LicenseContext; namespace MiniExcelLib.OpenXml.Tests.Cells; @@ -105,7 +105,9 @@ public async Task QueryCells_ReportsNumberFormatsAsync() sheet.Cells["A1"].Value = 0.233; sheet.Cells["A1"].Style.Numberformat.Format = "0.0%"; sheet.Cells["A2"].Value = 5; - using var stream = new MemoryStream(package.GetAsByteArray()); + + await using var stream = new MemoryStream(); + await package.SaveAsAsync(stream); var cells = await _excelImporter.QueryCellsAsync(stream).ToDictionaryAsync(c => c.Reference); @@ -142,13 +144,20 @@ public async Task QueryCells_RejectsOutOfRangeCellReferenceAsync() using (var archive = new ZipArchive(stream, ZipArchiveMode.Update, leaveOpen: true)) { var entry = archive.GetEntry("xl/worksheets/sheet1.xml")!; + string xml; - using (var reader = new StreamReader(entry.Open())) + using (var reader = new StreamReader(await entry.OpenAsync())) + { xml = await reader.ReadToEndAsync(); + } Assert.Contains("r=\"A2\"", xml); entry.Delete(); - using var writer = new StreamWriter(archive.CreateEntry("xl/worksheets/sheet1.xml").Open()); + + var newEntry = archive.CreateEntry("xl/worksheets/sheet1.xml"); + await using var entryStream = await newEntry.OpenAsync(); + await using var writer = new StreamWriter(entryStream); + await writer.WriteAsync(xml.Replace("r=\"A2\"", "r=\"XFE2\"")); } stream.Position = 0; diff --git a/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsTests.cs b/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsTests.cs index 1c14d7de..d18043ac 100644 --- a/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsTests.cs @@ -1,5 +1,3 @@ -using LicenseContext = OfficeOpenXml.LicenseContext; - namespace MiniExcelLib.OpenXml.Tests.Cells; public class QueryCellsTests