diff --git a/README_V2.md b/README_V2.md
index 64155f20..7e061d3a 100644
--- a/README_V2.md
+++ b/README_V2.md
@@ -171,6 +171,7 @@ The exporters also fully support asynchronous operations:
await exporter.ExportAsync(outputPath, values);
```
+
### Release Notes
If you're migrating from a `1.x` version, please check the [upgrade notes](V2-Upgrade-Notes.md).
@@ -201,6 +202,7 @@ You can find the benchmarks' results for the latest release [here](benchmarks/re
- [Query/Import](#docs-import)
- [Create/Export](#docs-export)
- [Excel Template](#docs-template)
+- [Excel Editor](#docs-editing)
- [Attributes and configuration](#docs-attributes)
- [CSV specifics](#docs-csv)
- [Other functionalities](#docs-other)
@@ -1177,6 +1179,21 @@ Result:
+### Editing existing workbooks
+
+> Warning: this feature is a work in progress and currently very limited!
+
+Cell style updates are queued and applied in worksheet and cell order when `Save` is called. If the same cell is updated more than once, the last update wins.
+
+```csharp
+var editor = MiniExcelV2.Editors.GetOpenXmlEditor();
+editor.StartEditingPipeline(path)
+ .UpdateCellStyle("A1", style => style.FontColor = Color.Red)
+ .UpdateCellStyle("X100", style => style.FontColor = Color.Blue)
+ .SaveChanges();
+```
+
+
### Attributes and configuration
#### 1. Specify the column name, column index, or ignore the column entirely.
@@ -1611,12 +1628,12 @@ exporter.Export(path, value, configuration: config);
#### Read empty string as null
By default, empty values are mapped to `string.Empty`.
-You can modify this behavior and map them to `null` using the `CsvConfiguration.ReadEmptyStringAsNull` property:
+You can modify this behavior and map them to `null` using the `CsvConfiguration.ReadEmptyFieldsAsDefault` property:
```csharp
var config = new CsvConfiguration
{
- ReadEmptyStringAsNull = true
+ ReadEmptyFieldsAsDefault = true
};
```
diff --git a/src/MiniExcel.Core/MiniExcel.cs b/src/MiniExcel.Core/MiniExcel.cs
new file mode 100644
index 00000000..e69de29b
diff --git a/src/MiniExcel.Core/MiniExcelProviders.cs b/src/MiniExcel.Core/MiniExcelProviders.cs
index 5d0f775a..dee3bc98 100644
--- a/src/MiniExcel.Core/MiniExcelProviders.cs
+++ b/src/MiniExcel.Core/MiniExcelProviders.cs
@@ -14,3 +14,8 @@ public sealed class MiniExcelTemplaterProvider
{
internal MiniExcelTemplaterProvider() { }
}
+
+public sealed class MiniExcelEditorProvider
+{
+ internal MiniExcelEditorProvider() { }
+}
diff --git a/src/MiniExcel.Core/MiniExcelV2.cs b/src/MiniExcel.Core/MiniExcelV2.cs
index b5b9b7c5..7fe2073c 100644
--- a/src/MiniExcel.Core/MiniExcelV2.cs
+++ b/src/MiniExcel.Core/MiniExcelV2.cs
@@ -8,6 +8,7 @@ public static class MiniExcelV2
public static readonly MiniExcelExporterProvider Exporters = new();
public static readonly MiniExcelImporterProvider Importers = new();
public static readonly MiniExcelTemplaterProvider Templaters = new();
+ public static readonly MiniExcelEditorProvider Editors = new();
}
[Obsolete("This class will be removed in the full release, use MiniExcelV2 instead.", true)]
@@ -16,4 +17,5 @@ public static class MiniExcel
public static readonly MiniExcelExporterProvider Exporters = new();
public static readonly MiniExcelImporterProvider Importers = new();
public static readonly MiniExcelTemplaterProvider Templaters = new();
+ public static readonly MiniExcelEditorProvider Editors = new();
}
diff --git a/src/MiniExcel.OpenXml/Api/OpenXmlEditor.cs b/src/MiniExcel.OpenXml/Api/OpenXmlEditor.cs
new file mode 100644
index 00000000..2e4d038c
--- /dev/null
+++ b/src/MiniExcel.OpenXml/Api/OpenXmlEditor.cs
@@ -0,0 +1,52 @@
+using MiniExcelLib.OpenXml.Editor;
+
+// ReSharper disable once CheckNamespace
+namespace MiniExcelLib.OpenXml;
+
+public sealed class OpenXmlEditor
+{
+ internal OpenXmlEditor() { }
+
+ ///
+ /// Creates a new editing pipeline for the provided Excel document.
+ ///
+ /// The file path to the Excel document to edit.
+ ///
+ /// An instance that can be used to apply modifications to the document.
+ ///
+ ///
+ /// This method opens the file for exclusive read-write access. The file is locked until
+ /// SaveChanges or SaveChangesAsync is called.
+ ///
+ public OpenXmlEditingPipeline StartEditingPipeline(string path)
+ {
+ if (string.IsNullOrWhiteSpace(path))
+ throw new ArgumentException("The path cannot be null or whitespace.", nameof(path));
+
+ var stream = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read);
+ return new OpenXmlEditingPipeline(stream, leaveOpen: false);
+ }
+
+ ///
+ /// Creates a new editing pipeline for the provided Excel document.
+ ///
+ /// The stream containing the Excel file data.
+ ///
+ /// If true the stream remains open after changes are saved and must be disposed by the caller,
+ /// if false it is automatically closed when changes are saved. Default is false.
+ ///
+ ///
+ /// An instance that can be used to apply modifications to the file.
+ ///
+ ///
+ /// Even with parameter leaveOpen: false, the underlying stream will not be disposed until
+ /// SaveChanges or SaveChangesAsync are called.
+ ///
+ public OpenXmlEditingPipeline StartEditingPipeline(Stream stream, bool leaveOpen = false)
+ {
+ if (stream is null)
+ throw new ArgumentNullException(nameof(stream));
+
+ return new OpenXmlEditingPipeline(stream, leaveOpen);
+ }
+}
diff --git a/src/MiniExcel.OpenXml/Api/ProviderExtensions.cs b/src/MiniExcel.OpenXml/Api/ProviderExtensions.cs
index 7e62c6b7..d747bf01 100644
--- a/src/MiniExcel.OpenXml/Api/ProviderExtensions.cs
+++ b/src/MiniExcel.OpenXml/Api/ProviderExtensions.cs
@@ -6,4 +6,5 @@ public static class ProviderExtensions
public static OpenXmlExporter GetOpenXmlExporter(this MiniExcelExporterProvider exporterProvider) => new();
public static OpenXmlImporter GetOpenXmlImporter(this MiniExcelImporterProvider importerProvider) => new();
public static OpenXmlTemplater GetOpenXmlTemplater(this MiniExcelTemplaterProvider templaterProvider) => new();
-}
\ No newline at end of file
+ public static OpenXmlEditor GetOpenXmlEditor(this MiniExcelEditorProvider editorProvider) => new();
+}
diff --git a/src/MiniExcel.OpenXml/Editor/OpenXmlEditingPipeline.cs b/src/MiniExcel.OpenXml/Editor/OpenXmlEditingPipeline.cs
new file mode 100644
index 00000000..34cf0564
--- /dev/null
+++ b/src/MiniExcel.OpenXml/Editor/OpenXmlEditingPipeline.cs
@@ -0,0 +1,105 @@
+using MiniExcelLib.OpenXml.Styles;
+
+namespace MiniExcelLib.OpenXml.Editor;
+
+///
+/// Represents a pipeline for editing Excel files with a fluent API.
+///
+public sealed partial class OpenXmlEditingPipeline
+{
+ private readonly OpenXmlEditorInternals _internals;
+
+ internal OpenXmlEditingPipeline(Stream stream, bool leaveOpen)
+ {
+ _internals = new OpenXmlEditorInternals(stream, leaveOpen);
+ }
+
+ ///
+ /// Updates the style of a cell using a callback function that modifies the provided object.
+ ///
+ /// The cell reference in standard Excel format (e.g., "A1", "B5").
+ /// A callback function that receives an object and applies the desired style changes.
+ /// The name of the worksheet to update. If null or not specified, the first sheet in the workbook is used.
+ ///
+ /// Returns this instance to enable method chaining.
+ ///
+ ///
+ /// Modifications are queued in the pipeline and not written to the file until SaveChanges or SaveChangesAsync is called.
+ ///
+ public OpenXmlEditingPipeline UpdateCellStyle(string cellReference, Action updateCellCallback, string? sheetName = null)
+ {
+ if (updateCellCallback is null)
+ throw new ArgumentNullException(nameof(updateCellCallback));
+
+ var style = new OpenXmlCellStyle();
+ updateCellCallback(style);
+
+ return UpdateCellStyle(cellReference, style, sheetName);
+ }
+
+ ///
+ /// Updates the style of a cell using a pre-configured object.
+ ///
+ /// The cell reference in standard Excel format (e.g., "A1", "B5").
+ /// The object containing the style properties to apply to the cell.
+ /// The name of the worksheet to update. If null or not specified, the first sheet in the workbook is used.
+ ///
+ /// Returns this instance to enable method chaining.
+ ///
+ ///
+ /// Modifications are queued in the pipeline and not written to the file until SaveChanges or SaveChangesAsync is called.
+ ///
+ public OpenXmlEditingPipeline UpdateCellStyle(string cellReference, OpenXmlCellStyle cellStyle, string? sheetName = null)
+ {
+ _internals.UpdateCellStyle(cellReference, cellStyle, sheetName);
+ return this;
+ }
+
+ ///
+ /// Applies all queued modifications to the Excel document and saves it to the original stream or file.
+ /// The pipeline cannot be reused afterwards.
+ ///
+ /// The token to monitor for cancellation requests.
+ ///
+ /// If the pipeline was created from a stream with leaveOpen: true, the caller is responsible for its disposal.
+ ///
+ [CreateSyncVersion]
+ public async Task SaveChangesAsync(CancellationToken cancellationToken = default)
+ {
+ await _internals.SaveAsync(cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Applies all queued modifications to the Excel document and saves it to the provided path.
+ /// The pipeline cannot be reused afterwards.
+ ///
+ /// The path to save the modified Excel document to.
+ /// The token to monitor for cancellation requests.
+ ///
+ /// If the pipeline was created from a stream with leaveOpen: true, the caller is responsible for its disposal.
+ ///
+ [CreateSyncVersion]
+ public async Task SaveChangesAsync(string outputPath, CancellationToken cancellationToken = default)
+ {
+ var stream = File.OpenWrite(outputPath);
+ await using var disposableStream = stream.ConfigureAwait(false);
+
+ await SaveChangesAsync(stream, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Applies all queued modifications to the Excel document and saves it to the provided stream.
+ /// The pipeline cannot be reused afterwards.
+ ///
+ /// The stream to save the modified Excel document to.
+ /// The token to monitor for cancellation requests.
+ ///
+ /// If the pipeline was created from a stream with leaveOpen: true, the caller is responsible for its disposal.
+ /// The caller is always responsible for disposing the output stream.
+ ///
+ [CreateSyncVersion]
+ public async Task SaveChangesAsync(Stream outputStream, CancellationToken cancellationToken = default)
+ {
+ await _internals.SaveAsync(outputStream, cancellationToken).ConfigureAwait(false);
+ }
+}
diff --git a/src/MiniExcel.OpenXml/Editor/OpenXmlEditorInternals.cs b/src/MiniExcel.OpenXml/Editor/OpenXmlEditorInternals.cs
new file mode 100644
index 00000000..fb1ec04f
--- /dev/null
+++ b/src/MiniExcel.OpenXml/Editor/OpenXmlEditorInternals.cs
@@ -0,0 +1,522 @@
+using System.Drawing;
+using MiniExcelLib.OpenXml.Styles;
+
+namespace MiniExcelLib.OpenXml.Editor;
+
+public sealed partial class OpenXmlEditorInternals
+{
+ private const int MaxColumn = 16_384;
+ private const int MaxRow = 1_048_576;
+
+ private readonly Stream _stream;
+ private readonly bool _leaveOpen;
+ private readonly List _styleUpdates = [];
+
+ internal OpenXmlEditorInternals(Stream stream, bool leaveOpen)
+ {
+ if (!stream.CanRead || !stream.CanWrite || !stream.CanSeek)
+ throw new ArgumentException("The stream must be readable, writable, and seekable.", nameof(stream));
+
+ _stream = stream ?? throw new ArgumentNullException(nameof(stream));
+ _leaveOpen = leaveOpen;
+ }
+
+ /// Queues a partial style update for an existing cell.
+ internal void UpdateCellStyle(string cellReference, OpenXmlCellStyle cellStyle, string? sheetName = null)
+ {
+ if (cellStyle is null)
+ throw new ArgumentNullException(nameof(cellStyle));
+
+ if (!CellReferenceConverter.TryParseCellReference(cellReference, out var column, out var row) || column > MaxColumn || row > MaxRow)
+ throw new ArgumentException($"'{cellReference}' is not a valid cell reference.", nameof(cellReference));
+
+ if (cellStyle.FontColor is not { } fontColor)
+ throw new ArgumentException("At least one style property must be specified.", nameof(cellStyle));
+
+ var normalizedReference = CellReferenceConverter.GetCellFromCoordinates(column, row);
+ _styleUpdates.Add(new CellStyleUpdate(normalizedReference, column, row, sheetName, fontColor));
+ }
+
+ /// Applies all queued updates to the workbook and saves it to an output stream.
+ [CreateSyncVersion]
+ public async Task SaveAsync(Stream outputStream, CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ if (_styleUpdates.Count == 0)
+ return;
+
+ _stream.Seek(0, SeekOrigin.Begin);
+ await ApplyUpdatesAsync(_stream, outputStream, cancellationToken).ConfigureAwait(false);
+ await outputStream.FlushAsync(cancellationToken).ConfigureAwait(false);
+
+ _styleUpdates.Clear();
+ }
+ finally
+ {
+ if (!_leaveOpen)
+ {
+ await _stream.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+ }
+
+ /// Applies all queued updates to the workbook and replaces the original stream.
+ [CreateSyncVersion]
+ public async Task SaveAsync(CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ if (_styleUpdates.Count == 0)
+ return;
+
+ _stream.Seek(0, SeekOrigin.Begin);
+
+ var tempStream = new MemoryStream();
+ await using var disposableMemoryStream = tempStream.ConfigureAwait(false);
+
+ await ApplyUpdatesAsync(_stream, tempStream, cancellationToken).ConfigureAwait(false);
+
+ cancellationToken.ThrowIfCancellationRequested();
+ // We cannot honor the cancellation of the task after this point because
+ // the workbook would be only partially written to the stream thus corrupting the original document
+
+ _stream.Seek(0, SeekOrigin.Begin);
+ _stream.SetLength(0);
+
+ tempStream.Seek(0, SeekOrigin.Begin);
+ await tempStream.CopyToAsync(_stream, 81920, CancellationToken.None).ConfigureAwait(false);
+ await _stream.FlushAsync(CancellationToken.None).ConfigureAwait(false);
+
+ _styleUpdates.Clear();
+ }
+ finally
+ {
+ if (!_leaveOpen)
+ {
+ await _stream.DisposeAsync().ConfigureAwait(false);
+ }
+ }
+ }
+
+ [CreateSyncVersion]
+ private async Task ApplyUpdatesAsync(Stream inputStream, Stream outputStream, CancellationToken cancellationToken)
+ {
+ inputStream.Seek(0, SeekOrigin.Begin);
+
+#if NET10_0_OR_GREATER
+ var inputArchive = new ZipArchive(inputStream, ZipArchiveMode.Read, leaveOpen: true);
+ await using var disposableInputArchive = inputArchive.ConfigureAwait(false);
+#else
+ using var inputArchive = new ZipArchive(inputStream, ZipArchiveMode.Read, leaveOpen: true);
+#endif
+
+ var contentTypes = await LoadDocumentAsync(GetRequiredEntry(inputArchive, ExcelFileNames.ContentTypes), cancellationToken).ConfigureAwait(false);
+ if (contentTypes.Descendants()
+ .Attributes("ContentType")
+ .Any(attribute => attribute.Value.Contains("macroEnabled", StringComparison.OrdinalIgnoreCase)))
+ {
+ throw new NotSupportedException("MiniExcel's OpenXmlEditor does not support the .xlsm format.");
+ }
+
+ var workbook = await LoadDocumentAsync(GetRequiredEntry(inputArchive, ExcelFileNames.Workbook), cancellationToken).ConfigureAwait(false);
+ var workbookRelationships = await LoadDocumentAsync(GetRequiredEntry(inputArchive, ExcelFileNames.WorkbookRels), cancellationToken).ConfigureAwait(false);
+ var sheets = GetSheets(workbook, workbookRelationships);
+ var pendingUpdates = ResolveUpdates(sheets);
+
+ var stylesEntry = GetRequiredEntry(inputArchive, ExcelFileNames.Styles);
+ var styles = await LoadDocumentAsync(stylesEntry, cancellationToken).ConfigureAwait(false);
+ var styleContext = new StyleUpdateContext(styles);
+ var updatesBySheet = pendingUpdates
+ .GroupBy(update => update.Sheet.Path, StringComparer.OrdinalIgnoreCase)
+ .ToList();
+
+ var worksheetPaths = new HashSet(
+ updatesBySheet.Select(group => group.Key),
+ StringComparer.OrdinalIgnoreCase);
+
+#if NET10_0_OR_GREATER
+ var outputArchive = new ZipArchive(outputStream, ZipArchiveMode.Create, leaveOpen: true);
+ await using var disposableOutputArchive = outputArchive.ConfigureAwait(false);
+#else
+ using var outputArchive = new ZipArchive(outputStream, ZipArchiveMode.Create, leaveOpen: true);
+#endif
+
+ foreach (var inputEntry in inputArchive.Entries)
+ {
+ if (!inputEntry.FullName.Equals(ExcelFileNames.Styles, StringComparison.OrdinalIgnoreCase) &&
+ !worksheetPaths.Contains(inputEntry.FullName))
+ {
+ await CopyEntryAsync(inputEntry, outputArchive, cancellationToken).ConfigureAwait(false);
+ }
+ }
+
+ foreach (var sheetUpdates in updatesBySheet)
+ {
+ var inputEntry = GetRequiredEntry(inputArchive, sheetUpdates.Key);
+ var outputEntry = CreateEntry(outputArchive, inputEntry);
+ await RewriteWorksheetAsync(inputEntry, outputEntry, sheetUpdates, styleContext, cancellationToken).ConfigureAwait(false);
+ }
+
+ await WriteDocumentEntryAsync(outputArchive, stylesEntry, styles, cancellationToken).ConfigureAwait(false);
+ }
+
+ private List ResolveUpdates(IReadOnlyList sheets)
+ {
+ var updates = new Dictionary<(string SheetPath, string CellReference), ResolvedStyleUpdate>();
+
+ foreach (var update in _styleUpdates)
+ {
+ var sheet = string.IsNullOrEmpty(update.SheetName)
+ ? sheets.FirstOrDefault()
+ : sheets.FirstOrDefault(sheet => string.Equals(sheet.Name, update.SheetName, StringComparison.OrdinalIgnoreCase));
+
+ if (sheet is null)
+ {
+ var errorMsg = update.SheetName is null
+ ? "The workbook does not contain any worksheets."
+ : $"Worksheet '{update.SheetName}' does not exist.";
+
+ throw new ArgumentException(errorMsg);
+ }
+
+ updates[(sheet.Path, update.CellReference)] = new ResolvedStyleUpdate(
+ sheet, update.CellReference, update.Column, update.Row, update.FontColor);
+ }
+
+ return updates.Values
+ .OrderBy(update => update.Sheet.Index)
+ .ThenBy(update => update.Row)
+ .ThenBy(update => update.Column)
+ .ToList();
+ }
+
+ private static List GetSheets(XDocument workbook, XDocument relationships)
+ {
+ var relationshipTargets = relationships.Descendants()
+ .Where(element => element.Name.LocalName == "Relationship" &&
+ element.Attribute("Type")?.Value.EndsWith("/worksheet", StringComparison.Ordinal) == true)
+ .ToDictionary(
+ element => element.Attribute("Id")?.Value ?? string.Empty,
+ element => NormalizeWorkbookTarget(element.Attribute("Target")?.Value ?? string.Empty),
+ StringComparer.Ordinal);
+
+ return workbook.Descendants()
+ .Where(element => element.Name.LocalName == "sheet")
+ .Select((element, index) =>
+ {
+ var relationshipId = element.Attributes().FirstOrDefault(attribute => attribute.Name.LocalName == "id")?.Value
+ ?? throw new InvalidDataException("A worksheet is missing its relationship id.");
+
+ if (!relationshipTargets.TryGetValue(relationshipId, out var path))
+ throw new InvalidDataException($"Worksheet relationship '{relationshipId}' does not exist.");
+
+ return new SheetReference(index, element.Attribute("name")?.Value ?? string.Empty, path);
+ })
+ .ToList();
+ }
+
+ private static string NormalizeWorkbookTarget(string target)
+ {
+ if (string.IsNullOrWhiteSpace(target))
+ throw new InvalidDataException("A worksheet relationship has an empty target.");
+
+ var uri = new Uri(new Uri("https://miniexcel.local/xl/workbook.xml"), target.Replace('\\', '/'));
+ return Uri.UnescapeDataString(uri.AbsolutePath).TrimStart('/');
+ }
+
+ private static int ParseStyleIndex(string? value, string cellReference)
+ {
+ if (value is null)
+ return 0;
+
+ if (int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out var styleIndex) && styleIndex >= 0)
+ return styleIndex;
+
+ throw new InvalidDataException($"Cell '{cellReference}' has an invalid style index.");
+ }
+
+ private static ZipArchiveEntry GetRequiredEntry(ZipArchive archive, string path)
+ => archive.GetEntry(path) ?? throw new InvalidDataException($"The OpenXml document does not contain '{path}'.");
+
+ [CreateSyncVersion]
+ private static async Task LoadDocumentAsync(ZipArchiveEntry entry, CancellationToken cancellationToken)
+ {
+ var stream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false);
+ await using var disposableStream = stream.ConfigureAwait(false);
+ return await XDocument.LoadAsync(stream, LoadOptions.PreserveWhitespace, cancellationToken).ConfigureAwait(false);
+ }
+
+ [CreateSyncVersion]
+ private static async Task CopyEntryAsync(ZipArchiveEntry inputEntry, ZipArchive outputArchive, CancellationToken cancellationToken)
+ {
+ var outputEntry = CreateEntry(outputArchive, inputEntry);
+ if (inputEntry.FullName.EndsWith("/", StringComparison.Ordinal))
+ return;
+
+ var inputStream = await inputEntry.OpenAsync(cancellationToken).ConfigureAwait(false);
+ await using var disposableInputStream = inputStream.ConfigureAwait(false);
+
+ var outputStream = await outputEntry.OpenAsync(cancellationToken).ConfigureAwait(false);
+ await using var disposableOutputStream = outputStream.ConfigureAwait(false);
+
+ await inputStream.CopyToAsync(outputStream, 81920, cancellationToken).ConfigureAwait(false);
+ }
+
+ [CreateSyncVersion]
+ private static async Task RewriteWorksheetAsync(ZipArchiveEntry inputEntry, ZipArchiveEntry outputEntry,
+ IEnumerable updates, StyleUpdateContext styleContext, CancellationToken cancellationToken)
+ {
+ var pendingUpdates = updates.ToDictionary(update => update.CellReference, StringComparer.OrdinalIgnoreCase);
+
+ var inputStream = await inputEntry.OpenAsync(cancellationToken).ConfigureAwait(false);
+ await using var disposableInputStream = inputStream.ConfigureAwait(false);
+
+ var outputStream = await outputEntry.OpenAsync(cancellationToken).ConfigureAwait(false);
+ await using var disposableOutputStream = outputStream.ConfigureAwait(false);
+
+ var readerSettings = new XmlReaderSettings
+ {
+#if !SYNC_ONLY
+ Async = true,
+#endif
+ XmlResolver = null
+ };
+ using var reader = XmlReader.Create(inputStream, readerSettings);
+
+ var writerSettings = new XmlWriterSettings
+ {
+#if !SYNC_ONLY
+ Async = true,
+#endif
+ Encoding = new UTF8Encoding(false)
+ };
+#if NET
+ var writer = XmlWriter.Create(outputStream, writerSettings);
+ await using var disposableWriter = writer.ConfigureAwait(false);
+#else
+ using var writer = XmlWriter.Create(outputStream, writerSettings);
+#endif
+
+ while (await reader.ReadAsync().ConfigureAwait(false))
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (reader is { NodeType: XmlNodeType.Element, LocalName: "c" } &&
+ reader.GetAttribute("r") is { } cellReference &&
+ pendingUpdates.TryGetValue(cellReference, out var update))
+ {
+ var originalStyleIndex = ParseStyleIndex(reader.GetAttribute("s"), cellReference);
+ var styleIndex = styleContext.GetStyleIndex(originalStyleIndex, update.FontColor);
+ await WriteCellStartElementAsync(reader, writer, styleIndex).ConfigureAwait(false);
+ pendingUpdates.Remove(cellReference);
+
+ continue;
+ }
+
+ await WriteCurrentNodeAsync(reader, writer).ConfigureAwait(false);
+ }
+
+ if (pendingUpdates.Count > 0)
+ {
+ var missingCell = pendingUpdates.Values.OrderBy(update => update.Row).ThenBy(update => update.Column).First();
+ throw new InvalidDataException($"Cell '{missingCell.CellReference}' does not exist in worksheet '{missingCell.Sheet.Name}'.");
+ }
+
+ await writer.FlushAsync().ConfigureAwait(false);
+ }
+
+ [CreateSyncVersion]
+ private static async Task WriteCellStartElementAsync(XmlReader reader, XmlWriter writer, int styleIndex)
+ {
+ await writer.WriteStartElementAsync(reader.Prefix, reader.LocalName, reader.NamespaceURI).ConfigureAwait(false);
+ var styleWritten = false;
+
+ if (reader.MoveToFirstAttribute())
+ {
+ do
+ {
+ if (reader is { LocalName: "s", NamespaceURI.Length: 0 })
+ {
+ await writer.WriteAttributeStringAsync(null, "s", null, styleIndex.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false);
+ styleWritten = true;
+ }
+ else
+ {
+ await writer.WriteAttributeStringAsync(reader.Prefix, reader.LocalName, reader.NamespaceURI, reader.Value).ConfigureAwait(false);
+ }
+ }
+ while (reader.MoveToNextAttribute());
+
+ reader.MoveToElement();
+ }
+
+ if (!styleWritten)
+ await writer.WriteAttributeStringAsync(null, "s", null, styleIndex.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false);
+
+ if (reader.IsEmptyElement)
+ await writer.WriteEndElementAsync().ConfigureAwait(false);
+ }
+
+ [CreateSyncVersion]
+ private static async Task WriteCurrentNodeAsync(XmlReader reader, XmlWriter writer)
+ {
+ switch (reader.NodeType)
+ {
+ case XmlNodeType.Element:
+ await writer.WriteStartElementAsync(reader.Prefix, reader.LocalName, reader.NamespaceURI).ConfigureAwait(false);
+ if (reader.MoveToFirstAttribute())
+ {
+ do
+ {
+ await writer.WriteAttributeStringAsync(reader.Prefix, reader.LocalName, reader.NamespaceURI, reader.Value).ConfigureAwait(false);
+ }
+ while (reader.MoveToNextAttribute());
+
+ reader.MoveToElement();
+ }
+ if (reader.IsEmptyElement)
+ await writer.WriteEndElementAsync().ConfigureAwait(false);
+ break;
+
+ case XmlNodeType.EndElement:
+ await writer.WriteFullEndElementAsync().ConfigureAwait(false);
+ break;
+
+ case XmlNodeType.Text:
+ await writer.WriteStringAsync(reader.Value).ConfigureAwait(false);
+ break;
+
+ case XmlNodeType.CDATA:
+ await writer.WriteCDataAsync(reader.Value).ConfigureAwait(false);
+ break;
+
+ case XmlNodeType.Whitespace:
+ case XmlNodeType.SignificantWhitespace:
+ await writer.WriteWhitespaceAsync(reader.Value).ConfigureAwait(false);
+ break;
+
+ case XmlNodeType.Comment:
+ await writer.WriteCommentAsync(reader.Value).ConfigureAwait(false);
+ break;
+
+ case XmlNodeType.ProcessingInstruction:
+ await writer.WriteProcessingInstructionAsync(reader.Name, reader.Value).ConfigureAwait(false);
+ break;
+
+ case XmlNodeType.XmlDeclaration:
+ await writer.WriteStartDocumentAsync().ConfigureAwait(false);
+ break;
+
+ case XmlNodeType.DocumentType:
+ await writer.WriteDocTypeAsync(reader.Name, reader.GetAttribute("PUBLIC"), reader.GetAttribute("SYSTEM"), reader.Value).ConfigureAwait(false);
+ break;
+
+ case XmlNodeType.EntityReference:
+ await writer.WriteEntityRefAsync(reader.Name).ConfigureAwait(false);
+ break;
+ }
+ }
+
+ [CreateSyncVersion]
+ private static async Task WriteDocumentEntryAsync(ZipArchive outputArchive, ZipArchiveEntry inputEntry,
+ XDocument document, CancellationToken cancellationToken)
+ {
+ var outputEntry = CreateEntry(outputArchive, inputEntry);
+ var outputStream = await outputEntry.OpenAsync(cancellationToken).ConfigureAwait(false);
+ await using var disposableOuputStream = outputStream.ConfigureAwait(false);
+
+ await document.SaveAsync(outputStream, SaveOptions.DisableFormatting, cancellationToken).ConfigureAwait(false);
+ }
+
+ private static ZipArchiveEntry CreateEntry(ZipArchive archive, ZipArchiveEntry sourceEntry)
+ {
+ var entry = archive.CreateEntry(sourceEntry.FullName, CompressionLevel.Optimal);
+ entry.LastWriteTime = sourceEntry.LastWriteTime;
+ return entry;
+ }
+
+ private sealed class StyleUpdateContext
+ {
+ private readonly XNamespace _namespace;
+ private readonly XElement _fonts;
+ private readonly XElement _cellFormats;
+ private readonly List _originalFonts;
+ private readonly List _originalCellFormats;
+ private readonly Dictionary<(int StyleIndex, int Argb), int> _styleIndexes = [];
+
+ internal StyleUpdateContext(XDocument styles)
+ {
+ var root = styles.Root ?? throw new InvalidDataException("The styles document has no root element.");
+ _namespace = root.Name.Namespace;
+ _fonts = root.Element(_namespace + "fonts") ?? throw new InvalidDataException("The styles document has no fonts collection.");
+ _cellFormats = root.Element(_namespace + "cellXfs") ?? throw new InvalidDataException("The styles document has no cell formats collection.");
+ _originalFonts = _fonts.Elements(_namespace + "font").ToList();
+ _originalCellFormats = _cellFormats.Elements(_namespace + "xf").ToList();
+ }
+
+ internal int GetStyleIndex(int originalStyleIndex, Color fontColor)
+ {
+ var key = (originalStyleIndex, fontColor.ToArgb());
+ if (_styleIndexes.TryGetValue(key, out var styleIndex))
+ return styleIndex;
+
+ if (originalStyleIndex >= _originalCellFormats.Count)
+ throw new InvalidDataException($"Style index '{originalStyleIndex}' does not exist.");
+
+ var originalCellFormat = _originalCellFormats[originalStyleIndex];
+ var fontIdValue = originalCellFormat.Attribute("fontId")?.Value ?? "0";
+ if (!int.TryParse(fontIdValue, NumberStyles.None, CultureInfo.InvariantCulture, out var fontId) || fontId < 0 || fontId >= _originalFonts.Count)
+ throw new InvalidDataException($"Font index '{fontIdValue}' does not exist.");
+
+ var font = new XElement(_originalFonts[fontId]);
+ var color = new XElement(_namespace + "color", new XAttribute("rgb", $"{fontColor.A:X2}{fontColor.R:X2}{fontColor.G:X2}{fontColor.B:X2}"));
+ var oldColor = font.Elements().FirstOrDefault(element => element.Name.LocalName == "color");
+
+ if (oldColor is null)
+ font.Add(color);
+ else
+ oldColor.ReplaceWith(color);
+
+ _fonts.Add(font);
+ _fonts.SetAttributeValue("count", _fonts.Elements(_namespace + "font").Count());
+ var newFontId = _fonts.Elements(_namespace + "font").Count() - 1;
+
+ var cellFormat = new XElement(originalCellFormat);
+ cellFormat.SetAttributeValue("fontId", newFontId);
+ cellFormat.SetAttributeValue("applyFont", "1");
+ _cellFormats.Add(cellFormat);
+ _cellFormats.SetAttributeValue("count", _cellFormats.Elements(_namespace + "xf").Count());
+
+ styleIndex = _cellFormats.Elements(_namespace + "xf").Count() - 1;
+ _styleIndexes.Add(key, styleIndex);
+
+ return styleIndex;
+ }
+ }
+
+ private sealed class CellStyleUpdate(string cellReference, int column, int row, string? sheetName, Color fontColor)
+ {
+ internal string CellReference { get; } = cellReference;
+ internal int Column { get; } = column;
+ internal int Row { get; } = row;
+ internal string? SheetName { get; } = sheetName;
+ internal Color FontColor { get; } = fontColor;
+ }
+
+ private sealed class SheetReference(int index, string name, string path)
+ {
+ internal int Index { get; } = index;
+ internal string Name { get; } = name;
+ internal string Path { get; } = path;
+ }
+
+ private sealed class ResolvedStyleUpdate(SheetReference sheet, string cellReference, int column, int row, Color fontColor)
+ {
+ internal SheetReference Sheet { get; } = sheet;
+ internal string CellReference { get; } = cellReference;
+ internal int Column { get; } = column;
+ internal int Row { get; } = row;
+ internal Color FontColor { get; } = fontColor;
+ }
+}
diff --git a/src/MiniExcel.OpenXml/Styles/OpenXmlCellStyle.cs b/src/MiniExcel.OpenXml/Styles/OpenXmlCellStyle.cs
new file mode 100644
index 00000000..f5e0ba46
--- /dev/null
+++ b/src/MiniExcel.OpenXml/Styles/OpenXmlCellStyle.cs
@@ -0,0 +1,9 @@
+using System.Drawing;
+
+namespace MiniExcelLib.OpenXml.Styles;
+
+public sealed class OpenXmlCellStyle
+{
+ /// Gets or sets the cell font color.
+ public Color? FontColor { get; set; }
+}
\ No newline at end of file
diff --git a/tests/MiniExcel.OpenXml.Tests/Editor/OpenXmlEditorTests.cs b/tests/MiniExcel.OpenXml.Tests/Editor/OpenXmlEditorTests.cs
new file mode 100644
index 00000000..3d08651b
--- /dev/null
+++ b/tests/MiniExcel.OpenXml.Tests/Editor/OpenXmlEditorTests.cs
@@ -0,0 +1,116 @@
+using System.Drawing;
+using ClosedXML.Excel;
+using MiniExcelLib.Tests.Common.Utils;
+
+namespace MiniExcelLib.OpenXml.Tests.Editor;
+
+public class OpenXmlEditorTests
+{
+ [Fact]
+ public void SaveAppliesUpdatesInCellOrderAndPreservesExistingStyle()
+ {
+ using var path = AutoDeletingPath.Create();
+ using (var workbook = new XLWorkbook())
+ {
+ var worksheet = workbook.AddWorksheet("Data");
+ var firstCell = worksheet.Cell("A1");
+ firstCell.Value = 12.34;
+ firstCell.Style.NumberFormat.Format = "0.00";
+ firstCell.Style.Fill.BackgroundColor = XLColor.Yellow;
+ firstCell.Style.Border.LeftBorder = XLBorderStyleValues.Thin;
+ firstCell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
+ worksheet.Cell("X100").Value = "last";
+ workbook.SaveAs(path.ToString());
+ }
+
+ MiniExcelV2.Editors.GetOpenXmlEditor()
+ .StartEditingPipeline(path.ToString())
+ .UpdateCellStyle("X100", style => style.FontColor = Color.Blue, "Data")
+ .UpdateCellStyle("A1", style => style.FontColor = Color.Red, "Data")
+ .SaveChanges();
+
+ using var updatedWorkbook = new XLWorkbook(path.ToString());
+ var updatedWorksheet = updatedWorkbook.Worksheet("Data");
+ var firstCellStyle = updatedWorksheet.Cell("A1").Style;
+
+ Assert.Equal(Color.Red.ToArgb(), firstCellStyle.Font.FontColor.Color.ToArgb());
+ Assert.Equal(Color.Blue.ToArgb(), updatedWorksheet.Cell("X100").Style.Font.FontColor.Color.ToArgb());
+ Assert.Equal("0.00", firstCellStyle.NumberFormat.Format);
+ Assert.Equal(XLColor.Yellow, firstCellStyle.Fill.BackgroundColor);
+ Assert.Equal(XLBorderStyleValues.Thin, firstCellStyle.Border.LeftBorder);
+ Assert.Equal(XLAlignmentHorizontalValues.Center, firstCellStyle.Alignment.Horizontal);
+ }
+
+ [Fact]
+ public void LastUpdateWinsForTheSameCell()
+ {
+ using var path = AutoDeletingPath.Create();
+ CreateWorkbook(path.ToString());
+
+ MiniExcelV2.Editors.GetOpenXmlEditor()
+ .StartEditingPipeline(path.ToString())
+ .UpdateCellStyle("A1", style => style.FontColor = Color.Red)
+ .UpdateCellStyle("A1", style => style.FontColor = Color.Blue)
+ .SaveChanges();
+
+ using var workbook = new XLWorkbook(path.ToString());
+ Assert.Equal(Color.Blue.ToArgb(), workbook.Worksheet(1).Cell("A1").Style.Font.FontColor.Color.ToArgb());
+ }
+
+ [Fact]
+ public void SaveAsyncUpdatesASelectedWorksheetInAStream()
+ {
+ using var stream = new MemoryStream();
+ using (var workbook = new XLWorkbook())
+ {
+ workbook.AddWorksheet("First").Cell("A1").Value = "first";
+ workbook.AddWorksheet("Second").Cell("A1").Value = "second";
+ workbook.SaveAs(stream);
+ }
+
+ MiniExcelV2.Editors.GetOpenXmlEditor()
+ .StartEditingPipeline(stream, leaveOpen: true)
+ .UpdateCellStyle("A1", style => style.FontColor = Color.Blue, "Second")
+ .SaveChanges();
+
+ stream.Position = 0;
+ using var updatedWorkbook = new XLWorkbook(stream);
+ Assert.NotEqual(Color.Blue.ToArgb(), updatedWorkbook.Worksheet("First").Cell("A1").Style.Font.FontColor.Color.ToArgb());
+ Assert.Equal(Color.Blue.ToArgb(), updatedWorkbook.Worksheet("Second").Cell("A1").Style.Font.FontColor.Color.ToArgb());
+ }
+
+ [Fact]
+ public void UpdateCellStyleRejectsInvalidReferences()
+ {
+ using var stream = new MemoryStream();
+ var pipeline = MiniExcelV2.Editors.GetOpenXmlEditor().StartEditingPipeline(stream);
+
+ Assert.Throws(() => pipeline.UpdateCellStyle("1A", style => style.FontColor = Color.Red));
+ Assert.Throws(() => pipeline.UpdateCellStyle("XFE1", style => style.FontColor = Color.Red));
+ Assert.Throws(() => pipeline.UpdateCellStyle("A1048577", style => style.FontColor = Color.Red));
+ }
+
+ [Fact]
+ public void FailedSaveLeavesTheOriginalWorkbookUnchanged()
+ {
+ using var path = AutoDeletingPath.Create();
+ CreateWorkbook(path.ToString());
+
+ var editor = MiniExcelV2.Editors.GetOpenXmlEditor()
+ .StartEditingPipeline(path.ToString())
+ .UpdateCellStyle("A1", style => style.FontColor = Color.Red)
+ .UpdateCellStyle("A2", style => style.FontColor = Color.Blue);
+
+ Assert.Throws(editor.SaveChanges);
+
+ using var workbook = new XLWorkbook(path.ToString());
+ Assert.NotEqual(Color.Red.ToArgb(), workbook.Worksheet(1).Cell("A1").Style.Font.FontColor.Color.ToArgb());
+ }
+
+ private static void CreateWorkbook(string path)
+ {
+ using var workbook = new XLWorkbook();
+ workbook.AddWorksheet("Data").Cell("A1").Value = "value";
+ workbook.SaveAs(path);
+ }
+}
diff --git a/tests/MiniExcel.OpenXml.Tests/Editor/OpenXmlEditorTestsAsync.cs b/tests/MiniExcel.OpenXml.Tests/Editor/OpenXmlEditorTestsAsync.cs
new file mode 100644
index 00000000..24885650
--- /dev/null
+++ b/tests/MiniExcel.OpenXml.Tests/Editor/OpenXmlEditorTestsAsync.cs
@@ -0,0 +1,105 @@
+using System.Drawing;
+using ClosedXML.Excel;
+using MiniExcelLib.Tests.Common.Utils;
+
+namespace MiniExcelLib.OpenXml.Tests.Editor;
+
+public class OpenXmlEditorTestsAsync
+{
+ [Fact]
+ public async Task SaveAppliesUpdatesInCellOrderAndPreservesExistingStyle()
+ {
+ using var path = AutoDeletingPath.Create();
+ using (var workbook = new XLWorkbook())
+ {
+ var worksheet = workbook.AddWorksheet("Data");
+ var firstCell = worksheet.Cell("A1");
+ firstCell.Value = 12.34;
+ firstCell.Style.NumberFormat.Format = "0.00";
+ firstCell.Style.Fill.BackgroundColor = XLColor.Yellow;
+ firstCell.Style.Border.LeftBorder = XLBorderStyleValues.Thin;
+ firstCell.Style.Alignment.Horizontal = XLAlignmentHorizontalValues.Center;
+ worksheet.Cell("X100").Value = "last";
+ workbook.SaveAs(path.ToString());
+ }
+
+ await MiniExcelV2.Editors.GetOpenXmlEditor()
+ .StartEditingPipeline(path.ToString())
+ .UpdateCellStyle("X100", style => style.FontColor = Color.Blue, "Data")
+ .UpdateCellStyle("A1", style => style.FontColor = Color.Red, "Data")
+ .SaveChangesAsync();
+
+ using var updatedWorkbook = new XLWorkbook(path.ToString());
+ var updatedWorksheet = updatedWorkbook.Worksheet("Data");
+ var firstCellStyle = updatedWorksheet.Cell("A1").Style;
+
+ Assert.Equal(Color.Red.ToArgb(), firstCellStyle.Font.FontColor.Color.ToArgb());
+ Assert.Equal(Color.Blue.ToArgb(), updatedWorksheet.Cell("X100").Style.Font.FontColor.Color.ToArgb());
+ Assert.Equal("0.00", firstCellStyle.NumberFormat.Format);
+ Assert.Equal(XLColor.Yellow, firstCellStyle.Fill.BackgroundColor);
+ Assert.Equal(XLBorderStyleValues.Thin, firstCellStyle.Border.LeftBorder);
+ Assert.Equal(XLAlignmentHorizontalValues.Center, firstCellStyle.Alignment.Horizontal);
+ }
+
+ [Fact]
+ public async Task LastUpdateWinsForTheSameCell()
+ {
+ using var path = AutoDeletingPath.Create();
+ CreateWorkbook(path.ToString());
+
+ await MiniExcelV2.Editors.GetOpenXmlEditor()
+ .StartEditingPipeline(path.ToString())
+ .UpdateCellStyle("A1", style => style.FontColor = Color.Red)
+ .UpdateCellStyle("A1", style => style.FontColor = Color.Blue)
+ .SaveChangesAsync();
+
+ using var workbook = new XLWorkbook(path.ToString());
+ Assert.Equal(Color.Blue.ToArgb(), workbook.Worksheet(1).Cell("A1").Style.Font.FontColor.Color.ToArgb());
+ }
+
+ [Fact]
+ public async Task SaveAsyncUpdatesASelectedWorksheetInAStream()
+ {
+ using var stream = new MemoryStream();
+ using (var workbook = new XLWorkbook())
+ {
+ workbook.AddWorksheet("First").Cell("A1").Value = "first";
+ workbook.AddWorksheet("Second").Cell("A1").Value = "second";
+ workbook.SaveAs(stream);
+ }
+
+ await MiniExcelV2.Editors.GetOpenXmlEditor()
+ .StartEditingPipeline(stream, leaveOpen: true)
+ .UpdateCellStyle("A1", style => style.FontColor = Color.Blue, "Second")
+ .SaveChangesAsync();
+
+ stream.Position = 0;
+ using var updatedWorkbook = new XLWorkbook(stream);
+ Assert.NotEqual(Color.Blue.ToArgb(), updatedWorkbook.Worksheet("First").Cell("A1").Style.Font.FontColor.Color.ToArgb());
+ Assert.Equal(Color.Blue.ToArgb(), updatedWorkbook.Worksheet("Second").Cell("A1").Style.Font.FontColor.Color.ToArgb());
+ }
+
+ [Fact]
+ public async Task FailedSaveLeavesTheOriginalWorkbookUnchanged()
+ {
+ using var path = AutoDeletingPath.Create();
+ CreateWorkbook(path.ToString());
+
+ var editor = MiniExcelV2.Editors.GetOpenXmlEditor()
+ .StartEditingPipeline(path.ToString())
+ .UpdateCellStyle("A1", style => style.FontColor = Color.Red)
+ .UpdateCellStyle("A2", style => style.FontColor = Color.Blue);
+
+ await Assert.ThrowsAsync(() => editor.SaveChangesAsync());
+
+ using var workbook = new XLWorkbook(path.ToString());
+ Assert.NotEqual(Color.Red.ToArgb(), workbook.Worksheet(1).Cell("A1").Style.Font.FontColor.Color.ToArgb());
+ }
+
+ private static void CreateWorkbook(string path)
+ {
+ using var workbook = new XLWorkbook();
+ workbook.AddWorksheet("Data").Cell("A1").Value = "value";
+ workbook.SaveAs(path);
+ }
+}