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