diff --git a/src/MiniExcel.Core/Helpers/Polyfills.cs b/src/MiniExcel.Core/Helpers/Polyfills.cs index 03fd1bf2..164d0792 100644 --- a/src/MiniExcel.Core/Helpers/Polyfills.cs +++ b/src/MiniExcel.Core/Helpers/Polyfills.cs @@ -32,6 +32,19 @@ public static TNumber Clamp(TNumber value, TNumber min, TNumber max) wh return value; } } + + [EditorBrowsable(EditorBrowsableState.Advanced)] + public static IEnumerable ExceptBy(this IEnumerable first, IEnumerable second, Func keySelector, IEqualityComparer? comparer) + { + var set = new HashSet(second, comparer); + foreach (var element in first) + { + if (set.Add(keySelector(element))) + { + yield return element; + } + } + } #endif #if !NET10_0_OR_GREATER diff --git a/src/MiniExcel.OpenXml/Api/OpenXmlExporter.cs b/src/MiniExcel.OpenXml/Api/OpenXmlExporter.cs index 5042d6d1..b2b11d5b 100644 --- a/src/MiniExcel.OpenXml/Api/OpenXmlExporter.cs +++ b/src/MiniExcel.OpenXml/Api/OpenXmlExporter.cs @@ -1,3 +1,5 @@ +using MiniExcelLib.OpenXml.Writer; + // ReSharper disable once CheckNamespace namespace MiniExcelLib.OpenXml; @@ -46,7 +48,7 @@ public async Task InsertSheetAsync(string path, object value, string sheetN /// /// Inserts a new worksheet into an existing OpenXml document. /// - /// The stream containing the OpenXml document to modify. + /// The stream containing the OpenXml document to copy. /// The data object to insert into the new sheet. This can be an enumerable collection of a reference type, a IEnumeable<IDictionary<string, object>>, a or a . /// The name to assign to the new worksheet. /// If true, includes the header row in the new sheet; otherwise, only data rows are written. @@ -74,6 +76,74 @@ public async Task InsertSheetAsync(Stream stream, object value, string shee return await writer.InsertAsync(overwriteSheet, progress, cancellationToken).ConfigureAwait(false); } + /// + /// Copies an existing OpenXml document and inserts a new worksheet with the provided data. + /// + /// A readable stream containing the source OpenXml document to copy. + /// A writable stream where the modified OpenXml document will be written. + /// The data object to insert into the new sheet. This can be an enumerable collection of a reference type, a or a . + /// The name to assign to the new worksheet. Defaults to "Sheet1". + /// If true, includes the header row in the new sheet; otherwise, only data rows are written. + /// If true, overwrites any existing sheet with the same name; otherwise, an exception will be raised if the sheet already exists. + /// Optional configuration settings for the insert operation. + /// Optional progress reporter to track the operation progress. The report value represents the number of cells processed for during the inserting sheet process. + /// A cancellation token to monitor for cancellation requests. + /// The number of rows written to the new sheet. + /// + /// This method requires FastMode to be disabled. + /// + [CreateSyncVersion] + public async Task CopyAndAddSheetAsync(Stream inputStream, Stream outputStream, object value, string sheetName = "Sheet1", + bool printHeader = true, bool overwriteSheet = false, OpenXmlConfiguration? configuration = null, + IProgress? progress = null, CancellationToken cancellationToken = default) + { + var writer = await OpenXmlWriter + .CreateForCopyAsync(inputStream, outputStream, value, sheetName, printHeader, configuration, cancellationToken) + .ConfigureAwait(false); + + return await writer.CopyAndInsertAsync(overwriteSheet, progress, cancellationToken).ConfigureAwait(false); + } + + /// + /// Copies an existing OpenXml document and inserts a new worksheet with the provided data. + /// + /// The path to the OpenXml document to copy. + /// The path where the OpenXml document will be written. + /// The data object to insert into the new sheet. This can be an enumerable collection of a reference type, a or a . + /// The name to assign to the new worksheet. Defaults to "Sheet1". + /// If true, includes the header row in the new sheet; otherwise, only data rows are written. Defaults to true. + /// If true, overwrites the file at the specified path, otherwise a will be raised if the file already exists. + /// If true, overwrites any existing sheet with the same name; otherwise, an exception will be raised if the sheet already exists. Defaults to false. + /// Optional configuration settings for the copy and insert operation. + /// Optional progress reporter to track the operation progress. The report value represents the number of cells processed. + /// A cancellation token to monitor for cancellation requests. + /// The number of rows written to the new sheet. + /// + /// FastMode needs to be disabled for the method to work. + /// + /// Thrown if the input and output paths reference the same file. + [CreateSyncVersion] + public async Task CopyAndAddSheetAsync(string inputFile, string outputFile, object value, string sheetName = "Sheet1", + bool printHeader = true, bool overwriteFile = false, bool overwriteSheet = false, OpenXmlConfiguration? configuration = null, + IProgress? progress = null, CancellationToken cancellationToken = default) + { + if (inputFile.Equals(outputFile, StringComparison.InvariantCultureIgnoreCase)) + throw new ArgumentException("The generated file must not have the same path as the original file."); + + #if NET8_0_OR_GREATER + var inputStream = new FileStream(inputFile, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.RandomAccess); + await using var disposableInputStream = inputStream.ConfigureAwait(false); + + var outputStream = new FileStream(outputFile, overwriteFile ? FileMode.Create : FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.SequentialScan); + await using var disposableOutputStream = outputStream.ConfigureAwait(false); + #else + using var inputStream = new FileStream(inputFile, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.RandomAccess); + using var outputStream = new FileStream(outputFile, mode: overwriteFile ? FileMode.Create : FileMode.CreateNew, FileAccess.Write, FileShare.None, 4096, FileOptions.SequentialScan); + #endif + + return await CopyAndAddSheetAsync(inputStream, outputStream, value, sheetName, printHeader, overwriteSheet, configuration, progress, cancellationToken).ConfigureAwait(false); + } + /// /// Exports data to a file as an OpenXml document. /// diff --git a/src/MiniExcel.OpenXml/Constants/ExcelFileNames.cs b/src/MiniExcel.OpenXml/Constants/ExcelFileNames.cs index 4de4779f..eb5d7947 100644 --- a/src/MiniExcel.OpenXml/Constants/ExcelFileNames.cs +++ b/src/MiniExcel.OpenXml/Constants/ExcelFileNames.cs @@ -10,8 +10,9 @@ internal static class ExcelFileNames internal const string Styles = "xl/styles.xml"; internal const string Workbook = "xl/workbook.xml"; internal const string WorkbookRels = "xl/_rels/workbook.xml.rels"; - internal const string Worksheet = "xl/worksheets/sheet"; + internal const string WorksheetBase = "xl/worksheets/sheet"; + internal static string Worksheet(int sheetId) => $"xl/worksheets/sheet{sheetId}.xml"; internal static string SheetRels(int sheetId) => $"xl/worksheets/_rels/sheet{sheetId}.xml.rels"; internal static string Drawing(int sheetIndex) => $"xl/drawings/drawing{sheetIndex + 1}.xml"; internal static string DrawingRels(int sheetIndex) => $"xl/drawings/_rels/drawing{sheetIndex + 1}.xml.rels"; diff --git a/src/MiniExcel.OpenXml/FluentMapping/MappingWriter.cs b/src/MiniExcel.OpenXml/FluentMapping/MappingWriter.cs index 9b87d963..8b1646a3 100644 --- a/src/MiniExcel.OpenXml/FluentMapping/MappingWriter.cs +++ b/src/MiniExcel.OpenXml/FluentMapping/MappingWriter.cs @@ -1,3 +1,5 @@ +using MiniExcelLib.OpenXml.Writer; + namespace MiniExcelLib.OpenXml.FluentMapping; internal static partial class MappingWriter where T : class diff --git a/src/MiniExcel.OpenXml/Models/FileDto.cs b/src/MiniExcel.OpenXml/Models/FileDto.cs index 8bb581f5..9f032071 100644 --- a/src/MiniExcel.OpenXml/Models/FileDto.cs +++ b/src/MiniExcel.OpenXml/Models/FileDto.cs @@ -6,7 +6,7 @@ internal class FileDto internal string Extension { get; set; } internal string Path => $"xl/media/{ID}.{Extension}"; internal string Path2 => $"/xl/media/{ID}.{Extension}"; - internal byte[] Byte { get; set; } + internal byte[] Contents { get; set; } internal int RowIndex { get; set; } internal int CellIndex { get; set; } internal bool IsImage { get; set; } diff --git a/src/MiniExcel.OpenXml/OpenXmlReader.cs b/src/MiniExcel.OpenXml/OpenXmlReader.cs index 683d251d..0606099d 100644 --- a/src/MiniExcel.OpenXml/OpenXmlReader.cs +++ b/src/MiniExcel.OpenXml/OpenXmlReader.cs @@ -344,7 +344,7 @@ private ZipArchiveEntry GetSheetEntry(string? sheetName) { // if sheets count > 1 need to read xl/_rels/workbook.xml.rels var sheets = Archive.EntryCollection - .Where(w => w.FullName.TrimStart('/').StartsWith(ExcelFileNames.Worksheet, StringComparison.OrdinalIgnoreCase)) + .Where(w => w.FullName.TrimStart('/').StartsWith(ExcelFileNames.WorksheetBase, StringComparison.OrdinalIgnoreCase)) .ToArray(); ZipArchiveEntry sheetEntry; @@ -747,7 +747,7 @@ internal async Task> GetDimensionsAsync(CancellationToken canc var ranges = new List(); var sheets = Archive.EntryCollection.Where(e => - e.FullName.TrimStart('/').StartsWith(ExcelFileNames.Worksheet, StringComparison.OrdinalIgnoreCase)); + e.FullName.TrimStart('/').StartsWith(ExcelFileNames.WorksheetBase, StringComparison.OrdinalIgnoreCase)); foreach (var sheet in sheets) { diff --git a/src/MiniExcel.OpenXml/Utils/SharedStringsDiskCache.cs b/src/MiniExcel.OpenXml/Utils/SharedStringsDiskCache.cs index 0aca3bf3..0d857461 100644 --- a/src/MiniExcel.OpenXml/Utils/SharedStringsDiskCache.cs +++ b/src/MiniExcel.OpenXml/Utils/SharedStringsDiskCache.cs @@ -1,6 +1,6 @@ namespace MiniExcelLib.OpenXml.Utils; -internal class SharedStringsDiskCache : IDictionary, IDisposable +internal sealed class SharedStringsDiskCache : IDictionary, IDisposable { private const int ExcelCellMaxLength = 32767; private static readonly Encoding Encoding = new UTF8Encoding(true); @@ -34,7 +34,7 @@ public SharedStringsDiskCache(string sharedStringsCacheDir) } // index must start with 0-N - private void Add(int index, string value) + public void Add(int index, string value) { if (index > _maxIndex) _maxIndex = index; @@ -50,6 +50,9 @@ private void Add(int index, string value) private string GetValue(int index) { + if (index > _maxIndex) + throw new KeyNotFoundException(); + _positionFs.Position = index * 4; var bytes = new byte[4]; _ = _positionFs.Read(bytes, 0, 4); @@ -67,42 +70,39 @@ private string GetValue(int index) return Encoding.GetString(bytes); } - public ICollection Keys => throw new NotImplementedException(); - public ICollection Values => throw new NotImplementedException(); + public ICollection Keys => throw new NotSupportedException(); + public ICollection Values => throw new NotSupportedException(); public bool IsReadOnly => throw new NotImplementedException(); public bool Remove(int key) { - throw new NotImplementedException(); + throw new NotSupportedException(); } public bool TryGetValue(int key, out string value) { - throw new NotImplementedException(); + throw new NotSupportedException(); } - public void Add(KeyValuePair item) - { - throw new NotImplementedException(); - } + public void Add(KeyValuePair item) => Add(item.Key, item.Value); public void Clear() { - throw new NotImplementedException(); + throw new NotSupportedException(); } public bool Contains(KeyValuePair item) { - throw new NotImplementedException(); + throw new NotSupportedException(); } public void CopyTo(KeyValuePair[] array, int arrayIndex) { - throw new NotImplementedException(); + throw new NotSupportedException(); } public bool Remove(KeyValuePair item) { - throw new NotImplementedException(); + throw new NotSupportedException(); } public IEnumerator> GetEnumerator() @@ -111,51 +111,25 @@ public IEnumerator> GetEnumerator() yield return new KeyValuePair(i, this[i]); } - IEnumerator IEnumerable.GetEnumerator() - { - for (int i = 0; i <= _maxIndex; i++) - yield return this[i]; - } - - void IDictionary.Add(int key, string value) - { - throw new NotImplementedException(); - } - - - ~SharedStringsDiskCache() - { - Dispose(disposing: false); - } + IEnumerator IEnumerable.GetEnumerator() => GetEnumerator(); public void Dispose() { - Dispose(disposing: true); - GC.SuppressFinalize(this); - } - - protected virtual void Dispose(bool disposing) - { - if (!_disposedValue) - { - if (disposing) - { - // TODO: dispose managed state (managed objects) - } - - _positionFs.Dispose(); - if (File.Exists(_positionFs.Name)) - File.Delete(_positionFs.Name); + if (_disposedValue) + return; + + _positionFs.Dispose(); + if (File.Exists(_positionFs.Name)) + File.Delete(_positionFs.Name); - _lengthFs.Dispose(); - if (File.Exists(_lengthFs.Name)) - File.Delete(_lengthFs.Name); + _lengthFs.Dispose(); + if (File.Exists(_lengthFs.Name)) + File.Delete(_lengthFs.Name); - _valueFs.Dispose(); - if (File.Exists(_valueFs.Name)) - File.Delete(_valueFs.Name); + _valueFs.Dispose(); + if (File.Exists(_valueFs.Name)) + File.Delete(_valueFs.Name); - _disposedValue = true; - } + _disposedValue = true; } } diff --git a/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.CopyInsert.cs b/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.CopyInsert.cs new file mode 100644 index 00000000..7839b7c8 --- /dev/null +++ b/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.CopyInsert.cs @@ -0,0 +1,318 @@ +using System.ComponentModel; +using System.Xml.Linq; +using MiniExcelLib.Core; +using MiniExcelLib.OpenXml.Constants; +using MiniExcelLib.OpenXml.Styles.Builder; + +namespace MiniExcelLib.OpenXml.Writer; + +internal partial class OpenXmlWriter +{ + private static readonly string[] EntriesToIgnoreOnCopy = [ + ExcelFileNames.ContentTypes, + ExcelFileNames.Workbook, + ExcelFileNames.WorkbookRels, + ExcelFileNames.SharedStrings, + ExcelFileNames.Styles + ]; + + private readonly Stream? _oldStream; + private readonly ZipArchive? _oldArchive; + + + private OpenXmlWriter(Stream oldStream, Stream newStream, ZipArchive oldArchive, ZipArchive newArchive, object? value, string sheetName, OpenXmlConfiguration configuration, bool printHeader) + : this(newStream, newArchive, value, sheetName, configuration, printHeader) + { + _oldStream = oldStream; + _oldArchive = oldArchive; + } + + [CreateSyncVersion] + internal static async ValueTask CreateForCopyAsync(Stream inStream, Stream outStream, object? value, string sheetName, bool printHeader, IMiniExcelConfiguration? configuration, CancellationToken cancellationToken = default) + { + ThrowHelper.ThrowIfInvalidSheetName(sheetName); + + var conf = configuration as OpenXmlConfiguration ?? OpenXmlConfiguration.Default; + if (conf is { EnableAutoWidth: true, FastMode: false }) + throw new InvalidOperationException("Auto width requires fast mode to be enabled"); + + var oldArchive = await ZipArchive.CreateAsync(inStream, ZipArchiveMode.Read, true, Utf8WithBom, cancellationToken).ConfigureAwait(false); + var newArchive = await ZipArchive.CreateAsync(outStream, ZipArchiveMode.Create, true, Utf8WithBom, cancellationToken).ConfigureAwait(false); + return new OpenXmlWriter(inStream, outStream, oldArchive, newArchive, value, sheetName, conf, printHeader); + } + + [CreateSyncVersion] + public async Task CopyAndInsertAsync(bool overwriteSheet = false, IProgress? progress = null, CancellationToken cancellationToken = default) + { +#if NET10_0_OR_GREATER + await using var disposableOldArchive = _oldArchive!.ConfigureAwait(false); + await using var disposableNewArchive = _archive.ConfigureAwait(false); +#else + using var disposableOldArchive = _oldArchive; + using var disposableNewArchive = _archive; +#endif + using var reader = await OpenXmlReader.CreateAsync(_oldStream!, _configuration, cancellationToken: cancellationToken).ConfigureAwait(false); + var rels = await reader.GetWorkbookRelsAsync(_oldArchive!.Entries, cancellationToken).ConfigureAwait(false) ?? []; + + _sheets.AddRange(rels + .OrderBy(sheet => sheet.Id) + .Select(sheet => new SheetDto + { + Name = sheet.Name, + SheetIdx = (int)sheet.Id, + State = sheet.State + }) + ); + + var existingSheetDto = _sheets.SingleOrDefault(s => s.Name == _sheetName); + if (existingSheetDto is not null && !overwriteSheet) + throw new InvalidOperationException($"Sheet \"{_sheetName}\" already exists"); + + var sheetStylesBuilderUtils = await CopySheetStylesAndGetBuilderUtilsAsync(cancellationToken).ConfigureAwait(false); + await using var disposableSheetStylesBuilderUtils = sheetStylesBuilderUtils.ConfigureAwait(false); + + await _sheetStyleBuildContext.DisposeAsync().ConfigureAwait(false); + _sheetStyleBuildContext = sheetStylesBuilderUtils.SheetStyleBuildContext; + + var sharedStringsEntry = _oldArchive.GetEntry(ExcelFileNames.SharedStrings); + if (sharedStringsEntry is not null) + { + foreach (var (key, value) in reader.SharedStrings) + { + _sharedStrings[value] = key; + } + } + + int rowsWritten; + List entriesToIgnoreOnCopy = [..EntriesToIgnoreOnCopy]; + + if (existingSheetDto is null) + { + _currentSheetIndex = (int)rels.Max(m => m.Id) + 1; + var newSheetInfoDto = GetSheetInfos(_sheetName).ToDto(_currentSheetIndex); + _sheets.Add(newSheetInfoDto); + + rowsWritten = await CreateSheetXmlAsync(_value, newSheetInfoDto.Path, progress, cancellationToken).ConfigureAwait(false); + } + else + { + _currentSheetIndex = existingSheetDto.SheetIdx; + rowsWritten = await CreateSheetXmlAsync(_value, existingSheetDto.Path, progress, cancellationToken).ConfigureAwait(false); + entriesToIgnoreOnCopy.AddRange([ + ExcelFileNames.Worksheet(_currentSheetIndex), + ExcelFileNames.SheetRels(_currentSheetIndex), + ExcelFileNames.Drawing(_currentSheetIndex - 1), + ExcelFileNames.DrawingRels(_currentSheetIndex - 1) + ]); + } + + foreach (var entry in _oldArchive.Entries.ExceptBy(entriesToIgnoreOnCopy, e => e.FullName, StringComparer.InvariantCultureIgnoreCase)) + { + await CopyEntryAsync(entry, cancellationToken).ConfigureAwait(false); + } + + await sheetStylesBuilderUtils.SheetStyleBuilder.BuildAsync(cancellationToken).ConfigureAwait(false); + if (sheetStylesBuilderUtils.Archive.GetEntry(ExcelFileNames.Styles) is { } tempStylesEntry) + { + var newStylesEntry = _archive.CreateEntry(ExcelFileNames.Styles, CompressionLevel.Fastest); + var newStylesEntryStream = await newStylesEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + var tempStylesEntryStream = await tempStylesEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + +#if NET8_0_OR_GREATER + await using var disposableNewStylesEntryStream = newStylesEntryStream.ConfigureAwait(false); + await using var disposableTempStylesEntryStream = tempStylesEntryStream.ConfigureAwait(false); +#else + using var disposableNewStylesEntryStream = newStylesEntryStream; + using var disposableTempStylesEntryStream = tempStylesEntryStream; +#endif + + await tempStylesEntryStream.CopyToAsync(newStylesEntryStream, 81920, cancellationToken).ConfigureAwait(false); + await newStylesEntryStream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + await AddFilesToZipAsync(cancellationToken).ConfigureAwait(false); + await GenerateSharedStringsAsync(cancellationToken).ConfigureAwait(false); + await GenerateDrawingRelXmlAsync(_currentSheetIndex - 1, cancellationToken).ConfigureAwait(false); + await GenerateDrawingXmlAsync(_currentSheetIndex - 1, cancellationToken).ConfigureAwait(false); + + await CreateZipEntryAsync( + ExcelFileNames.SheetRels(_currentSheetIndex), + null, + ExcelXml.DefaultSheetRelXml.Replace("{{format}}", ExcelXml.DrawingRelationship(_currentSheetIndex)), + cancellationToken).ConfigureAwait(false); + + var (workbookXml, workbookRelsXml, _) = GenerateWorkbookXmls(); + await CreateZipEntryAsync( + ExcelFileNames.Workbook, + ExcelContentTypes.Workbook, + ExcelXml.DefaultWorkbookXml.Replace("{{sheets}}", workbookXml), + cancellationToken).ConfigureAwait(false); + + await CreateZipEntryAsync( + ExcelFileNames.WorkbookRels, + null, + ExcelXml.DefaultWorkbookXmlRels.Replace("{{sheets}}", workbookRelsXml), + cancellationToken).ConfigureAwait(false); + + await CopyAndUpdateContentTypesAsync(cancellationToken).ConfigureAwait(false); + + return rowsWritten; + } + + [CreateSyncVersion] + private async Task CopySheetStylesAndGetBuilderUtilsAsync(CancellationToken cancellationToken = default) + { + var backingStream = new MemoryStream(); + var tempArchive = await ZipArchive.CreateAsync(backingStream, ZipArchiveMode.Create, true, Utf8WithBom, cancellationToken).ConfigureAwait(false); +#if NET10_0_OR_GREATER + await using (_ = tempArchive.ConfigureAwait(false)) +#else + using (_ = tempArchive) +#endif + { + var tempStylesEntry = tempArchive.CreateEntry(ExcelFileNames.Styles, CompressionLevel.Fastest); + if (_oldArchive?.GetEntry(ExcelFileNames.Styles) is { } stylesEntry) + { + var oldEntryStream = await stylesEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + var tempEntryStream = await tempStylesEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + + await oldEntryStream.CopyToAsync(tempEntryStream, 81920, cancellationToken).ConfigureAwait(false); + await tempEntryStream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + } + + backingStream.Seek(0, SeekOrigin.Begin); + var copiedArchive = await ZipArchive.CreateAsync(backingStream, ZipArchiveMode.Update, true, Utf8WithBom, cancellationToken).ConfigureAwait(false); + + SheetStyleBuildContext? oldStylesContext = null; + try + { + oldStylesContext = new SheetStyleBuildContext(_zipContentsMap, copiedArchive, Utf8WithBom); + SheetStyleBuilderBase builder = _configuration.TableStyles switch + { + TableStyles.None => new MinimalSheetStyleBuilder(oldStylesContext), + TableStyles.Default => new DefaultSheetStyleBuilder(oldStylesContext, _configuration.StyleOptions), + _ => throw new InvalidEnumArgumentException(nameof(_configuration.TableStyles), (int)_configuration.TableStyles, typeof(TableStyles)) + }; + + var newInfos = builder.GetGeneratedElementInfos(); + await oldStylesContext.CreateAsync(newInfos, cancellationToken).ConfigureAwait(false); + + var copiedContext = oldStylesContext; + oldStylesContext = null; + return new TempSheetStylesBuilderUtils(backingStream, copiedArchive, copiedContext, builder); + } + finally + { +#if SYNC_ONLY + oldStylesContext?.Dispose(); +#else + var ctxDisposeTask = oldStylesContext?.DisposeAsync(); + if (ctxDisposeTask.HasValue) await ctxDisposeTask.Value.ConfigureAwait(false); +#endif + } + } + + [CreateSyncVersion] + private async Task CopyEntryAsync(ZipArchiveEntry entry, CancellationToken cancellationToken = default) + { + var newEntry = _archive.CreateEntry(entry.FullName, CompressionLevel.Fastest); + +#if NET8_0_OR_GREATER + var oldEntryStream = await _oldArchive!.GetEntry(entry.FullName)!.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var oldDisposableSheetStream = oldEntryStream.ConfigureAwait(false); + + var newEntryStream = await newEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var newDisposableSheetStream = newEntryStream.ConfigureAwait(false); +#else + using var oldEntryStream = await _oldArchive!.GetEntry(entry.FullName)!.OpenAsync(cancellationToken).ConfigureAwait(false); + using var newEntryStream = await newEntry.OpenAsync(cancellationToken).ConfigureAwait(false); +#endif + + await oldEntryStream.CopyToAsync(newEntryStream, 81920, cancellationToken).ConfigureAwait(false); + await newEntryStream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + [CreateSyncVersion] + private async Task CopyAndUpdateContentTypesAsync(CancellationToken cancellationToken = default) + { + var contentTypesZipEntry = _oldArchive!.Entries.SingleOrDefault(s => s.FullName == ExcelFileNames.ContentTypes); + if (contentTypesZipEntry is null) + { + await GenerateContentTypesXmlAsync(cancellationToken).ConfigureAwait(false); + return; + } + +#if NET8_0_OR_GREATER + var stream = await contentTypesZipEntry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableStream = stream.ConfigureAwait(false); + var doc = await XDocument.LoadAsync(stream, LoadOptions.None, cancellationToken).ConfigureAwait(false); +#else + using var stream = contentTypesZipEntry.Open(); + var doc = XDocument.Load(stream); +#endif + + var ns = doc.Root!.GetDefaultNamespace(); + var typesElement = doc.Descendants(ns + "Types").Single(); + + var partNames = new HashSet(StringComparer.InvariantCultureIgnoreCase); + var attrNames = typesElement.Elements(ns + "Override").Select(s => s.Attribute("PartName")?.Value); + foreach (var partName in attrNames.OfType()) + { + partNames.Add(partName); + } + + foreach (var (entry, contentType) in _zipContentsMap) + { + cancellationToken.ThrowIfCancellationRequested(); + + var entryPath = $"/{entry}"; + if (!partNames.Contains(entryPath)) + { + var newElement = new XElement(ns + "Override", new XAttribute("ContentType", contentType), new XAttribute("PartName", entryPath)); + typesElement.Add(newElement); + } + } + + var contentTypesEntry = _archive.CreateEntry(ExcelFileNames.ContentTypes, CompressionLevel.Fastest); + var contentTypesEntryStream = await contentTypesEntry.OpenAsync(cancellationToken).ConfigureAwait(false); +#if NET8_0_OR_GREATER + await using var disposableContetTypesEntryStream = contentTypesEntryStream.ConfigureAwait(false); + await doc.SaveAsync(contentTypesEntryStream, SaveOptions.None, cancellationToken).ConfigureAwait(false); +#else + using var disposableContetTypesEntryStream = contentTypesEntryStream; + doc.Save(contentTypesEntryStream); +#endif + } + + private class TempSheetStylesBuilderUtils(Stream backingStream, ZipArchive archive, SheetStyleBuildContext sheetStyleBuildContext, ISheetStyleBuilder sheetStyleBuilder) : IDisposable, IAsyncDisposable + { + private readonly Stream _backingStream = backingStream; + + public ZipArchive Archive { get; } = archive; + public ISheetStyleBuilder SheetStyleBuilder { get; } = sheetStyleBuilder; + public SheetStyleBuildContext SheetStyleBuildContext { get; } = sheetStyleBuildContext; + + public void Dispose() + { + SheetStyleBuildContext.Dispose(); + Archive.Dispose(); + _backingStream.Dispose(); + } + + public async ValueTask DisposeAsync() + { + await SheetStyleBuildContext.DisposeAsync().ConfigureAwait(false); +#if NET10_0_OR_GREATER + await Archive.DisposeAsync().ConfigureAwait(false); +#else + Archive.Dispose(); +#endif +#if NET8_0_OR_GREATER + await _backingStream.DisposeAsync().ConfigureAwait(false); +#else + _backingStream.Dispose(); +#endif + } + } +} diff --git a/src/MiniExcel.OpenXml/OpenXmlWriter.DefaultOpenXml.cs b/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.DefaultOpenXml.cs similarity index 90% rename from src/MiniExcel.OpenXml/OpenXmlWriter.DefaultOpenXml.cs rename to src/MiniExcel.OpenXml/Writer/OpenXmlWriter.DefaultOpenXml.cs index bdb882b9..6db4ac8b 100644 --- a/src/MiniExcel.OpenXml/OpenXmlWriter.DefaultOpenXml.cs +++ b/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.DefaultOpenXml.cs @@ -1,9 +1,9 @@ -using MiniExcelLib.OpenXml.Constants; using System.ComponentModel; using MiniExcelLib.Core.Attributes; +using MiniExcelLib.OpenXml.Constants; using static MiniExcelLib.Core.Helpers.ImageHelper; -namespace MiniExcelLib.OpenXml; +namespace MiniExcelLib.OpenXml.Writer; internal partial class OpenXmlWriter { @@ -102,25 +102,24 @@ private string GetPanes() { var sb = new StringBuilder(); - string activePane = (_configuration.FreezeColumnCount > 0) switch + var activePane = (_configuration.FreezeColumnCount > 0) switch { true when _configuration.FreezeRowCount > 0 => "bottomRight", true => "topRight", _ => "bottomLeft" }; - - sb.Append( - WorksheetXml.StartPane( - xSplit: _configuration.FreezeColumnCount > 0 ? _configuration.FreezeColumnCount : null, - ySplit: _configuration.FreezeRowCount > 0 ? _configuration.FreezeRowCount : null, - topLeftCell: CellReferenceConverter.GetCellFromCoordinates( - _configuration.FreezeColumnCount + 1, - _configuration.FreezeRowCount + 1 - ), - activePane: activePane, - state: "frozen" - ) + + var startPane = WorksheetXml.StartPane( + xSplit: _configuration.FreezeColumnCount > 0 ? _configuration.FreezeColumnCount : null, + ySplit: _configuration.FreezeRowCount > 0 ? _configuration.FreezeRowCount : null, + topLeftCell: CellReferenceConverter.GetCellFromCoordinates( + _configuration.FreezeColumnCount + 1, + _configuration.FreezeRowCount + 1 + ), + activePane: activePane, + state: "frozen" ); + sb.Append(startPane); // write pane selections if (_configuration is { FreezeColumnCount: > 0, FreezeRowCount: > 0 }) @@ -148,7 +147,6 @@ private string GetPanes() */ var cellTr = CellReferenceConverter.GetCellFromCoordinates(_configuration.FreezeColumnCount, 1); sb.Append(WorksheetXml.PaneSelection("topRight", cellTr, cellTr)); - } else { @@ -157,7 +155,6 @@ private string GetPanes() */ sb.Append(WorksheetXml.PaneSelection("bottomLeft", null, null)); - } return sb.ToString(); @@ -305,7 +302,7 @@ private string GetFileValue(int rowIndex, int cellIndex, object value) //int rowIndex, int cellIndex var file = new FileDto { - Byte = bytes, + Contents = bytes, RowIndex = rowIndex, CellIndex = cellIndex, SheetId = _currentSheetIndex @@ -355,7 +352,7 @@ private static double CorrectDateTimeValue(DateTime value) var oaDate = value.ToOADate(); if (oaDate <= nonExistent1900Feb29SerialDate) { - oaDate -= 1; + oaDate--; } return oaDate; @@ -410,26 +407,26 @@ private string GetDrawingXml(int sheetIndex) return drawing.ToString(); } - private void GenerateWorkBookXmls( - out StringBuilder workbookXml, - out StringBuilder workbookRelsXml, - out Dictionary sheetsRelsXml) + private (string WorkbookXml, string WorkbookRelsXml, Dictionary SheetRelsXml) GenerateWorkbookXmls() { - workbookXml = new StringBuilder(); - workbookRelsXml = new StringBuilder(); - sheetsRelsXml = new Dictionary(); + var workbookXml = new StringBuilder(); + var workbookRelsXml = new StringBuilder(); + var sheetsRelsXml = new Dictionary(); + var sheetId = 0; foreach (var sheetDto in _sheets) { sheetId++; - workbookXml.AppendLine(ExcelXml.Sheet(sheetDto, sheetId)); + workbookXml.AppendLine(ExcelXml.Sheet(sheetDto, sheetId)); workbookRelsXml.AppendLine(ExcelXml.WorksheetRelationship(sheetDto)); //TODO: support multiple drawing //TODO: ../drawings/drawing1.xml or /xl/drawings/drawing1.xml sheetsRelsXml.Add(sheetDto.SheetIdx, ExcelXml.DrawingRelationship(sheetId)); } + + return (workbookXml.ToString(), workbookRelsXml.ToString(), sheetsRelsXml); } private string GetContentTypesXml() diff --git a/src/MiniExcel.OpenXml/OpenXmlWriter.cs b/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.cs similarity index 86% rename from src/MiniExcel.OpenXml/OpenXmlWriter.cs rename to src/MiniExcel.OpenXml/Writer/OpenXmlWriter.cs index 898d0eef..0363b789 100644 --- a/src/MiniExcel.OpenXml/OpenXmlWriter.cs +++ b/src/MiniExcel.OpenXml/Writer/OpenXmlWriter.cs @@ -5,7 +5,7 @@ using MiniExcelLib.OpenXml.Constants; using MiniExcelLib.OpenXml.Styles.Builder; -namespace MiniExcelLib.OpenXml; +namespace MiniExcelLib.OpenXml.Writer; internal partial class OpenXmlWriter : IMiniExcelWriter { @@ -13,16 +13,18 @@ internal partial class OpenXmlWriter : IMiniExcelWriter private readonly Stream _stream; private readonly ZipArchive _archive; + private readonly OpenXmlConfiguration _configuration; private readonly List _sheets = []; private readonly List _files = []; - private readonly SheetStyleBuildContext _sheetStyleBuildContext; private readonly string _sheetName; private readonly bool _printHeader; private readonly object? _value; private int _currentSheetIndex = 0; + private SheetStyleBuildContext _sheetStyleBuildContext; + private OpenXmlWriter(Stream stream, ZipArchive archive, object? value, string sheetName, OpenXmlConfiguration configuration, bool printHeader) { @@ -84,9 +86,9 @@ public async Task SaveAsAsync(IProgress? progress = null, Cancellati await AddFilesToZipAsync(cancellationToken).ConfigureAwait(false); await GenerateSharedStringsAsync(cancellationToken).ConfigureAwait(false); - await GenerateDrawinRelXmlAsync(cancellationToken).ConfigureAwait(false); + await GenerateDrawingRelXmlAsync(cancellationToken).ConfigureAwait(false); await GenerateDrawingXmlAsync(cancellationToken).ConfigureAwait(false); - await GenerateWorkbookXmlAsync(cancellationToken).ConfigureAwait(false); + await GenerateWorkbookXmlAsync(false, cancellationToken).ConfigureAwait(false); await GenerateContentTypesXmlAsync(cancellationToken).ConfigureAwait(false); return rowsWritten.ToArray(); @@ -120,28 +122,20 @@ public async Task InsertAsync(bool overwriteSheet = false, IProgress? var existingSheetDto = _sheets.SingleOrDefault(s => s.Name == _sheetName); if (existingSheetDto is not null && !overwriteSheet) - throw new Exception($"Sheet \"{_sheetName}\" already exists"); + throw new InvalidOperationException($"Sheet \"{_sheetName}\" already exists"); // GenerateStylesXml must be invoked after validating the overwritesheet parameter to avoid unnecessary style changes. var styleBuilder = await GetSheetStyleBuilderAsync(cancellationToken).ConfigureAwait(false); - var sharedStringsEntry = _archive.GetEntry(ExcelFileNames.SharedStrings); - if (sharedStringsEntry is not null) - { -#if NET8_0_OR_GREATER - var sharedStringsStream = await sharedStringsEntry.OpenAsync(cancellationToken).ConfigureAwait(false); - await using var disposableStream = sharedStringsStream.ConfigureAwait(false); -#else - using var sharedStringsStream = await sharedStringsEntry.OpenAsync(cancellationToken).ConfigureAwait(false); -#endif - - var index = 0; - await foreach (var sharedString in XmlReaderHelper.GetSharedStringsAsync(sharedStringsStream, cancellationToken).ConfigureAwait(false)) - { - _sharedStrings.Add(sharedString, index++); - } - } - + var sharedStringsEntry = _archive.GetEntry(ExcelFileNames.SharedStrings); + if (sharedStringsEntry is not null) + { + foreach (var (key, value) in reader.SharedStrings) + { + _sharedStrings[value] = key; + } + } + int rowsWritten; if (existingSheetDto is null) { @@ -165,25 +159,11 @@ public async Task InsertAsync(bool overwriteSheet = false, IProgress? await GenerateSharedStringsAsync(cancellationToken).ConfigureAwait(false); _archive.Entries.SingleOrDefault(s => s.FullName == ExcelFileNames.DrawingRels(_currentSheetIndex - 1))?.Delete(); - await GenerateDrawinRelXmlAsync(_currentSheetIndex - 1, cancellationToken).ConfigureAwait(false); + await GenerateDrawingRelXmlAsync(_currentSheetIndex - 1, cancellationToken).ConfigureAwait(false); _archive.Entries.SingleOrDefault(s => s.FullName == ExcelFileNames.Drawing(_currentSheetIndex - 1))?.Delete(); await GenerateDrawingXmlAsync(_currentSheetIndex - 1, cancellationToken).ConfigureAwait(false); - - GenerateWorkBookXmls(out var workbookXml, out var workbookRelsXml, out var sheetsRelsXml); - foreach (var (key, value) in sheetsRelsXml) - { - var sheetRelsXmlPath = ExcelFileNames.SheetRels(key); - _archive.Entries.SingleOrDefault(s => s.FullName == sheetRelsXmlPath)?.Delete(); - await CreateZipEntryAsync(sheetRelsXmlPath, null, ExcelXml.DefaultSheetRelXml.Replace("{{format}}", value), cancellationToken).ConfigureAwait(false); - } - - _archive.Entries.SingleOrDefault(s => s.FullName == ExcelFileNames.Workbook)?.Delete(); - await CreateZipEntryAsync(ExcelFileNames.Workbook, ExcelContentTypes.Workbook, ExcelXml.DefaultWorkbookXml.Replace("{{sheets}}", workbookXml.ToString()), cancellationToken).ConfigureAwait(false); - - _archive.Entries.SingleOrDefault(s => s.FullName == ExcelFileNames.WorkbookRels)?.Delete(); - await CreateZipEntryAsync(ExcelFileNames.WorkbookRels, null, ExcelXml.DefaultWorkbookXmlRels.Replace("{{sheets}}", workbookRelsXml.ToString()), cancellationToken).ConfigureAwait(false); - + await GenerateWorkbookXmlAsync(true, cancellationToken).ConfigureAwait(false); await InsertContentTypesXmlAsync(cancellationToken).ConfigureAwait(false); return rowsWritten; @@ -287,7 +267,7 @@ private async Task WriteValuesAsync(MiniExcelStreamWriter writer, object va maxRowIndex = _printHeader ? count + 1 : count; await writer.WriteAsync(WorksheetXml.Dimension(GetDimensionRef(maxRowIndex, mappings.Count)), cancellationToken).ConfigureAwait(false); } - else if (_configuration.FastMode) + else if (_archive.Mode == ZipArchiveMode.Update) { dimensionPlaceholderPostition = await WriteDimensionPlaceholderAsync(writer).ConfigureAwait(false); } @@ -363,7 +343,7 @@ private async Task WriteValuesAsync(MiniExcelStreamWriter writer, object va await writer.WriteAsync(WorksheetXml.Drawing(_currentSheetIndex), cancellationToken).ConfigureAwait(false); await writer.WriteAsync(WorksheetXml.EndWorksheet, cancellationToken).ConfigureAwait(false); - if (_configuration.FastMode && dimensionPlaceholderPostition != 0) + if (_archive.Mode == ZipArchiveMode.Update && dimensionPlaceholderPostition != 0) { await WriteDimensionAsync(writer, maxRowIndex, maxColumnIndex, dimensionPlaceholderPostition).ConfigureAwait(false); } @@ -516,7 +496,17 @@ private async Task AddFilesToZipAsync(CancellationToken cancellationToken) foreach (var item in _files) { cancellationToken.ThrowIfCancellationRequested(); - await CreateZipEntryAsync(item.Path, item.Byte, cancellationToken).ConfigureAwait(false); + + var entry = _archive.CreateEntry(item.Path, CompressionLevel.Fastest); + +#if NET8_0_OR_GREATER + var zipStream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var disposableZipStream = zipStream.ConfigureAwait(false); + await zipStream.WriteAsync(item.Contents, cancellationToken).ConfigureAwait(false); +#else + using var zipStream = entry.Open(); + await zipStream.WriteAsync(item.Contents, 0, item.Contents.Length, cancellationToken).ConfigureAwait(false); +#endif } } @@ -537,17 +527,17 @@ private async Task GetSheetStyleBuilderAsync(CancellationTok } [CreateSyncVersion] - private async Task GenerateDrawinRelXmlAsync(CancellationToken cancellationToken) + private async Task GenerateDrawingRelXmlAsync(CancellationToken cancellationToken) { for (int sheetIndex = 0; sheetIndex < _sheets.Count; sheetIndex++) { cancellationToken.ThrowIfCancellationRequested(); - await GenerateDrawinRelXmlAsync(sheetIndex, cancellationToken).ConfigureAwait(false); + await GenerateDrawingRelXmlAsync(sheetIndex, cancellationToken).ConfigureAwait(false); } } [CreateSyncVersion] - private async Task GenerateDrawinRelXmlAsync(int sheetIndex, CancellationToken cancellationToken) + private async Task GenerateDrawingRelXmlAsync(int sheetIndex, CancellationToken cancellationToken) { var drawing = GetDrawingRelationshipXml(sheetIndex); await CreateZipEntryAsync( @@ -579,37 +569,49 @@ await CreateZipEntryAsync( } [CreateSyncVersion] - private async Task GenerateWorkbookXmlAsync(CancellationToken cancellationToken) + private async Task GenerateWorkbookXmlAsync(bool removeOriginalEntry = false, CancellationToken cancellationToken = default) { - cancellationToken.ThrowIfCancellationRequested(); - GenerateWorkBookXmls(out var workbookXml, out var workbookRelsXml, out var sheetsRelsXml); - + var (workbookXml, workbookRelsXml, sheetsRelsXml) = GenerateWorkbookXmls(); foreach (var (key, value) in sheetsRelsXml) { + var sheetRelsXmlPath = ExcelFileNames.SheetRels(key); + if (removeOriginalEntry) + _archive.Entries.SingleOrDefault(s => s.FullName == sheetRelsXmlPath)?.Delete(); + await CreateZipEntryAsync( - ExcelFileNames.SheetRels(key), + sheetRelsXmlPath, null, ExcelXml.DefaultSheetRelXml.Replace("{{format}}", value), cancellationToken).ConfigureAwait(false); } + if(removeOriginalEntry) + _archive.Entries.SingleOrDefault(s => s.FullName == ExcelFileNames.Workbook)?.Delete(); + await CreateZipEntryAsync( ExcelFileNames.Workbook, ExcelContentTypes.Workbook, - ExcelXml.DefaultWorkbookXml.Replace("{{sheets}}", workbookXml.ToString()), + ExcelXml.DefaultWorkbookXml.Replace("{{sheets}}", workbookXml), cancellationToken).ConfigureAwait(false); + if(removeOriginalEntry) + _archive.Entries.SingleOrDefault(s => s.FullName == ExcelFileNames.WorkbookRels)?.Delete(); + await CreateZipEntryAsync( ExcelFileNames.WorkbookRels, null, - ExcelXml.DefaultWorkbookXmlRels.Replace("{{sheets}}", workbookRelsXml.ToString()), + ExcelXml.DefaultWorkbookXmlRels.Replace("{{sheets}}", workbookRelsXml), cancellationToken).ConfigureAwait(false); } [CreateSyncVersion] private async Task GenerateSharedStringsAsync(CancellationToken cancellationToken) { - await CreateZipEntryAsync(ExcelFileNames.SharedStrings, ExcelContentTypes.SharedStrings, ExcelXml.SharedStrings(_sharedStrings), cancellationToken).ConfigureAwait(false); + await CreateZipEntryAsync( + ExcelFileNames.SharedStrings, + ExcelContentTypes.SharedStrings, + ExcelXml.SharedStrings(_sharedStrings), + cancellationToken).ConfigureAwait(false); } [CreateSyncVersion] @@ -690,23 +692,6 @@ private async Task CreateZipEntryAsync(string path, string? contentType, string _zipContentsMap.Add(path, contentType); } - [CreateSyncVersion] - private async Task CreateZipEntryAsync(string path, byte[] content, CancellationToken cancellationToken) - { - cancellationToken.ThrowIfCancellationRequested(); - - var entry = _archive.CreateEntry(path, CompressionLevel.Fastest); - -#if NET8_0_OR_GREATER - var zipStream = await entry.OpenAsync(cancellationToken).ConfigureAwait(false); - await using var disposableZipStream = zipStream.ConfigureAwait(false); - await zipStream.WriteAsync(content, cancellationToken).ConfigureAwait(false); -#else - using var zipStream = entry.Open(); - await zipStream.WriteAsync(content, 0, content.Length, cancellationToken).ConfigureAwait(false); -#endif - } - [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. */ diff --git a/tests/MiniExcel.OpenXml.Tests/MiniExcelIssueAsyncTests.cs b/tests/MiniExcel.OpenXml.Tests/MiniExcelIssueAsyncTests.cs index 8dbd0eb0..329d4f80 100644 --- a/tests/MiniExcel.OpenXml.Tests/MiniExcelIssueAsyncTests.cs +++ b/tests/MiniExcel.OpenXml.Tests/MiniExcelIssueAsyncTests.cs @@ -1558,4 +1558,4 @@ class Issue951 public object this[string test] => new(); } -} \ No newline at end of file +}