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
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()
{
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..109a25bd
--- /dev/null
+++ b/src/MiniExcel.OpenXml/Models/ExcelCell.cs
@@ -0,0 +1,100 @@
+namespace MiniExcelLib.OpenXml.Models;
+
+///
+/// A worksheet cell together with the metadata that row queries leave out.
+///
+public readonly struct ExcelCell
+{
+ internal ExcelCell(int rowIndex, int columnIndex, object? value, bool hasFormula, string? formula, ExcelColor? fontColor, string? numberFormat)
+ {
+ RowIndex = rowIndex;
+ ColumnIndex = columnIndex;
+ Value = value;
+ HasFormula = hasFormula;
+ Formula = formula;
+ FontColor = fontColor;
+ NumberFormat = numberFormat;
+ }
+
+ ///
+ /// The A1-style reference of the cell, e.g. "B3".
+ ///
+ // 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.
+ ///
+ 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; }
+
+ ///
+ /// The number format code of the cell, e.g. "0.0%", or for the General format.
+ ///
+ public string? NumberFormat { 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..253d9346
--- /dev/null
+++ b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.Cells.cs
@@ -0,0 +1,164 @@
+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 (!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.");
+ }
+
+ 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;
+ string? numberFormat = 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);
+ if (_style.GetStyleFormat(xfIndex)?.FormatCode is { } formatCode and not "General")
+ numberFormat = formatCode;
+ }
+
+ return new ExcelCell(rowNumber, columnNumber, value, hasFormula, formula, fontColor, numberFormat);
+ }
+}
diff --git a/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs b/src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs
index 87a33008..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++;
@@ -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..40a268fb 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; }
-}
\ No newline at end of file
+ public int FontId { get; set; }
+}
diff --git a/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsAsyncTests.cs b/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsAsyncTests.cs
new file mode 100644
index 00000000..4b575ce1
--- /dev/null
+++ b/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsAsyncTests.cs
@@ -0,0 +1,168 @@
+using System.Drawing;
+using MiniExcelLib.Core.Helpers;
+using OfficeOpenXml.Drawing;
+
+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_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;
+
+ await using var stream = new MemoryStream();
+ await package.SaveAsAsync(stream);
+
+ 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()
+ {
+ 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));
+ }
+
+ [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(await entry.OpenAsync()))
+ {
+ xml = await reader.ReadToEndAsync();
+ }
+
+ Assert.Contains("r=\"A2\"", xml);
+ entry.Delete();
+
+ 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;
+
+ var exception = await Assert.ThrowsAsync(() => _excelImporter.QueryCellsAsync(stream).ToListAsync().AsTask());
+ Assert.Contains("XFE2", exception.Message);
+ }
+}
diff --git a/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsTests.cs b/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsTests.cs
new file mode 100644
index 00000000..d18043ac
--- /dev/null
+++ b/tests/MiniExcel.OpenXml.Tests/Cells/QueryCellsTests.cs
@@ -0,0 +1,28 @@
+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);
+ }
+}