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