From 75032867201e66cd01b9dd88b16938efc6f2d4f0 Mon Sep 17 00:00:00 2001 From: Wei Lin Date: Tue, 15 Sep 2026 01:25:25 +0800 Subject: [PATCH 1/8] Add deferred cell style updates --- README_V2.md | 11 + src/MiniExcel.Core/MiniExcel.cs | 0 src/MiniExcel.Core/MiniExcelProviders.cs | 5 + src/MiniExcel.OpenXml/Api/OpenXmlEditor.cs | 345 ++++++++++++++++++ .../Api/ProviderExtensions.cs | 6 + .../Styles/OpenXmlCellStyle.cs | 9 + .../Styles/OpenXmlEditorTests.cs | 115 ++++++ 7 files changed, 491 insertions(+) create mode 100644 src/MiniExcel.Core/MiniExcel.cs create mode 100644 src/MiniExcel.OpenXml/Api/OpenXmlEditor.cs create mode 100644 src/MiniExcel.OpenXml/Styles/OpenXmlCellStyle.cs create mode 100644 tests/MiniExcel.OpenXml.Tests/Styles/OpenXmlEditorTests.cs diff --git a/README_V2.md b/README_V2.md index 64155f20..4b295a02 100644 --- a/README_V2.md +++ b/README_V2.md @@ -171,6 +171,17 @@ The exporters also fully support asynchronous operations: await exporter.ExportAsync(outputPath, values); ``` +#### Editing cell styles + +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 +MiniExcel.Editors.GetOpenXmlEditor(path) + .UpdateCellStyle("A1", style => style.FontColor = Color.Red) + .UpdateCellStyle("X100", style => style.FontColor = Color.Blue) + .Save(); +``` + ### Release Notes If you're migrating from a `1.x` version, please check the [upgrade notes](V2-Upgrade-Notes.md). 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.OpenXml/Api/OpenXmlEditor.cs b/src/MiniExcel.OpenXml/Api/OpenXmlEditor.cs new file mode 100644 index 00000000..72a713be --- /dev/null +++ b/src/MiniExcel.OpenXml/Api/OpenXmlEditor.cs @@ -0,0 +1,345 @@ +using System.Drawing; +using MiniExcelLib.OpenXml.Styles; + +// ReSharper disable once CheckNamespace +namespace MiniExcelLib.OpenXml; + +public sealed class OpenXmlEditor +{ + private const int MaxColumn = 16_384; + private const int MaxRow = 1_048_576; + private readonly string? _path; + private readonly Stream? _stream; + private readonly List _styleUpdates = []; + + internal OpenXmlEditor(string path) + { + if (string.IsNullOrWhiteSpace(path)) + throw new ArgumentException("The path cannot be null or whitespace.", nameof(path)); + + _path = path; + } + + internal OpenXmlEditor(Stream stream) + { + _stream = stream ?? throw new ArgumentNullException(nameof(stream)); + } + + /// Queues a partial style update for an existing cell. + public OpenXmlEditor UpdateCellStyle(string cellReference, Action update, string? sheetName = null) + { + if (update is null) + throw new ArgumentNullException(nameof(update)); + + var style = new OpenXmlCellStyle(); + update(style); + return UpdateCellStyle(cellReference, style, sheetName); + } + + /// Queues a partial style update for an existing cell. + public OpenXmlEditor UpdateCellStyle(string cellReference, OpenXmlCellStyle style, string? sheetName = null) + { + if (style is null) + throw new ArgumentNullException(nameof(style)); + + 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 (style.FontColor is not { } fontColor) + throw new ArgumentException("At least one style property must be specified.", nameof(style)); + + var normalizedReference = CellReferenceConverter.GetCellFromCoordinates(column, row); + _styleUpdates.Add(new CellStyleUpdate(normalizedReference, column, row, sheetName, fontColor)); + return this; + } + + /// Applies all queued updates to the workbook. + public void Save(CancellationToken cancellationToken = default) => + SaveAsync(cancellationToken).GetAwaiter().GetResult(); + + /// Applies all queued updates to the workbook asynchronously. + public async Task SaveAsync(CancellationToken cancellationToken = default) + { + if (_styleUpdates.Count == 0) + return; + + if (_path is not null) + { + await SavePathAsync(cancellationToken).ConfigureAwait(false); + } + else + { + await SaveStreamAsync(_stream!, cancellationToken).ConfigureAwait(false); + } + + _styleUpdates.Clear(); + } + + private async Task SavePathAsync(CancellationToken cancellationToken) + { + var temporaryPath = $"{_path}.{Guid.NewGuid():N}.tmp"; + try + { + using (var source = new FileStream(_path!, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (var temporaryStream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None)) + { + await source.CopyToAsync(temporaryStream, 81920, cancellationToken).ConfigureAwait(false); + temporaryStream.Position = 0; + await ApplyUpdatesAsync(temporaryStream, cancellationToken).ConfigureAwait(false); + await temporaryStream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + ReplaceFile(temporaryPath, _path!); + } + finally + { + if (File.Exists(temporaryPath)) + File.Delete(temporaryPath); + } + } + + private async Task SaveStreamAsync(Stream stream, CancellationToken cancellationToken) + { + if (!stream.CanRead || !stream.CanWrite || !stream.CanSeek) + throw new ArgumentException("The stream must be readable, writable, and seekable.", nameof(stream)); + + stream.Seek(0, SeekOrigin.Begin); + var temporaryPath = Path.GetTempFileName(); + try + { + using var temporaryStream = new FileStream(temporaryPath, FileMode.Create, FileAccess.ReadWrite, FileShare.None); + await stream.CopyToAsync(temporaryStream, 81920, cancellationToken).ConfigureAwait(false); + temporaryStream.Position = 0; + await ApplyUpdatesAsync(temporaryStream, cancellationToken).ConfigureAwait(false); + + temporaryStream.Position = 0; + stream.Position = 0; + stream.SetLength(0); + await temporaryStream.CopyToAsync(stream, 81920, cancellationToken).ConfigureAwait(false); + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + File.Delete(temporaryPath); + } + } + + private async Task ApplyUpdatesAsync(Stream stream, CancellationToken cancellationToken) + { + stream.Seek(0, SeekOrigin.Begin); + using var archive = new ZipArchive(stream, ZipArchiveMode.Update, leaveOpen: true); + + var contentTypes = await LoadDocumentAsync(GetRequiredEntry(archive, ExcelFileNames.ContentTypes), cancellationToken).ConfigureAwait(false); + if (contentTypes.Descendants().Attributes("ContentType") + .Any(attribute => attribute.Value.IndexOf("macroEnabled", StringComparison.OrdinalIgnoreCase) >= 0)) + throw new NotSupportedException("MiniExcel's OpenXml editor does not support the .xlsm format."); + + var workbook = await LoadDocumentAsync(GetRequiredEntry(archive, ExcelFileNames.Workbook), cancellationToken).ConfigureAwait(false); + var workbookRelationships = await LoadDocumentAsync(GetRequiredEntry(archive, ExcelFileNames.WorkbookRels), cancellationToken).ConfigureAwait(false); + var sheets = GetSheets(workbook, workbookRelationships); + var pendingUpdates = ResolveUpdates(sheets); + + var stylesEntry = GetRequiredEntry(archive, ExcelFileNames.Styles); + var styles = await LoadDocumentAsync(stylesEntry, cancellationToken).ConfigureAwait(false); + var styleContext = new StyleUpdateContext(styles); + var worksheetDocuments = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var update in pendingUpdates) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!worksheetDocuments.TryGetValue(update.Sheet.Path, out var worksheet)) + { + worksheet = await LoadDocumentAsync(GetRequiredEntry(archive, update.Sheet.Path), cancellationToken).ConfigureAwait(false); + worksheetDocuments.Add(update.Sheet.Path, worksheet); + } + + var worksheetNamespace = worksheet.Root?.Name.Namespace + ?? throw new InvalidDataException($"Worksheet '{update.Sheet.Name}' has no root element."); + var cell = worksheet.Descendants(worksheetNamespace + "c") + .FirstOrDefault(element => string.Equals(element.Attribute("r")?.Value, update.CellReference, StringComparison.OrdinalIgnoreCase)) + ?? throw new InvalidDataException($"Cell '{update.CellReference}' does not exist in worksheet '{update.Sheet.Name}'."); + + var originalStyleIndex = ParseStyleIndex(cell.Attribute("s")?.Value, update.CellReference); + cell.SetAttributeValue("s", styleContext.GetStyleIndex(originalStyleIndex, update.FontColor)); + } + + await ReplaceEntryAsync(archive, ExcelFileNames.Styles, styles, cancellationToken).ConfigureAwait(false); + foreach (var worksheet in worksheetDocuments) + await ReplaceEntryAsync(archive, worksheet.Key, worksheet.Value, cancellationToken).ConfigureAwait(false); + } + + private static void ReplaceFile(string sourcePath, string destinationPath) + { + File.Replace(sourcePath, destinationPath, null); + } + + private List ResolveUpdates(IReadOnlyList sheets) + { + var updates = new Dictionary<(string SheetPath, string CellReference), ResolvedStyleUpdate>(); + + foreach (var update in _styleUpdates) + { + var sheet = update.SheetName is null + ? sheets.FirstOrDefault() + : sheets.FirstOrDefault(candidate => string.Equals(candidate.Name, update.SheetName, StringComparison.OrdinalIgnoreCase)); + if (sheet is null) + throw new ArgumentException(update.SheetName is null + ? "The workbook does not contain any worksheets." + : $"Worksheet '{update.SheetName}' does not exist."); + + 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") + .Where(element => 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}'."); + + private static async Task LoadDocumentAsync(ZipArchiveEntry entry, CancellationToken cancellationToken) + { + using var stream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); + return await XDocument.LoadAsync(stream, LoadOptions.PreserveWhitespace, cancellationToken).ConfigureAwait(false); + } + + private static async Task ReplaceEntryAsync(ZipArchive archive, string path, XDocument document, CancellationToken cancellationToken) + { + archive.GetEntry(path)?.Delete(); + var entry = archive.CreateEntry(path, CompressionLevel.Optimal); + using var stream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); + await document.SaveAsync(stream, SaveOptions.DisableFormatting, cancellationToken).ConfigureAwait(false); + } + + 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; + } +} \ No newline at end of file diff --git a/src/MiniExcel.OpenXml/Api/ProviderExtensions.cs b/src/MiniExcel.OpenXml/Api/ProviderExtensions.cs index 7e62c6b7..2c9ad8b2 100644 --- a/src/MiniExcel.OpenXml/Api/ProviderExtensions.cs +++ b/src/MiniExcel.OpenXml/Api/ProviderExtensions.cs @@ -6,4 +6,10 @@ 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(); + + /// Creates an editor for an existing OpenXml workbook. + public static OpenXmlEditor GetOpenXmlEditor(this MiniExcelEditorProvider editorProvider, string path) => new(path); + + /// Creates an editor for an existing OpenXml workbook stream. + public static OpenXmlEditor GetOpenXmlEditor(this MiniExcelEditorProvider editorProvider, Stream stream) => new(stream); } \ No newline at end of file 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/Styles/OpenXmlEditorTests.cs b/tests/MiniExcel.OpenXml.Tests/Styles/OpenXmlEditorTests.cs new file mode 100644 index 00000000..6a6679dc --- /dev/null +++ b/tests/MiniExcel.OpenXml.Tests/Styles/OpenXmlEditorTests.cs @@ -0,0 +1,115 @@ +using System.Drawing; +using ClosedXML.Excel; +using MiniExcelLib.Tests.Common.Utils; + +namespace MiniExcelLib.OpenXml.Tests.Styles; + +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()); + } + + MiniExcel.Editors.GetOpenXmlEditor(path.ToString()) + .UpdateCellStyle("X100", style => style.FontColor = Color.Blue, "Data") + .UpdateCellStyle("A1", style => style.FontColor = Color.Red, "Data") + .Save(); + + 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()); + + MiniExcel.Editors.GetOpenXmlEditor(path.ToString()) + .UpdateCellStyle("A1", style => style.FontColor = Color.Red) + .UpdateCellStyle("A1", style => style.FontColor = Color.Blue) + .Save(); + + 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 MiniExcel.Editors.GetOpenXmlEditor(stream) + .UpdateCellStyle("A1", style => style.FontColor = Color.Blue, "Second") + .SaveAsync(); + + 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 editor = MiniExcel.Editors.GetOpenXmlEditor(stream); + + Assert.Throws(() => + editor.UpdateCellStyle("1A", style => style.FontColor = Color.Red)); + Assert.Throws(() => + editor.UpdateCellStyle("XFE1", style => style.FontColor = Color.Red)); + Assert.Throws(() => + editor.UpdateCellStyle("A1048577", style => style.FontColor = Color.Red)); + } + + [Fact] + public void FailedSaveLeavesTheOriginalWorkbookUnchanged() + { + using var path = AutoDeletingPath.Create(); + CreateWorkbook(path.ToString()); + + var editor = MiniExcel.Editors.GetOpenXmlEditor(path.ToString()) + .UpdateCellStyle("A1", style => style.FontColor = Color.Red) + .UpdateCellStyle("A2", style => style.FontColor = Color.Blue); + + Assert.Throws(() => editor.Save()); + + 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); + } +} \ No newline at end of file From da1f453c1da99d0c28dfb6a38cb6327d9054d265 Mon Sep 17 00:00:00 2001 From: Wei Lin Date: Tue, 15 Sep 2026 08:43:55 +0800 Subject: [PATCH 2/8] Stream worksheet updates in OpenXml editor --- src/MiniExcel.OpenXml/Api/OpenXmlEditor.cs | 215 +++++++++++++++++---- 1 file changed, 178 insertions(+), 37 deletions(-) diff --git a/src/MiniExcel.OpenXml/Api/OpenXmlEditor.cs b/src/MiniExcel.OpenXml/Api/OpenXmlEditor.cs index 72a713be..70a97a06 100644 --- a/src/MiniExcel.OpenXml/Api/OpenXmlEditor.cs +++ b/src/MiniExcel.OpenXml/Api/OpenXmlEditor.cs @@ -84,9 +84,7 @@ private async Task SavePathAsync(CancellationToken cancellationToken) using (var source = new FileStream(_path!, FileMode.Open, FileAccess.Read, FileShare.Read)) using (var temporaryStream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None)) { - await source.CopyToAsync(temporaryStream, 81920, cancellationToken).ConfigureAwait(false); - temporaryStream.Position = 0; - await ApplyUpdatesAsync(temporaryStream, cancellationToken).ConfigureAwait(false); + await ApplyUpdatesAsync(source, temporaryStream, cancellationToken).ConfigureAwait(false); await temporaryStream.FlushAsync(cancellationToken).ConfigureAwait(false); } @@ -109,9 +107,7 @@ private async Task SaveStreamAsync(Stream stream, CancellationToken cancellation try { using var temporaryStream = new FileStream(temporaryPath, FileMode.Create, FileAccess.ReadWrite, FileShare.None); - await stream.CopyToAsync(temporaryStream, 81920, cancellationToken).ConfigureAwait(false); - temporaryStream.Position = 0; - await ApplyUpdatesAsync(temporaryStream, cancellationToken).ConfigureAwait(false); + await ApplyUpdatesAsync(stream, temporaryStream, cancellationToken).ConfigureAwait(false); temporaryStream.Position = 0; stream.Position = 0; @@ -125,49 +121,49 @@ private async Task SaveStreamAsync(Stream stream, CancellationToken cancellation } } - private async Task ApplyUpdatesAsync(Stream stream, CancellationToken cancellationToken) + private async Task ApplyUpdatesAsync(Stream inputStream, Stream outputStream, CancellationToken cancellationToken) { - stream.Seek(0, SeekOrigin.Begin); - using var archive = new ZipArchive(stream, ZipArchiveMode.Update, leaveOpen: true); + inputStream.Seek(0, SeekOrigin.Begin); + using var inputArchive = new ZipArchive(inputStream, ZipArchiveMode.Read, leaveOpen: true); - var contentTypes = await LoadDocumentAsync(GetRequiredEntry(archive, ExcelFileNames.ContentTypes), cancellationToken).ConfigureAwait(false); + var contentTypes = await LoadDocumentAsync(GetRequiredEntry(inputArchive, ExcelFileNames.ContentTypes), cancellationToken).ConfigureAwait(false); if (contentTypes.Descendants().Attributes("ContentType") .Any(attribute => attribute.Value.IndexOf("macroEnabled", StringComparison.OrdinalIgnoreCase) >= 0)) throw new NotSupportedException("MiniExcel's OpenXml editor does not support the .xlsm format."); - var workbook = await LoadDocumentAsync(GetRequiredEntry(archive, ExcelFileNames.Workbook), cancellationToken).ConfigureAwait(false); - var workbookRelationships = await LoadDocumentAsync(GetRequiredEntry(archive, ExcelFileNames.WorkbookRels), cancellationToken).ConfigureAwait(false); + 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(archive, ExcelFileNames.Styles); + var stylesEntry = GetRequiredEntry(inputArchive, ExcelFileNames.Styles); var styles = await LoadDocumentAsync(stylesEntry, cancellationToken).ConfigureAwait(false); var styleContext = new StyleUpdateContext(styles); - var worksheetDocuments = new Dictionary(StringComparer.OrdinalIgnoreCase); + var updatesBySheet = pendingUpdates + .GroupBy(update => update.Sheet.Path, StringComparer.OrdinalIgnoreCase) + .ToList(); + var worksheetPaths = new HashSet( + updatesBySheet.Select(group => group.Key), + StringComparer.OrdinalIgnoreCase); - foreach (var update in pendingUpdates) + using var outputArchive = new ZipArchive(outputStream, ZipArchiveMode.Create, leaveOpen: true); + foreach (var inputEntry in inputArchive.Entries) { - cancellationToken.ThrowIfCancellationRequested(); - - if (!worksheetDocuments.TryGetValue(update.Sheet.Path, out var worksheet)) - { - worksheet = await LoadDocumentAsync(GetRequiredEntry(archive, update.Sheet.Path), cancellationToken).ConfigureAwait(false); - worksheetDocuments.Add(update.Sheet.Path, worksheet); - } + if (inputEntry.FullName.Equals(ExcelFileNames.Styles, StringComparison.OrdinalIgnoreCase) + || worksheetPaths.Contains(inputEntry.FullName)) + continue; - var worksheetNamespace = worksheet.Root?.Name.Namespace - ?? throw new InvalidDataException($"Worksheet '{update.Sheet.Name}' has no root element."); - var cell = worksheet.Descendants(worksheetNamespace + "c") - .FirstOrDefault(element => string.Equals(element.Attribute("r")?.Value, update.CellReference, StringComparison.OrdinalIgnoreCase)) - ?? throw new InvalidDataException($"Cell '{update.CellReference}' does not exist in worksheet '{update.Sheet.Name}'."); + await CopyEntryAsync(inputEntry, outputArchive, cancellationToken).ConfigureAwait(false); + } - var originalStyleIndex = ParseStyleIndex(cell.Attribute("s")?.Value, update.CellReference); - cell.SetAttributeValue("s", styleContext.GetStyleIndex(originalStyleIndex, update.FontColor)); + 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 ReplaceEntryAsync(archive, ExcelFileNames.Styles, styles, cancellationToken).ConfigureAwait(false); - foreach (var worksheet in worksheetDocuments) - await ReplaceEntryAsync(archive, worksheet.Key, worksheet.Value, cancellationToken).ConfigureAwait(false); + await WriteDocumentEntryAsync(outputArchive, stylesEntry, styles, cancellationToken).ConfigureAwait(false); } private static void ReplaceFile(string sourcePath, string destinationPath) @@ -253,12 +249,157 @@ private static async Task LoadDocumentAsync(ZipArchiveEntry entry, Ca return await XDocument.LoadAsync(stream, LoadOptions.PreserveWhitespace, cancellationToken).ConfigureAwait(false); } - private static async Task ReplaceEntryAsync(ZipArchive archive, string path, XDocument document, CancellationToken cancellationToken) + private static async Task CopyEntryAsync(ZipArchiveEntry inputEntry, ZipArchive outputArchive, CancellationToken cancellationToken) { - archive.GetEntry(path)?.Delete(); - var entry = archive.CreateEntry(path, CompressionLevel.Optimal); - using var stream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); - await document.SaveAsync(stream, SaveOptions.DisableFormatting, cancellationToken).ConfigureAwait(false); + var outputEntry = CreateEntry(outputArchive, inputEntry); + if (inputEntry.FullName.EndsWith("/", StringComparison.Ordinal)) + return; + + using var inputStream = await inputEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + using var outputStream = await outputEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + await inputStream.CopyToAsync(outputStream, 81920, cancellationToken).ConfigureAwait(false); + } + + private static async Task RewriteWorksheetAsync(ZipArchiveEntry inputEntry, ZipArchiveEntry outputEntry, + IEnumerable updates, StyleUpdateContext styleContext, CancellationToken cancellationToken) + { + var pendingUpdates = updates.ToDictionary(update => update.CellReference, StringComparer.OrdinalIgnoreCase); + using var inputStream = await inputEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + using var outputStream = await outputEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + using var reader = XmlReader.Create(inputStream, new XmlReaderSettings + { + Async = true, + CloseInput = false, + DtdProcessing = DtdProcessing.Prohibit + }); + using var writer = XmlWriter.Create(outputStream, new XmlWriterSettings + { + Async = true, + CloseOutput = false, + Encoding = new UTF8Encoding(false), + Indent = false + }); + + while (await reader.ReadAsync().ConfigureAwait(false)) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (reader.NodeType == XmlNodeType.Element && reader.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); + } + + private static async Task WriteCellStartElementAsync(XmlReader reader, XmlWriter writer, int styleIndex) + { + await writer.WriteStartElementAsync(reader.Prefix, reader.LocalName, reader.NamespaceURI).ConfigureAwait(false); + var wroteStyle = false; + if (reader.MoveToFirstAttribute()) + { + do + { + if (reader.LocalName == "s" && reader.NamespaceURI.Length == 0) + { + await writer.WriteAttributeStringAsync(null, "s", null, styleIndex.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); + wroteStyle = true; + } + else + { + await writer.WriteAttributeStringAsync(reader.Prefix, reader.LocalName, reader.NamespaceURI, reader.Value).ConfigureAwait(false); + } + } + while (reader.MoveToNextAttribute()); + + reader.MoveToElement(); + } + + if (!wroteStyle) + await writer.WriteAttributeStringAsync(null, "s", null, styleIndex.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); + + if (reader.IsEmptyElement) + await writer.WriteEndElementAsync().ConfigureAwait(false); + } + + 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; + } + } + + private static async Task WriteDocumentEntryAsync(ZipArchive outputArchive, ZipArchiveEntry inputEntry, + XDocument document, CancellationToken cancellationToken) + { + var outputEntry = CreateEntry(outputArchive, inputEntry); + using var outputStream = await outputEntry.OpenAsync(cancellationToken).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 From 2d2f2ba6fc03fb1603b52cc1aa3c17ccc557e1cc Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Tue, 15 Sep 2026 20:22:13 +0200 Subject: [PATCH 3/8] Renamed the MiniExcel class to MiniExcelV2 Applied for rebasing on top of #1010 --- src/MiniExcel.Core/MiniExcelV2.cs | 2 ++ .../Styles/OpenXmlEditorTests.cs | 10 +++++----- 2 files changed, 7 insertions(+), 5 deletions(-) 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/tests/MiniExcel.OpenXml.Tests/Styles/OpenXmlEditorTests.cs b/tests/MiniExcel.OpenXml.Tests/Styles/OpenXmlEditorTests.cs index 6a6679dc..90a382b7 100644 --- a/tests/MiniExcel.OpenXml.Tests/Styles/OpenXmlEditorTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Styles/OpenXmlEditorTests.cs @@ -23,7 +23,7 @@ public void SaveAppliesUpdatesInCellOrderAndPreservesExistingStyle() workbook.SaveAs(path.ToString()); } - MiniExcel.Editors.GetOpenXmlEditor(path.ToString()) + MiniExcelV2.Editors.GetOpenXmlEditor(path.ToString()) .UpdateCellStyle("X100", style => style.FontColor = Color.Blue, "Data") .UpdateCellStyle("A1", style => style.FontColor = Color.Red, "Data") .Save(); @@ -46,7 +46,7 @@ public void LastUpdateWinsForTheSameCell() using var path = AutoDeletingPath.Create(); CreateWorkbook(path.ToString()); - MiniExcel.Editors.GetOpenXmlEditor(path.ToString()) + MiniExcelV2.Editors.GetOpenXmlEditor(path.ToString()) .UpdateCellStyle("A1", style => style.FontColor = Color.Red) .UpdateCellStyle("A1", style => style.FontColor = Color.Blue) .Save(); @@ -66,7 +66,7 @@ public async Task SaveAsyncUpdatesASelectedWorksheetInAStream() workbook.SaveAs(stream); } - await MiniExcel.Editors.GetOpenXmlEditor(stream) + await MiniExcelV2.Editors.GetOpenXmlEditor(stream) .UpdateCellStyle("A1", style => style.FontColor = Color.Blue, "Second") .SaveAsync(); @@ -80,7 +80,7 @@ await MiniExcel.Editors.GetOpenXmlEditor(stream) public void UpdateCellStyleRejectsInvalidReferences() { using var stream = new MemoryStream(); - var editor = MiniExcel.Editors.GetOpenXmlEditor(stream); + var editor = MiniExcelV2.Editors.GetOpenXmlEditor(stream); Assert.Throws(() => editor.UpdateCellStyle("1A", style => style.FontColor = Color.Red)); @@ -96,7 +96,7 @@ public void FailedSaveLeavesTheOriginalWorkbookUnchanged() using var path = AutoDeletingPath.Create(); CreateWorkbook(path.ToString()); - var editor = MiniExcel.Editors.GetOpenXmlEditor(path.ToString()) + var editor = MiniExcelV2.Editors.GetOpenXmlEditor(path.ToString()) .UpdateCellStyle("A1", style => style.FontColor = Color.Red) .UpdateCellStyle("A2", style => style.FontColor = Color.Blue); From 4cc021af20a9ebfe9b63466a74d7961af3d735cf Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Wed, 16 Sep 2026 23:06:34 +0200 Subject: [PATCH 4/8] Refactored the new OpenXmlEditor utility - Separated the API from the implementation details by moving the latters from `OpenXmlEditor` to `OpenXmlEditorInternals` - Moved the builder pattern to the intermediate class `OpenXmlEditingPipeline` to facilitate handling the resources and adding new features - Added proper synchronous implementation via the `SyncMethodGenerator` - Ajusted tests to reflect the changes --- src/MiniExcel.OpenXml/Api/OpenXmlEditor.cs | 481 +---------------- .../Api/ProviderExtensions.cs | 9 +- .../Editor/OpenXmlEditingPipeline.cs | 38 ++ .../Editor/OpenXmlEditorInternals.cs | 498 ++++++++++++++++++ .../Styles/OpenXmlEditorTests.cs | 35 +- 5 files changed, 567 insertions(+), 494 deletions(-) create mode 100644 src/MiniExcel.OpenXml/Editor/OpenXmlEditingPipeline.cs create mode 100644 src/MiniExcel.OpenXml/Editor/OpenXmlEditorInternals.cs diff --git a/src/MiniExcel.OpenXml/Api/OpenXmlEditor.cs b/src/MiniExcel.OpenXml/Api/OpenXmlEditor.cs index 70a97a06..21783ad7 100644 --- a/src/MiniExcel.OpenXml/Api/OpenXmlEditor.cs +++ b/src/MiniExcel.OpenXml/Api/OpenXmlEditor.cs @@ -1,486 +1,27 @@ -using System.Drawing; -using MiniExcelLib.OpenXml.Styles; +using MiniExcelLib.OpenXml.Editor; // ReSharper disable once CheckNamespace namespace MiniExcelLib.OpenXml; public sealed class OpenXmlEditor { - private const int MaxColumn = 16_384; - private const int MaxRow = 1_048_576; - private readonly string? _path; - private readonly Stream? _stream; - private readonly List _styleUpdates = []; + internal OpenXmlEditor() { } - internal OpenXmlEditor(string path) + + public OpenXmlEditingPipeline StartEditingPipeline(string path) { if (string.IsNullOrWhiteSpace(path)) throw new ArgumentException("The path cannot be null or whitespace.", nameof(path)); - _path = path; + var stream = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read); + return new OpenXmlEditingPipeline(stream, leaveOpen: false); } - internal OpenXmlEditor(Stream stream) + public OpenXmlEditingPipeline StartEditingPipeline(Stream stream, bool leaveOpen = false) { - _stream = stream ?? throw new ArgumentNullException(nameof(stream)); - } - - /// Queues a partial style update for an existing cell. - public OpenXmlEditor UpdateCellStyle(string cellReference, Action update, string? sheetName = null) - { - if (update is null) - throw new ArgumentNullException(nameof(update)); - - var style = new OpenXmlCellStyle(); - update(style); - return UpdateCellStyle(cellReference, style, sheetName); - } - - /// Queues a partial style update for an existing cell. - public OpenXmlEditor UpdateCellStyle(string cellReference, OpenXmlCellStyle style, string? sheetName = null) - { - if (style is null) - throw new ArgumentNullException(nameof(style)); - - 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 (style.FontColor is not { } fontColor) - throw new ArgumentException("At least one style property must be specified.", nameof(style)); - - var normalizedReference = CellReferenceConverter.GetCellFromCoordinates(column, row); - _styleUpdates.Add(new CellStyleUpdate(normalizedReference, column, row, sheetName, fontColor)); - return this; - } - - /// Applies all queued updates to the workbook. - public void Save(CancellationToken cancellationToken = default) => - SaveAsync(cancellationToken).GetAwaiter().GetResult(); - - /// Applies all queued updates to the workbook asynchronously. - public async Task SaveAsync(CancellationToken cancellationToken = default) - { - if (_styleUpdates.Count == 0) - return; - - if (_path is not null) - { - await SavePathAsync(cancellationToken).ConfigureAwait(false); - } - else - { - await SaveStreamAsync(_stream!, cancellationToken).ConfigureAwait(false); - } - - _styleUpdates.Clear(); - } - - private async Task SavePathAsync(CancellationToken cancellationToken) - { - var temporaryPath = $"{_path}.{Guid.NewGuid():N}.tmp"; - try - { - using (var source = new FileStream(_path!, FileMode.Open, FileAccess.Read, FileShare.Read)) - using (var temporaryStream = new FileStream(temporaryPath, FileMode.CreateNew, FileAccess.ReadWrite, FileShare.None)) - { - await ApplyUpdatesAsync(source, temporaryStream, cancellationToken).ConfigureAwait(false); - await temporaryStream.FlushAsync(cancellationToken).ConfigureAwait(false); - } - - ReplaceFile(temporaryPath, _path!); - } - finally - { - if (File.Exists(temporaryPath)) - File.Delete(temporaryPath); - } - } - - private async Task SaveStreamAsync(Stream stream, CancellationToken cancellationToken) - { - if (!stream.CanRead || !stream.CanWrite || !stream.CanSeek) - throw new ArgumentException("The stream must be readable, writable, and seekable.", nameof(stream)); - - stream.Seek(0, SeekOrigin.Begin); - var temporaryPath = Path.GetTempFileName(); - try - { - using var temporaryStream = new FileStream(temporaryPath, FileMode.Create, FileAccess.ReadWrite, FileShare.None); - await ApplyUpdatesAsync(stream, temporaryStream, cancellationToken).ConfigureAwait(false); - - temporaryStream.Position = 0; - stream.Position = 0; - stream.SetLength(0); - await temporaryStream.CopyToAsync(stream, 81920, cancellationToken).ConfigureAwait(false); - await stream.FlushAsync(cancellationToken).ConfigureAwait(false); - } - finally - { - File.Delete(temporaryPath); - } - } - - private async Task ApplyUpdatesAsync(Stream inputStream, Stream outputStream, CancellationToken cancellationToken) - { - inputStream.Seek(0, SeekOrigin.Begin); - using var inputArchive = new ZipArchive(inputStream, ZipArchiveMode.Read, leaveOpen: true); - - var contentTypes = await LoadDocumentAsync(GetRequiredEntry(inputArchive, ExcelFileNames.ContentTypes), cancellationToken).ConfigureAwait(false); - if (contentTypes.Descendants().Attributes("ContentType") - .Any(attribute => attribute.Value.IndexOf("macroEnabled", StringComparison.OrdinalIgnoreCase) >= 0)) - throw new NotSupportedException("MiniExcel's OpenXml editor 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); - - using var outputArchive = new ZipArchive(outputStream, ZipArchiveMode.Create, leaveOpen: true); - foreach (var inputEntry in inputArchive.Entries) - { - if (inputEntry.FullName.Equals(ExcelFileNames.Styles, StringComparison.OrdinalIgnoreCase) - || worksheetPaths.Contains(inputEntry.FullName)) - continue; - - 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 static void ReplaceFile(string sourcePath, string destinationPath) - { - File.Replace(sourcePath, destinationPath, null); - } - - private List ResolveUpdates(IReadOnlyList sheets) - { - var updates = new Dictionary<(string SheetPath, string CellReference), ResolvedStyleUpdate>(); - - foreach (var update in _styleUpdates) - { - var sheet = update.SheetName is null - ? sheets.FirstOrDefault() - : sheets.FirstOrDefault(candidate => string.Equals(candidate.Name, update.SheetName, StringComparison.OrdinalIgnoreCase)); - if (sheet is null) - throw new ArgumentException(update.SheetName is null - ? "The workbook does not contain any worksheets." - : $"Worksheet '{update.SheetName}' does not exist."); - - 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") - .Where(element => 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('/'); - } + if (stream is null) + throw new ArgumentNullException(nameof(stream)); - 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}'."); - - private static async Task LoadDocumentAsync(ZipArchiveEntry entry, CancellationToken cancellationToken) - { - using var stream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); - return await XDocument.LoadAsync(stream, LoadOptions.PreserveWhitespace, cancellationToken).ConfigureAwait(false); - } - - private static async Task CopyEntryAsync(ZipArchiveEntry inputEntry, ZipArchive outputArchive, CancellationToken cancellationToken) - { - var outputEntry = CreateEntry(outputArchive, inputEntry); - if (inputEntry.FullName.EndsWith("/", StringComparison.Ordinal)) - return; - - using var inputStream = await inputEntry.OpenAsync(cancellationToken).ConfigureAwait(false); - using var outputStream = await outputEntry.OpenAsync(cancellationToken).ConfigureAwait(false); - await inputStream.CopyToAsync(outputStream, 81920, cancellationToken).ConfigureAwait(false); - } - - private static async Task RewriteWorksheetAsync(ZipArchiveEntry inputEntry, ZipArchiveEntry outputEntry, - IEnumerable updates, StyleUpdateContext styleContext, CancellationToken cancellationToken) - { - var pendingUpdates = updates.ToDictionary(update => update.CellReference, StringComparer.OrdinalIgnoreCase); - using var inputStream = await inputEntry.OpenAsync(cancellationToken).ConfigureAwait(false); - using var outputStream = await outputEntry.OpenAsync(cancellationToken).ConfigureAwait(false); - using var reader = XmlReader.Create(inputStream, new XmlReaderSettings - { - Async = true, - CloseInput = false, - DtdProcessing = DtdProcessing.Prohibit - }); - using var writer = XmlWriter.Create(outputStream, new XmlWriterSettings - { - Async = true, - CloseOutput = false, - Encoding = new UTF8Encoding(false), - Indent = false - }); - - while (await reader.ReadAsync().ConfigureAwait(false)) - { - cancellationToken.ThrowIfCancellationRequested(); - - if (reader.NodeType == XmlNodeType.Element && reader.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); - } - - private static async Task WriteCellStartElementAsync(XmlReader reader, XmlWriter writer, int styleIndex) - { - await writer.WriteStartElementAsync(reader.Prefix, reader.LocalName, reader.NamespaceURI).ConfigureAwait(false); - var wroteStyle = false; - if (reader.MoveToFirstAttribute()) - { - do - { - if (reader.LocalName == "s" && reader.NamespaceURI.Length == 0) - { - await writer.WriteAttributeStringAsync(null, "s", null, styleIndex.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); - wroteStyle = true; - } - else - { - await writer.WriteAttributeStringAsync(reader.Prefix, reader.LocalName, reader.NamespaceURI, reader.Value).ConfigureAwait(false); - } - } - while (reader.MoveToNextAttribute()); - - reader.MoveToElement(); - } - - if (!wroteStyle) - await writer.WriteAttributeStringAsync(null, "s", null, styleIndex.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); - - if (reader.IsEmptyElement) - await writer.WriteEndElementAsync().ConfigureAwait(false); - } - - 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; - } - } - - private static async Task WriteDocumentEntryAsync(ZipArchive outputArchive, ZipArchiveEntry inputEntry, - XDocument document, CancellationToken cancellationToken) - { - var outputEntry = CreateEntry(outputArchive, inputEntry); - using var outputStream = await outputEntry.OpenAsync(cancellationToken).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; + return new OpenXmlEditingPipeline(stream, leaveOpen); } -} \ No newline at end of file +} diff --git a/src/MiniExcel.OpenXml/Api/ProviderExtensions.cs b/src/MiniExcel.OpenXml/Api/ProviderExtensions.cs index 2c9ad8b2..d747bf01 100644 --- a/src/MiniExcel.OpenXml/Api/ProviderExtensions.cs +++ b/src/MiniExcel.OpenXml/Api/ProviderExtensions.cs @@ -6,10 +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(); - - /// Creates an editor for an existing OpenXml workbook. - public static OpenXmlEditor GetOpenXmlEditor(this MiniExcelEditorProvider editorProvider, string path) => new(path); - - /// Creates an editor for an existing OpenXml workbook stream. - public static OpenXmlEditor GetOpenXmlEditor(this MiniExcelEditorProvider editorProvider, Stream stream) => new(stream); -} \ 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..fbada13b --- /dev/null +++ b/src/MiniExcel.OpenXml/Editor/OpenXmlEditingPipeline.cs @@ -0,0 +1,38 @@ +using MiniExcelLib.OpenXml.Styles; + +namespace MiniExcelLib.OpenXml.Editor; + +public sealed partial class OpenXmlEditingPipeline +{ + private readonly OpenXmlEditorInternals _internals; + + internal OpenXmlEditingPipeline(Stream stream, bool leaveOpen) + { + _internals = new OpenXmlEditorInternals(stream, leaveOpen); + } + + /// Queues a partial style update for an existing cell. + public OpenXmlEditingPipeline UpdateCellStyle(string cellReference, Action updateCellFunc, string? sheetName = null) + { + if (updateCellFunc is null) + throw new ArgumentNullException(nameof(updateCellFunc)); + + var style = new OpenXmlCellStyle(); + updateCellFunc(style); + + return UpdateCellStyle(cellReference, style, sheetName); + } + + /// Queues a partial style update for an existing cell. + public OpenXmlEditingPipeline UpdateCellStyle(string cellReference, OpenXmlCellStyle cellStyle, string? sheetName = null) + { + _internals.UpdateCellStyle(cellReference, cellStyle, sheetName); + return this; + } + + [CreateSyncVersion] + public async Task SaveChangesAsync(CancellationToken cancellationToken = default) + { + await _internals.SaveAsync(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..623159e1 --- /dev/null +++ b/src/MiniExcel.OpenXml/Editor/OpenXmlEditorInternals.cs @@ -0,0 +1,498 @@ +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. + [CreateSyncVersion] + public async Task SaveAsync(CancellationToken cancellationToken = default) + { + if (_styleUpdates.Count == 0) + return; + + try + { + _stream.Seek(0, SeekOrigin.Begin); + + var tempStream = new MemoryStream(); + await using var disposableMemoryStream = tempStream.ConfigureAwait(false); + + await ApplyUpdatesAsync(_stream, tempStream, cancellationToken).ConfigureAwait(false); + + _stream.Seek(0, SeekOrigin.Begin); + _stream.SetLength(0); + + cancellationToken.ThrowIfCancellationRequested(); + // We cannot honor the cancellation of the task after this point because + // the workbook would be only partially written to the stream and get corrupted + + 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/tests/MiniExcel.OpenXml.Tests/Styles/OpenXmlEditorTests.cs b/tests/MiniExcel.OpenXml.Tests/Styles/OpenXmlEditorTests.cs index 90a382b7..c158b7b4 100644 --- a/tests/MiniExcel.OpenXml.Tests/Styles/OpenXmlEditorTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Styles/OpenXmlEditorTests.cs @@ -23,10 +23,11 @@ public void SaveAppliesUpdatesInCellOrderAndPreservesExistingStyle() workbook.SaveAs(path.ToString()); } - MiniExcelV2.Editors.GetOpenXmlEditor(path.ToString()) + MiniExcelV2.Editors.GetOpenXmlEditor() + .StartEditingPipeline(path.ToString()) .UpdateCellStyle("X100", style => style.FontColor = Color.Blue, "Data") .UpdateCellStyle("A1", style => style.FontColor = Color.Red, "Data") - .Save(); + .SaveChanges(); using var updatedWorkbook = new XLWorkbook(path.ToString()); var updatedWorksheet = updatedWorkbook.Worksheet("Data"); @@ -46,10 +47,11 @@ public void LastUpdateWinsForTheSameCell() using var path = AutoDeletingPath.Create(); CreateWorkbook(path.ToString()); - MiniExcelV2.Editors.GetOpenXmlEditor(path.ToString()) + MiniExcelV2.Editors.GetOpenXmlEditor() + .StartEditingPipeline(path.ToString()) .UpdateCellStyle("A1", style => style.FontColor = Color.Red) .UpdateCellStyle("A1", style => style.FontColor = Color.Blue) - .Save(); + .SaveChanges(); using var workbook = new XLWorkbook(path.ToString()); Assert.Equal(Color.Blue.ToArgb(), workbook.Worksheet(1).Cell("A1").Style.Font.FontColor.Color.ToArgb()); @@ -66,9 +68,10 @@ public async Task SaveAsyncUpdatesASelectedWorksheetInAStream() workbook.SaveAs(stream); } - await MiniExcelV2.Editors.GetOpenXmlEditor(stream) + await MiniExcelV2.Editors.GetOpenXmlEditor() + .StartEditingPipeline(stream, leaveOpen: true) .UpdateCellStyle("A1", style => style.FontColor = Color.Blue, "Second") - .SaveAsync(); + .SaveChangesAsync(); stream.Position = 0; using var updatedWorkbook = new XLWorkbook(stream); @@ -80,14 +83,11 @@ await MiniExcelV2.Editors.GetOpenXmlEditor(stream) public void UpdateCellStyleRejectsInvalidReferences() { using var stream = new MemoryStream(); - var editor = MiniExcelV2.Editors.GetOpenXmlEditor(stream); - - Assert.Throws(() => - editor.UpdateCellStyle("1A", style => style.FontColor = Color.Red)); - Assert.Throws(() => - editor.UpdateCellStyle("XFE1", style => style.FontColor = Color.Red)); - Assert.Throws(() => - editor.UpdateCellStyle("A1048577", style => style.FontColor = Color.Red)); + 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] @@ -96,11 +96,12 @@ public void FailedSaveLeavesTheOriginalWorkbookUnchanged() using var path = AutoDeletingPath.Create(); CreateWorkbook(path.ToString()); - var editor = MiniExcelV2.Editors.GetOpenXmlEditor(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.Save()); + 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()); @@ -112,4 +113,4 @@ private static void CreateWorkbook(string path) workbook.AddWorksheet("Data").Cell("A1").Value = "value"; workbook.SaveAs(path); } -} \ No newline at end of file +} From 42f873a4c3b6773c52d3a2e48dfcdba9168cc9cb Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Wed, 16 Sep 2026 23:14:36 +0200 Subject: [PATCH 5/8] Separated synchronous from asynchronous tests and moved them to the Editor folder --- .../{Styles => Editor}/OpenXmlEditorTests.cs | 8 +- .../Editor/OpenXmlEditorTestsAsync.cs | 105 ++++++++++++++++++ 2 files changed, 109 insertions(+), 4 deletions(-) rename tests/MiniExcel.OpenXml.Tests/{Styles => Editor}/OpenXmlEditorTests.cs (93%) create mode 100644 tests/MiniExcel.OpenXml.Tests/Editor/OpenXmlEditorTestsAsync.cs diff --git a/tests/MiniExcel.OpenXml.Tests/Styles/OpenXmlEditorTests.cs b/tests/MiniExcel.OpenXml.Tests/Editor/OpenXmlEditorTests.cs similarity index 93% rename from tests/MiniExcel.OpenXml.Tests/Styles/OpenXmlEditorTests.cs rename to tests/MiniExcel.OpenXml.Tests/Editor/OpenXmlEditorTests.cs index c158b7b4..3d08651b 100644 --- a/tests/MiniExcel.OpenXml.Tests/Styles/OpenXmlEditorTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/Editor/OpenXmlEditorTests.cs @@ -2,7 +2,7 @@ using ClosedXML.Excel; using MiniExcelLib.Tests.Common.Utils; -namespace MiniExcelLib.OpenXml.Tests.Styles; +namespace MiniExcelLib.OpenXml.Tests.Editor; public class OpenXmlEditorTests { @@ -58,7 +58,7 @@ public void LastUpdateWinsForTheSameCell() } [Fact] - public async Task SaveAsyncUpdatesASelectedWorksheetInAStream() + public void SaveAsyncUpdatesASelectedWorksheetInAStream() { using var stream = new MemoryStream(); using (var workbook = new XLWorkbook()) @@ -68,10 +68,10 @@ public async Task SaveAsyncUpdatesASelectedWorksheetInAStream() workbook.SaveAs(stream); } - await MiniExcelV2.Editors.GetOpenXmlEditor() + MiniExcelV2.Editors.GetOpenXmlEditor() .StartEditingPipeline(stream, leaveOpen: true) .UpdateCellStyle("A1", style => style.FontColor = Color.Blue, "Second") - .SaveChangesAsync(); + .SaveChanges(); stream.Position = 0; using var updatedWorkbook = new XLWorkbook(stream); 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); + } +} From 005bfc49c47da4643301ccf1ba3b8d8215ccfa53 Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Thu, 17 Sep 2026 00:50:28 +0200 Subject: [PATCH 6/8] Added documentation for the API nethods --- src/MiniExcel.OpenXml/Api/OpenXmlEditor.cs | 27 +++++++++++- .../Editor/OpenXmlEditingPipeline.cs | 42 ++++++++++++++++--- 2 files changed, 62 insertions(+), 7 deletions(-) diff --git a/src/MiniExcel.OpenXml/Api/OpenXmlEditor.cs b/src/MiniExcel.OpenXml/Api/OpenXmlEditor.cs index 21783ad7..a3dc6fe1 100644 --- a/src/MiniExcel.OpenXml/Api/OpenXmlEditor.cs +++ b/src/MiniExcel.OpenXml/Api/OpenXmlEditor.cs @@ -7,7 +7,17 @@ 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 + /// is called. + /// public OpenXmlEditingPipeline StartEditingPipeline(string path) { if (string.IsNullOrWhiteSpace(path)) @@ -17,6 +27,21 @@ public OpenXmlEditingPipeline StartEditingPipeline(string path) 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 + /// or are called. + /// public OpenXmlEditingPipeline StartEditingPipeline(Stream stream, bool leaveOpen = false) { if (stream is null) diff --git a/src/MiniExcel.OpenXml/Editor/OpenXmlEditingPipeline.cs b/src/MiniExcel.OpenXml/Editor/OpenXmlEditingPipeline.cs index fbada13b..e4c478ec 100644 --- a/src/MiniExcel.OpenXml/Editor/OpenXmlEditingPipeline.cs +++ b/src/MiniExcel.OpenXml/Editor/OpenXmlEditingPipeline.cs @@ -11,25 +11,55 @@ internal OpenXmlEditingPipeline(Stream stream, bool leaveOpen) _internals = new OpenXmlEditorInternals(stream, leaveOpen); } - /// Queues a partial style update for an existing cell. - public OpenXmlEditingPipeline UpdateCellStyle(string cellReference, Action updateCellFunc, string? sheetName = null) + /// + /// 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 or are called. + /// + public OpenXmlEditingPipeline UpdateCellStyle(string cellReference, Action updateCellCallback, string? sheetName = null) { - if (updateCellFunc is null) - throw new ArgumentNullException(nameof(updateCellFunc)); + if (updateCellCallback is null) + throw new ArgumentNullException(nameof(updateCellCallback)); var style = new OpenXmlCellStyle(); - updateCellFunc(style); + updateCellCallback(style); return UpdateCellStyle(cellReference, style, sheetName); } - /// Queues a partial style update for an existing cell. + /// + /// 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 or are 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 file. + /// + /// The token to monitor for cancellation requests. + /// + /// This method must be called to persist any modifications made through the pipeline. + /// 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) { From bb8120581c05cbd0f42f1ab3da54d2f5011e7c83 Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Thu, 17 Sep 2026 01:26:16 +0200 Subject: [PATCH 7/8] Minor adjustments Fixed small stream disposal and task cancellation issues in `OpenXmlEditor.SaveAsync` and created new "Excel Editor" readme section --- README_V2.md | 30 +++++++++++-------- .../Editor/OpenXmlEditorInternals.cs | 12 ++++---- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/README_V2.md b/README_V2.md index 4b295a02..785b3014 100644 --- a/README_V2.md +++ b/README_V2.md @@ -171,16 +171,6 @@ The exporters also fully support asynchronous operations: await exporter.ExportAsync(outputPath, values); ``` -#### Editing cell styles - -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 -MiniExcel.Editors.GetOpenXmlEditor(path) - .UpdateCellStyle("A1", style => style.FontColor = Color.Red) - .UpdateCellStyle("X100", style => style.FontColor = Color.Blue) - .Save(); -``` ### Release Notes @@ -212,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) @@ -1188,6 +1179,21 @@ Result: image +### 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) + .Save(); +``` + + ### Attributes and configuration #### 1. Specify the column name, column index, or ignore the column entirely. @@ -1622,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.OpenXml/Editor/OpenXmlEditorInternals.cs b/src/MiniExcel.OpenXml/Editor/OpenXmlEditorInternals.cs index 623159e1..8bab030d 100644 --- a/src/MiniExcel.OpenXml/Editor/OpenXmlEditorInternals.cs +++ b/src/MiniExcel.OpenXml/Editor/OpenXmlEditorInternals.cs @@ -41,11 +41,11 @@ internal void UpdateCellStyle(string cellReference, OpenXmlCellStyle cellStyle, [CreateSyncVersion] public async Task SaveAsync(CancellationToken cancellationToken = default) { - if (_styleUpdates.Count == 0) - return; - try { + if (_styleUpdates.Count == 0) + return; + _stream.Seek(0, SeekOrigin.Begin); var tempStream = new MemoryStream(); @@ -53,13 +53,13 @@ public async Task SaveAsync(CancellationToken cancellationToken = default) await ApplyUpdatesAsync(_stream, tempStream, cancellationToken).ConfigureAwait(false); - _stream.Seek(0, SeekOrigin.Begin); - _stream.SetLength(0); - cancellationToken.ThrowIfCancellationRequested(); // We cannot honor the cancellation of the task after this point because // the workbook would be only partially written to the stream and get corrupted + _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); From bf607460c298315823115eb8b3b19b8e161cf6da Mon Sep 17 00:00:00 2001 From: Michele Bastione Date: Thu, 17 Sep 2026 23:39:14 +0200 Subject: [PATCH 8/8] Added method overloads to save pipeline modifications to different paths or streams --- README_V2.md | 2 +- src/MiniExcel.OpenXml/Api/OpenXmlEditor.cs | 6 +-- .../Editor/OpenXmlEditingPipeline.cs | 45 +++++++++++++++++-- .../Editor/OpenXmlEditorInternals.cs | 28 +++++++++++- 4 files changed, 71 insertions(+), 10 deletions(-) diff --git a/README_V2.md b/README_V2.md index 785b3014..7e061d3a 100644 --- a/README_V2.md +++ b/README_V2.md @@ -1190,7 +1190,7 @@ var editor = MiniExcelV2.Editors.GetOpenXmlEditor(); editor.StartEditingPipeline(path) .UpdateCellStyle("A1", style => style.FontColor = Color.Red) .UpdateCellStyle("X100", style => style.FontColor = Color.Blue) - .Save(); + .SaveChanges(); ``` diff --git a/src/MiniExcel.OpenXml/Api/OpenXmlEditor.cs b/src/MiniExcel.OpenXml/Api/OpenXmlEditor.cs index a3dc6fe1..2e4d038c 100644 --- a/src/MiniExcel.OpenXml/Api/OpenXmlEditor.cs +++ b/src/MiniExcel.OpenXml/Api/OpenXmlEditor.cs @@ -15,8 +15,8 @@ internal OpenXmlEditor() { } /// 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 - /// is called. + /// 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) { @@ -40,7 +40,7 @@ public OpenXmlEditingPipeline StartEditingPipeline(string path) /// /// /// Even with parameter leaveOpen: false, the underlying stream will not be disposed until - /// or are called. + /// SaveChanges or SaveChangesAsync are called. /// public OpenXmlEditingPipeline StartEditingPipeline(Stream stream, bool leaveOpen = false) { diff --git a/src/MiniExcel.OpenXml/Editor/OpenXmlEditingPipeline.cs b/src/MiniExcel.OpenXml/Editor/OpenXmlEditingPipeline.cs index e4c478ec..34cf0564 100644 --- a/src/MiniExcel.OpenXml/Editor/OpenXmlEditingPipeline.cs +++ b/src/MiniExcel.OpenXml/Editor/OpenXmlEditingPipeline.cs @@ -2,6 +2,9 @@ namespace MiniExcelLib.OpenXml.Editor; +/// +/// Represents a pipeline for editing Excel files with a fluent API. +/// public sealed partial class OpenXmlEditingPipeline { private readonly OpenXmlEditorInternals _internals; @@ -21,7 +24,7 @@ internal OpenXmlEditingPipeline(Stream stream, bool leaveOpen) /// Returns this instance to enable method chaining. /// /// - /// Modifications are queued in the pipeline and not written to the file until or are called. + /// 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) { @@ -44,7 +47,7 @@ public OpenXmlEditingPipeline UpdateCellStyle(string cellReference, Action instance to enable method chaining. /// /// - /// Modifications are queued in the pipeline and not written to the file until or are called. + /// 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) { @@ -53,11 +56,11 @@ public OpenXmlEditingPipeline UpdateCellStyle(string cellReference, OpenXmlCellS } /// - /// Applies all queued modifications to the Excel file. + /// 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. /// - /// This method must be called to persist any modifications made through the pipeline. /// If the pipeline was created from a stream with leaveOpen: true, the caller is responsible for its disposal. /// [CreateSyncVersion] @@ -65,4 +68,38 @@ 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 index 8bab030d..fb1ec04f 100644 --- a/src/MiniExcel.OpenXml/Editor/OpenXmlEditorInternals.cs +++ b/src/MiniExcel.OpenXml/Editor/OpenXmlEditorInternals.cs @@ -37,7 +37,31 @@ internal void UpdateCellStyle(string cellReference, OpenXmlCellStyle cellStyle, _styleUpdates.Add(new CellStyleUpdate(normalizedReference, column, row, sheetName, fontColor)); } - /// Applies all queued updates to the workbook. + /// 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) { @@ -55,7 +79,7 @@ public async Task SaveAsync(CancellationToken cancellationToken = default) cancellationToken.ThrowIfCancellationRequested(); // We cannot honor the cancellation of the task after this point because - // the workbook would be only partially written to the stream and get corrupted + // the workbook would be only partially written to the stream thus corrupting the original document _stream.Seek(0, SeekOrigin.Begin); _stream.SetLength(0);