Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions README_V2.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <a name="docs-export" />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()
{
Expand Down
39 changes: 39 additions & 0 deletions src/MiniExcel.OpenXml/Api/OpenXmlImporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,45 @@ public async Task<CommentResultSet> RetrieveCommentsAsync(Stream stream, string?
return await reader.ReadCommentsAsync(sheetName, cancellationToken).ConfigureAwait(false);
}

/// <summary>
/// Queries every cell stored in a worksheet together with its formula and font color.
/// </summary>
/// <param name="path">The path to the Excel document.</param>
/// <param name="sheetName">The name of the worksheet to query. If not specified, the first sheet is used.</param>
/// <param name="cancellationToken">A token to cancel the asynchronous operation.</param>
/// <remarks>
/// Cells are returned in document order, one <see cref="ExcelCell"/> per cell element in the file, so cells that were never written are skipped.
/// </remarks>
[CreateSyncVersion]
public async IAsyncEnumerable<ExcelCell> 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;
}

/// <summary>
/// Queries every cell stored in a worksheet together with its formula and font color.
/// </summary>
/// <param name="stream">The stream containing the Excel file data. The stream position is not reset after reading.</param>
/// <param name="sheetName">The name of the worksheet to query. If not specified, the first sheet is used.</param>
/// <param name="leaveOpen">True to leave the stream open after the query is completed, otherwise false.</param>
/// <param name="cancellationToken">A token to cancel the asynchronous operation.</param>
/// <remarks>
/// Cells are returned in document order, one <see cref="ExcelCell"/> per cell element in the file, so cells that were never written are skipped.
/// </remarks>
[CreateSyncVersion]
public async IAsyncEnumerable<ExcelCell> 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;
}

/// <summary>
/// Queries a named table in an Excel worksheet and returns dynamic objects representing each row.
/// </summary>
Expand Down
100 changes: 100 additions & 0 deletions src/MiniExcel.OpenXml/Models/ExcelCell.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
namespace MiniExcelLib.OpenXml.Models;

/// <summary>
/// A worksheet cell together with the metadata that row queries leave out.
/// </summary>
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;
}

/// <summary>
/// The A1-style reference of the cell, e.g. "B3".
/// </summary>
// 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);

/// <summary>
/// The 1-based row number of the cell.
/// </summary>
public int RowIndex { get; }

/// <summary>
/// The 1-based column number of the cell.
/// </summary>
public int ColumnIndex { get; }

/// <summary>
/// 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.
/// </summary>
public object? Value { get; }

/// <summary>
/// True when the value of the cell is computed by a formula.
/// </summary>
public bool HasFormula { get; }

/// <summary>
/// The formula text as stored in the file, without the leading '='.
/// Cells that only share the formula of another cell report <see cref="HasFormula"/> as true and this property as <see langword="null"/>.
/// </summary>
public string? Formula { get; }

/// <summary>
/// The font color applied to the cell, or <see langword="null"/> when its font declares no color.
/// </summary>
public ExcelColor? FontColor { get; }

/// <summary>
/// The number format code of the cell, e.g. "0.0%", or <see langword="null"/> for the General format.
/// </summary>
public string? NumberFormat { get; }
}

/// <summary>
/// A color as declared in the workbook styles. Exactly one of <see cref="Auto"/>, <see cref="Rgb"/>, <see cref="Theme"/> or <see cref="Indexed"/> is normally set.
/// </summary>
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;
}

/// <summary>
/// True when the color is the automatic system color.
/// </summary>
public bool Auto { get; }

/// <summary>
/// The ARGB hex value, e.g. "FF000000".
/// </summary>
public string? Rgb { get; }

/// <summary>
/// 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).
/// </summary>
public int? Theme { get; }

/// <summary>
/// The index into the legacy indexed color palette.
/// </summary>
public int? Indexed { get; }

/// <summary>
/// The lightening (positive) or darkening (negative) applied to the color, from -1 to 1.
/// </summary>
public double Tint { get; }
}
164 changes: 164 additions & 0 deletions src/MiniExcel.OpenXml/Reader/OpenXmlReader.Cells.cs
Original file line number Diff line number Diff line change
@@ -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<ExcelCell> 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<ExcelCell> 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.");
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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);
}
}
6 changes: 3 additions & 3 deletions src/MiniExcel.OpenXml/Reader/OpenXmlReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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++;
Expand Down Expand Up @@ -705,7 +705,7 @@ protected async Task<CellAndColumn> 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;
Expand Down
Loading
Loading